Compare commits

...
Author SHA1 Message Date
bflatasticandClaude Opus 5.5 3e40d9b1f1 Preset store: a missing store folder no longer warns about actions.json (Windows)
Opening the preset store read actions.json unconditionally and decided "the file is missing"
by matching the error text ("not found", "no such file", "os error 2"). On Windows, a file in a
folder that doesn't exist yet reports "The system cannot find the path specified (os error 3)",
so a fresh install (store folder created on the first save) got a spurious
"actions: actions.json skipped" warning at startup, and two engine tests failed on Windows.

actions.json is now read only when the store lists it, like index.json already was; the
error-text heuristic is gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 06:08:26 +07:00
b436950ae3 Layer Style: complete multi-instance effects and dialog parity (#157) (#786)
* layer style: instance-aware engine apply (layer.layerStyle.replace) with param overlays, contour presets and factory defaults

* layer style: instance-based dialog state preserves every effect; Preview checkbox gates the live preview

* layer style: Styles swatches and New Style… save the dialog pending state (style.presets.new effects param)

* layer style: Contour picker and Noise slider for shadows and glows (CPU-rendered noise; GPU falls back to the CPU for noisy effects)

* layer style: Make Default / Reset to Default per effect kind (user defaults persist with the presets)

* layer style: double-stroke band regression (manga case) and control-protocol docs for the instance-based dialog

* layer style: make Styles a top-level tab displaying preset swatches in the right panel

* i18n: translate the Layer Style preset strings in de and pt-br

The German and Brazilian Portuguese catalogs landed on main after this
branch added "Style {name} saved", "Make Default" and "Reset to
Default"; every_tl_literal_is_translated needs them.

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

* presets: move the stray BTreeMap import to the top of the module

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-07 19:05:15 -04:00
8948511eeb Large PSBs open with their pixels: decode planes past 2 GiB, plus 4-5 GB PSB fixtures (#375) (#782)
* PSD: decode planes up to 8 GiB, so large PSBs keep their pixels (#375)

MAX_DECODED_BYTES capped a single decode at 2 GiB. Real PSBs go past
that in one plane set: the merged image of a 30000x30000 RGB file is
2.7 GB, a 33000x33000 16-bit layer channel 2.2 GB. Opening such a file
gave a document with no layers (flattened files) or layers whose
channels were "treated as empty" (layered files). Saving hit the same
cap on the encode side and silently fell back to uncompressed data.

The cap is now 8 GiB on 64-bit targets and 2 GiB elsewhere, the budget
photocraft-codecs already uses for Limits::max_alloc. It stays a
backstop: the decoders bound their output by their input on their own
(RLE expands at most 64x, Raw needs every byte present). The Raw and
RLE output buffers are reserved with try_reserve_exact, so a machine
without that much memory gets an error instead of an abort.

generated with Claude Code

* PSD import: a channel that can't be decoded leaves its layer empty (#375)

The warning says the channel is "treated as empty", but the importer
filled a missing transparency channel with opaque alpha, so a layer
whose channels all failed became an opaque black rectangle over the
document. A channel that is present but undecodable now leaves the
whole layer empty (transparent), as the warning says.

tests/large_psb.rs: the damaged-channel case (PSD and PSB), and an
opt-in release test that opens a flattened 29000x29000 PSB whose
merged image decodes to 2.52 GB (16 s, 11.6 GB peak here).

generated with Claude Code

* Large-file fixtures: generate and check 4-5 GB PSBs (#375, #216)

photocraft-psd example big_psb writes five PSBs straight from the spec,
independent of this crate's writer and its limits: flattened and
layered, raw (4.4 and 4.8 GB files, data past 4 GiB) and RLE (small
files whose planes decode past 2 GiB, up to a 33000x33000 16-bit
layer). Every sample follows one formula, so photocraft-io example
big_psb_check opens a file through photocraft_io::import and checks
pixels without a reference; with SAVE set it saves as PSB and checks
the re-opened file.

Release build, this machine: all five open with correct pixels and
survive a PSB save and re-open (5-14 s per import). Before the 8 GiB
cap, three of the five opened empty or black.

Scorecard FILE-216-2 missing -> partial: no 2 GB PSD, 150-layer or
20 MP fixture yet, and nothing runs in CI.

generated with Claude Code

* psd: ZIP channels reserve fallibly and read in bounded chunks

A small ZIP channel declaring a huge decoded size could make
zip_decompress try a multi-GiB with_capacity/read_to_end and abort on
allocation failure. Reserve with try_reserve_exact/try_reserve, read
through take(expected) in 1 MiB chunks, and report LimitExceeded
instead. Tests: absurd declared sizes, past MAX_DECODED_BYTES, garbage.

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-07 18:59:39 -04:00
Carter SmithandBrandon Thomas 2a2459f57a Drag-and-drop: place onto the canvas in Free Transform, open at a tab slot (#739)
* Drag-and-drop: place onto the canvas in Free Transform, open at a tab slot

Like Photoshop, a file dropped on the active document's canvas is placed as an
embedded smart object layer and enters Free Transform (Esc takes the place back,
Enter makes place + transform one "Place Embedded" step; several files are placed
one after another). Dropped on the tab strip, it opens with its tab at the slot
under the pointer, shown by an insertion line while dragging. Elsewhere it opens
as before.

winit 0.30 drops carry no position, so the desktop shell reads the pointer from
the OS (apps/photocraft/src/cursor.rs: AppKit, x11rb, winsafe; no unsafe).

* Drop product names from comments, translate the place error, keep move_item panic-free

- Reword the new comments that named another product ("the reference app").
- "Couldn't place {name}: {error}" is a tl! string with entries in every
  complete catalog; `failed` now takes the finished message.
- The now-public `move_item` returns None for an out-of-range index instead of
  panicking (regression test in move_document's test).

* Tab moves as a document.move command, views follow their documents, drop the windows-sys lock bump

- document.move ({document?, to}) reorders tabs for the UI, agents and scripts; tab-slot drops and
  background opens go through it.
- sync_views keeps each view and its windows with their document by DocId, so moving or closing a
  tab never hands its view to another document.
- Restore os_pipe's windows-sys 0.52 lock entry (unrelated to this change).
- cursor::client_points: Windows pixel-to-point conversion, tested across monitors with different DPIs.
- de and pt-br strings for the place error.

---------

Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 18:57:50 -04:00
0675a253dd M9: create vertical type from the Type tool flyout (#784)
* M9: create vertical type from the Type tool flyout

* Tool::ALL holds 47 tools now that Vertical Type and Content-Aware Move both landed

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-07 18:53:56 -04:00
f7be9fbe2c fix(photocraft): reject an unparseable --control port instead of silently dropping it (#701) (#777)
* fix(photocraft): reject an unparseable --control port instead of ignoring it (#701)

The app read --control and PHOTOCRAFT_CONTROL_PORT with parse().ok(),
so a typo (78787), a missing value, or a following flag consumed as the
value left the app running with no control server and no diagnostic.
Validate both sources, name the offending value, and exit non-zero,
matching photocraft-cli serve --port. A missing --control value no
longer swallows the next argument.

* Exit with status 2 on a bad --control port instead of 0

A launcher or agent could not tell a rejected control port from a normal
quit. The collected port errors now map to exit status 2 (command-line usage
error) through control_args_exit_code, which is unit-tested.

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

---------

Co-authored-by: Brandon Fenty <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:47:06 -04:00
57452bfd22 Bound layer-group nesting at import and Group Layers (stack-overflow guard) (#796)
* Bound layer-group nesting at import and Group Layers (stack-overflow guard)

Everything that walks the layer tree (Document::walk behind nearly every
command, the layers panel, PSD export, .pcraft save/load) recurses once per
level, but nothing bounded how deep a document could get: a PSD whose records
nest groups a few thousand deep (each level costs only a divider + folder
record pair) overflowed the stack in the importer's build recursion, in
apply_link_groups, and again in every consumer - an abort no panic hook or
fuzz run catches. The one cap that existed lived in the .pcraft save path
(MAX_GROUP_DEPTH = 100), so such a file even imported 'fine' and then bricked
saving.

- photocraft-doc now owns the limit (MAX_GROUP_DEPTH = 100, the value the
  format crate already enforced) plus Document::max_group_depth(), an
  iterative depth measure that cannot itself overflow.
- PSD/PSB import rejects files nested deeper than the cap with an actionable
  Unsupported error, mirroring what the .pcraft loader already does; the
  never-fail psd_to_document keeps working on such files by capping the
  recursion and warning about the unimported remainder (this was the overflow).
- apply_link_groups (resource 1026) recurses over the same tree and is
  bounded the same way.
- layer.groupLayers refuses to nest past the cap (checked inside the edit, so
  a rejected call records no history step and leaves the document untouched).

* Enforce the group nesting cap in layer.moveTo and Artboard from Layers

Moving a group beside or into a deeply nested layer, or wrapping a deep
group in an artboard, could still build a tree deeper than MAX_GROUP_DEPTH.
A shared check_group_depth helper now rejects those edits with an error and
records no history step, like layer.groupLayers.

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-07 18:46:45 -04:00
f9479063be Rework Camera Raw filter preview and navigation (#781)
* Add Camera Raw source-resolution preview navigation

* Keep web preview responsive and discard stale refinement errors

* Preserve Alt wheel intent throughout smoothed Camera Raw zoom

* Specify refinement result type for the wasm fallback

* Bound sparse Camera Raw proxy domains and validate detail tests

* Document Camera Raw zoom controls with native screenshots

* Drop the Camera Raw zoom screenshots; keep the guide as docs/camera-raw-zoom.md

We don't commit sizable binary assets. Removes fit.png and actual-size.png,
their LICENSE.txt and ATTRIBUTION row, and moves the controls guide next to
docs/camera-raw-histogram.md.

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

* Translate the Camera Raw full-resolution notice in de and pt-br

Main added German and Brazilian Portuguese catalogues after this branch
forked; the i18n coverage tests need the new label in both.

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

---------

Co-authored-by: abc2cb <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:40:24 -04:00
c887ed94cc Add the Content-Aware Move tool (#213) (#814)
* Content-Aware Move: move or extend the selected pixels, the gaps filled content-aware (#213)

New command paint.contentAwareMove {offset, mode: move|extend, structure 1..7,
color 0..10, sampleAllLayers}. The content is pasted at the offset, its colour
fitted to the new place by `color` (0 keeps it, 10 is a full seamless clone),
and an edge band whose width `structure` sets is re-synthesised from the
surroundings. In move mode the old place is then filled with
content_aware::fill, never sampling the moved content (no ghost). The
selection follows the content. One undo step; runs as a background job with
progress and cancel. Works on layer pixels, masks and channels, at any depth.

Timing (retouch_bench, release, 6016x6016, elliptical selections dragged
300 px): 250x250 move 240 ms / extend 129 ms, 500x500 0.93 s / 0.53 s,
1000x1000 2.6 s / 1.5 s.

Known limit: at low Structure the wide band comes out smeared (the PatchMatch
vote blurs a thin band between high-contrast regions); that is the shared
completion, not this command.

generated with Claude Code

* Content-Aware Move Tool in the J group (#213)

Tool::ContentAwareMove next to the Patch Tool: outside the selection a drag
draws a lasso, inside it drags the selection and runs paint.contentAwareMove
on release (as a background job). Options bar: Mode (Move/Extend),
Structure, Color, Sample All Layers. The drag shows the moving outline; a
live preview of the result is not part of this change. New strings are
translated in every catalog. Scorecard TOOL-213-4: missing -> partial
(Transform On Drop and the live preview are still open).

generated with Claude Code

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:40:14 -04:00
BFlatandClaude Opus 5.5 727daf5d67 Copy works on smart object, type and shape layers (#541) (#841)
Edit > Copy only reads, so it now takes whatever the active layer shows
(a smart object's, type layer's or shape's rendered pixels), with or
without a selection, as Photoshop does. Cut still needs a pixel layer and
says a smart object is not directly editable.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 05:14:43 +07:00
Brandon ThomasandClaude Opus 5.5 5c94c5e2b6 rustfmt crates/engine/tests/hdr_gamma_direction.rs (Format red on main since #801) (#842)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:09:41 -04:00
Brandon ThomasandClaude Opus 5.5 25f211500b i18n(ko): drop the duplicate Ambient and Warmth rows (main red since #811 + #824) (#834)
Both PRs added Korean labels for Relight's options, so ko.tsv listed each twice and
i18n::tests::bundled_catalogs_are_consistent failed on main.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 18:02:45 -04:00
a20c63521d Help › Search: find any menu command by name (#815)
* Help › Search: find any menu command by name

A search field at the top of the Help menu (focused when the menu opens)
lists the menu commands matching what is typed, with their menu path and
shortcut: labels starting with the text first, then a word starting with
it, then anywhere in the label, then a match in the menu path only. Click a
result, arrow down to it and press Return, or press Return in the field to
run the first enabled one. Up to 15 results; the field scrolls with the
menu's rows.

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

* Help search: match translated menu names as well as English

Untranslated strings ("Search menus", "No matching commands") fall back to English
through tr() until translators add them.

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

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 18:02:34 -04:00
684f85e2e5 ⌘⌥⌃-click on the canvas selects the topmost layer under the pointer (#813)
* ⌘⌥⌃-click on the canvas selects the topmost layer under the pointer

With any tool, ⌘⌥⌃-click runs `layer.pickAt` at the pointer (the topmost
visible layer with pixels there becomes the selected layer) without
switching to the Move tool; the press, drag and release are swallowed so the
active tool never paints or draws. It runs before the ⌃⌥ brush-resize
gesture, which the same keys would otherwise trigger.

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

* quick_pick: document the macOS-only gesture, report a failed pick as an error

Windows and Linux never report mac_cmd, so the gesture is a documented no-op there and
Ctrl+Alt keeps resizing the brush. Tests cover both, plus a failed pick.

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

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 18:02:23 -04:00
Elvis33LE ec64eb773c Reject geometry params that would silently wrap through i64->i32 narrowing (#803)
`commands::int` returns an unbounded i64, and the geometry call sites narrowed
it with `as i32`, which truncates by multiples of 2^32: layer.translate moved
a layer 50 px for `dx = 2^32 + 50` and to a large negative offset for
`dx = 3e9`; select.rect and document.pixel read unrelated coordinates;
image.crop cropped a rectangle unrelated to the numbers passed; artboard
rects landed elsewhere. No panic, no Err - the document was silently
mutated and then autosaved, and the behaviour is identical in debug and
release, so panic_hunt cannot see it either.

- new `commands::int_i32(cmd, p, key)` narrows with a bad-params error
  instead of a wrapping cast; select.rect, document.pixel, layer.translate,
  image.crop and the artboard rect keys go through it
- image.crop builds its rectangle with saturating_add (x + w overflow)
- edit.contentAwareFill's `area` parser returns a bad-params error for
  malformed arrays (wrong length, non-numbers, non-finite) and saturating
  arithmetic for huge ones - `[1e30, 0, 1e30, 10]` overflowed the i32
  additions before (a debug-build panic, a garbage sampling window in
  release)
- filters' integer helper clamps out-of-range values like the neighbouring
  filter params instead of wrapping (`3e9` came back as a negative offset)
2026-10-07 18:02:12 -04:00
LunaandLuna 9d8a78a17e fix(ui-egui): New Document keeps the physical size when Resolution changes (#758) (#818)
Editing Resolution in the New Document dialog only wrote the `resolution`
field. With Width/Height shown in inches, cm, mm, points or picas the
pixel width/height stayed as they were, so the displayed physical size
was recomputed from the old pixels and the document came out at a
different print size (A4 at 300 -> 150 ppi became 16.53 x 23.39 in).

The Resolution field now goes through a new `set_resolution` helper:
with a physical unit it scales width/height by new_ppi / old_ppi (so
A4 becomes 1240 x 1754 px at 150 ppi, still 8.27 x 11.69 in) and drops
the `__preset` marker; with pixels it keeps the pixel count, matching
Photoshop.

The Pixels/Inch <-> Pixels/Centimeter dropdown needs no change: it only
switches how the number is displayed, the stored resolution stays in
ppi, so width/height and the physical size are already unaffected.

Co-authored-by: Luna <[email protected]>
2026-10-07 18:01:33 -04:00
LunaandLuna 0c976f42d5 fix(geom): Rect::width/height no longer overflow on i32-extreme edges (#675) (#808)
width() and height() computed `x1 - x0` / `y1 - y0` with plain i32
subtraction. For extreme edges (e.g. an inverted i32::MAX..i32::MIN
rect, or a valid rect spanning most of the i32 range) the subtraction
overflows: debug builds panic, release builds wrap. warp.* commands
build a Rect straight from the JSON `rect` param, so an extreme value
reached this from user input.

Both now return the exact extent via `abs_diff` when the far edge is
past the near one, and 0 otherwise. The extent of any i32 pair fits in
u32, so no clamping or casting is needed. A plain `saturating_sub`
would avoid the panic but turn a wide valid rect into a width of
i32::MAX, which is wrong.

Follow-up (not in this change): warp_cmds still accepts absurd `rect`
params; it should reject them with a parameter error up front.

Co-authored-by: Luna <[email protected]>
2026-10-07 18:00:59 -04:00
bfentyandBrandon Fenty a508ee9014 fix(engine): clone source_rect clamps huge mapped coordinates (#716) (#809)
Mapping::source_rect cast input-derived floats with `as i32` and then
added the bilinear apron. A large `source` (e.g. 3e9) with a non-
translation mapping saturates the cast to i32::MAX and the `+ 2`
overflows: debug panics, release wraps to an inverted rect and samples
the wrong region silently.

Clamp to the i32 range before casting and apply the apron with
saturating arithmetic, matching the sibling Rect::inflate convention
AGENTS.md prescribes for input-derived offsets. Normal-sized sources
produce bit-identical rects.

Adds unit tests: the issue's 3e9 + 45-degree repro stays ordered
(panics on main at clone_source.rs:127), a negative huge source
clamps the same way, and a normal mapping's rect is unchanged.

Co-authored-by: Brandon Fenty <[email protected]>
2026-10-07 18:00:21 -04:00
TiredIsaandClaude Opus 5.5 4b3da49486 Camera Raw: read and write settings as a live PSD Smart Filter (part of #215) (#459)
* Camera Raw: read and write settings as a live PSD Smart Filter (part of #215)

- Map Camera Raw 18.4 (process 5/6) filter settings to and from PSD: custom white
  balance, Light, Presence, parametric and four point curves, HSL, Color Grading,
  sharpening, noise, grain and numeric post-crop vignette controls.
- Keep the original Photoshop descriptor; edits patch only changed fields. Unknown
  settings or versions stay an opaque filter, preserved byte for byte.
- PSD stores integer slider levels: export rounds continuous PhotoCraft values
  (hue 360 -> 0) instead of dropping the filter.
- Tests: field mapping, patching, opaque fallback, malformed input, rounding, and
  8/16/32-bit PSD -> edit -> .pcraft -> PSD with mask, filter order and undo/redo.

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

* Camera Raw PSD: use as_chunks for curve pairs (clippy 1.99)

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

* Camera Raw PSD: no product names in Rust comments, strings or test names

Only the descriptor class key itself keeps its literal name.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:59:16 -04:00
Nigel Ihza Firdausy 2b8be01d6f Handle Stack Mode sources with no visible layers (#770)
* fix(engine): reject empty smart-object stack sources (#717)

* test(engine): cover stack mode with no visible layers (#717)

* style: format regression coverage for #717

* test(engine): reproduce hidden Stack Mode sources through commands
2026-10-07 17:57:43 -04:00
Nigel Ihza Firdausy b62664ad33 fix(ui): cancel stale warp drags when the mesh changes (#757) (#774) 2026-10-07 17:57:33 -04:00
BFlatandClaude Opus 5.5 6b926cdecd DX12: load the shader compiler from System32 or beside the exe, never from PATH (#712) (#817)
wgpu's default DX12 compiler (`Auto`) loads `dxcompiler.dll` by name, and the Windows DLL search
falls through to the current directory and PATH, so another program's DXC build (a Chrome or SDK
folder) was loaded and crashed shader compilation or failed device creation. The app now uses FXC
(d3dcompiler_47.dll, part of Windows) unless a dxcompiler.dll ships beside photocraft.exe, which is
loaded by its full path. WGPU_DX12_COMPILER still overrides.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:48:27 -07:00
bfentyandBrandon Fenty 8ce0684759 fix(engine): view.newGuideLayout rejects absurd column/row counts (#704) (#797)
columns/rows came straight from params with no cap. layout_lines loops
once per requested line and each push_unique scans the growing guide
vector, so a count like 200000000 (well inside u32) never returned -
blocking the app, CLI, MCP and the TCP control channel.

Reject counts above 1000 with an actionable BadParams naming the
offending parameter. Photoshop's own dialog caps at 32; 1000 is a
generous ceiling whose worst case finishes instantly, so legitimate
grids are untouched.

Adds regression tests: the issue repro (200M columns, and rows)
returns an actionable error immediately, and bounded layouts at the
cap still produce their guides.

Co-authored-by: Brandon Fenty <[email protected]>
2026-10-07 14:47:42 -07:00
reese 4b80b031bc Auto-Align Layers: the dialog no longer sends reference 0 (#674) (#795)
* fix(dialogs): Auto-Align no longer sends reference 0 (#674)

* style(xtask): rustfmt the i18n coverage test
2026-10-07 14:46:13 -07:00
bfentyandNigel Ihza Firdausy 38d23b621f fix(engine): keyboard shortcut bulk set reports in-call clashes (#719) (#799)
keyboard shortcut bulk set reports in-call clashes in conflicts instead of silently dropping one binding (#719). Supersedes #771.

Co-authored-by: Nigel Ihza Firdausy <[email protected]>
2026-10-07 14:46:08 -07:00
bfentyandBrandon Fenty 385e8979de fix(algo): HDR Toning gamma follows its documented direction (#710) (#801)
The field documents "1 = neutral; lower = more contrast", but the tone
map used gamma directly as the powf exponent, so 0.5 lightened
midtones and 2.0 darkened them - the slider ran opposite to its own
contract. The crate's other two gamma controls (hdr.rs, proof_sim.rs)
already use the reciprocal exponent.

Apply 1.0 / gamma like they do. Neutral (1.0) is unchanged; every
other value now moves midtones the documented way.

Adds a regression test on a flat 50% grey: gamma 0.5 darkens the
midtone, gamma 2.0 lightens it (reversed on main: 0.71 / 0.25).

Co-authored-by: Brandon Fenty <[email protected]>
2026-10-07 14:46:04 -07:00
bfentyandBrandon Fenty 080f2c4c8d fix(algo): gallery colour injection covers Colored Pencil and Neon Glow (#709) (#805)
uses_colours() derived from the params notation, but Colored Pencil
(paper tint from the background) and Neon Glow (foreground mixed over
background) read the colours in their pixel code without listing them.
The single-filter commands therefore skipped colour injection and both
filters always ran with the default white/black regardless of the
session colours, while filter.filterGallery honoured them.

Add the two filters to uses_colours() at the source, so the command
injection, the gallery dialog note, and smart-filter re-renders all
agree.

Adds regression tests: filter.gallery.coloredPencil and
filter.gallery.neonGlow output must change with the session
background (both identical on main).

Co-authored-by: Brandon Fenty <[email protected]>
2026-10-07 14:45:59 -07:00
bfentyandBrandon Fenty f15fb4ee6e fix(algo): blur small sigmas instead of silently no-oping (#706) (#783)
gauss_box_radii followed Kovesi 2010 exactly, and the rounded triple
degenerates below sigma ~0.577: wl floors to 1 and all three radii
become 0, so every box pass early-returned and gauss_blur_n handed the
buffer back untouched for any sigma in [0.3, 0.577). Callers with
documented small sigmas (Camera Raw sharpenRadius 0.5 and colour noise,
HDR Toning radius 1, Shadows/Highlights mask blur, Oil Paint
cleanliness) silently did nothing in that band.

Clamp the degenerate case to one radius-1 pass - the smallest real
blur - which only engages where the ideal box widths are already at
their floor, leaving every sigma the paper handles well unchanged.

Adds the command-level regression test from the issue: cameraRaw
sharpenRadius 0.5 must change pixels (control: radius 3.0).

Co-authored-by: Brandon Fenty <[email protected]>
2026-10-07 14:45:54 -07:00
reese 48bfaa9d0d fix(file): open .psdt templates as new untitled documents (#807)
Templates show up in the Open dialog and open as the first free
Untitled-N without a path, so File › Save asks where to save instead of
offering the template. They still go to Open Recent. This covers every
way a file opens: File › Open and Open Recent, drops and launch
arguments, background opens, the control channel's app.open, file.openAs
and headless doc_open.
2026-10-07 14:45:49 -07:00
reese 9a9eb013db fix(layers): double-clicking just above or below a layer name renames it (#823)
The rename hit box was only the name's glyph height (about 15 px of a 32 px row), so a double-click a few pixels off vertically opened Layer Style. The name's column now spans the row's full height; to the right of the name still opens Layer Style. Refs #651.
2026-10-07 14:45:44 -07:00
BFlatandLuna 03ffca0d54 Dialog buttons follow the OS order; Windows/Linux unsaved prompt asks Yes / No / Cancel (#627, #776) (#826)
Dialog buttons follow the OS order through one shared widgets::dialog_buttons helper (fixes #776, #627). Supersedes #802, whose reordering of the unsaved-changes and Revert prompts this carries to every dialog.

Co-authored-by: Luna <[email protected]>
2026-10-07 14:45:40 -07:00
Tanish MankuandClaude Opus 5.5 41e9c742dc Headless saves write a flat TIFF unless they ask for layers (#825)
Maintainer decision on #672: the app's Save As keeps TIFF layers as Photoshop does (it sets
ExportOptions.tiff_layers from its Layers option), but scripted and agent saves stay flat by
default so conversions keep predictable sizes. #672 merged with the default on, so every
headless TIFF save became layered.

- photocraft-io: ExportOptions::default() has tiff_layers: false. The desktop and web Save As
  already set it explicitly from their settings, so the app is unchanged.
- photocraft-cli: --tiff-layers on convert, run and batch (listed in the usage text).
- engine: file.saveACopy, Batch, Image Processor, the export-to-files commands and the lens
  batch take "tiffLayers": bool=false, through one SaveOpts { quality, tiff_layers }.
- automation: serve doc.save and MCP doc_save take tiffLayers (default false).
- Tests: default ExportOptions write a flat TIFF; the CLI writes flat without the flag and
  layered with it (run and convert); #672's layered-TIFF tests ask for layers explicitly.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:35:31 -07:00
BFlatandClaude Opus 5.5 15af216bd1 Follow-ups to #665, #666, #734: Photoshop's mask-only Remove Background, one-step eye sweep, no-clone Show Only (#824)
* layer.showOnly: no document clone, keep layers added during a solo, more tests

- Compute the rows from a borrow instead of cloning the whole document, and set
  visibility by path (one walk) instead of an id lookup per layer.
- Restoring keeps the current visibility of layers the snapshot doesn't know
  (added while one layer was shown alone) instead of leaving them unspecified.
- Tests: enclosing groups stay visible and a group's contents keep their own
  visibility, an undo ends the solo, one history step, no snapshot shows every
  layer, bad layer ids and no document return errors.

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

* Layers eye sweep: one history step, no skipped rows on a fast drag

- Every layer.setProps of one eye-column drag shares a coalesce key, so the
  whole sweep is one "Layer Visibility" undo step (it was one per row).
- A row counts as swept when the pointer passed it since the last frame, so
  a fast drag that jumps a row in one frame no longer skips it.
- The sweep lives in a small EyeSweep helper instead of inline in layer_row.
- layer.setProps names a visibility-only change "Layer Visibility" also when
  it carries a coalesce key.
- The harness test also checks one undo step and a single-move drag.

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

* Layers panel: hidden effects and smart filters get an empty eye box too

Matches the layer rows (and Photoshop): an effect or smart filter that is off
leaves its eye box empty instead of drawing a struck-out eye, so the whole
eye column of the Layers panel reads the same way.

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

* Remove Background: Photoshop's mask-only Quick Action, reusing Select Subject's sampler

Photoshop's Remove Background is a Properties (and contextual task bar) Quick
Action that analyses the selected layer and adds a layer mask from Select
Subject; it has no Layer menu item and no destructive variant.

- No menu path (drops the Layer › Remove Background placement); it stays
  reachable from Quick Actions, the command palette, the CLI, the control
  channel and MCP.
- Always a layer mask: drop the `output: "transparency"` bake and the `fill`
  layer (both are existing commands: Layer Mask › Apply, New Fill Layer) and
  the `radius` knob.
- Analyse the layer alone by default (`sampleAllLayers` false), as Photoshop
  does for the selected layer; true samples the composite.
- Reuse Select Subject's sampler: smartselect_cmds gains `with_doc_sampler`
  (what `with_sampler` now calls) instead of a second CompositeSampler and
  find/refine helpers.
- Promote the Background only once a subject is found.
- Tests: mask at 8/16/32-bit with untouched pixels, one history step,
  undo/redo, Background promotion, no subject, layer-alone default vs
  sampleAllLayers, disabled states and bad params, replacing a mask.

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

* Korean labels for Relight's Ambient and Warmth (i18n coverage test red on main after #735)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-08 04:25:22 +07:00
Tanish MankuandClaude Opus 5.5 e1cf321183 rustfmt crates/ui-egui/src/export_dialog.rs (Format red on main since #672 + #733) (#822)
Formatting only: #672 and #733 both changed export_dialog.rs and were each
formatted on their own branches; merged together, cargo fmt --check fails.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:22:39 -07:00
Tanish MankuandClaude Opus 5.5 b6f5df3b3f pcraft directory saves: re-verify a rolling share of cached objects; stronger file signature (#821)
Follow-up to #752. After a directory save verifies an object, later saves trust it while its
file signature is unchanged. A file damaged in place with the same length and modification
time (bit rot, or a tool that restores timestamps) was trusted until the writer was recreated.

- The signature also carries inode, device and change time on Unix, which an in-place rewrite
  changes even when the modification time is restored.
- Each directory save re-reads and re-checks cached objects, oldest-checked first, up to
  DEFAULT_REVERIFY_BUDGET (16 MiB of compressed data; at least one object), so over enough
  saves every object is read again and a damaged one is rewritten. Repeat saves of small
  documents stay a full check; large ones pay a bounded cost per save.
  PcraftWriter::set_reverify_budget tunes it (0 = trust signatures, u64::MAX = check all).
- PcraftWriter's doc comment states what the cache trusts.
- Tests: a same-size, same-mtime tamper is repaired on the next save (fails with the budget
  at 0 on Windows, where there is no change time), and with a 1-byte budget the rolling
  re-check reaches the damaged object within one pass over the objects.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:20:36 -07:00
6ee3e4f752 Flat exports embed no document XMP by default; Export As gains a Metadata choice (#647) (#733)
* Flat exports embed no document XMP by default; Export As gains a Metadata choice (#647)

The document's XMP packet lists the text of every type layer
(photoshop:TextLayers) and one id per placed document
(photoshop:DocumentAncestors) - data a flat web image should not carry:
14-116 KB per export on real designer PSDs, and layer text leaking into
images that show only part of the design.

- io: ExportOptions.xmp (XmpEmbed::All | None, default None) drops the
  packet from flat exports before the codecs see it. Layered saves
  (PSD, PSB, .pcraft) always keep everything; DPI, ICC and EXIF are
  unaffected. Export As, Save a Copy, the CLI, Layers/Artboards to
  Files and Batch all share this path and get the safe default.
- The Export As dialog gains a Metadata dropdown (None / All, default
  None, like the source application's Export As); the file-size
  estimate follows the choice.
- Tests: new xmp_export.rs covers the default (png/jpg/webp/tif carry
  no packet), the All opt-in and the layered always-keep;
  metadata_roundtrip_png and the stale-orientation test document the
  new contract (with All, an embedded stale orientation is still
  rewritten upright).

* Export As alone starts at Metadata: None; Save As and conversions keep the XMP

ExportSettings::default() and ExportOptions::default() also serve File > Save As, file.saveAs,
the CLI and automation, which keep the document's metadata (as Photoshop's Save As does).
Only the Export As dialog defaults to None. A layered TIFF honours the choice too.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:19:17 -04:00
Brandon ThomasandClaude Opus 5.5 83ace1138e rustfmt crates/ui-egui/src/canvas.rs (Format red on main since #711) (#819)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:17:38 -04:00
9ef9992878 fix(automation): close ambient filesystem authorization gaps (#763)
* fix(automation): block ambient filesystem bypasses

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

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* test(automation): cover ambient path denial

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

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* fix(automation): guard filesystem path authorization

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

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

* automation: judge the first non-empty prefs segment; refuse any path without data

prefs.set skips empty path segments, so `.scriptEvents.enabled` reached
script events past the section check. Preset import / plugin.install
with a whitespace path also read the filesystem.

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]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:14:03 -04:00
Alex GorevskiandCopilot 1b24b5212b Repair corrupt directory-save objects (#752)
* Fix directory save reuse of corrupt objects

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

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Avoid revalidating unchanged directory-save objects

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

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:13:09 -04:00
Alex GorevskiandCopilot e9142754a4 Fix text ink bounds overflow (#750)
* Fix text ink bounds overflow

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

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Validate type size and tracking parameters

Reject out-of-range text metrics at type command boundaries to avoid silently empty renders.

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

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:12:55 -04:00
4a0a8d0794 Actions: actions.record/stop/play/list commands for agents and the CLI (part of #219) (#622)
* Actions: actions.record/stop/play/list commands for agents and the CLI (part of #219)

The Actions list moves from UiState to the engine Session, so the panel, photocraft-cli, the control channel and MCP share one list. actions.play checks every nested step with the same authorization a top-level command gets in untrusted sessions.

* Regenerate docs/scorecard.md after rebase

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

---------

Co-authored-by: Matt Van Horn <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:12:52 -04:00
Alex GorevskiandCopilot 51a0816f99 Bound paint stroke work (#678, #679) (#751)
* Bound paint stroke work

Cap airbrush catch-up for long timestamp gaps and enforce safe brush dimensions before rendering, with a lower-level renderer guard and regression coverage.

Fixes #678

Fixes #679

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

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* fix: align paint brush limits and airbrush catch-up

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

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:12:49 -04:00
Alex GorevskiandCopilot 165e11ad6a Fix hostile PSD pattern geometry (#743)
* Fix hostile PSD pattern geometry

Use checked rectangle extents and reject channel rectangles that differ from their parent tile, preventing panic-prone subtraction and huge zero-filled plane allocations. Bound aggregate decoded pattern data and PackBits expansion using parser limits and payload size. Add regression coverage for .pat, PSD global pattern blocks, and .abr embedded patterns.

Fixes #682
Fixes #683

Co-authored-by: Copilot <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Preserve bounded PSD pattern channel placement

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

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Co-authored-by: Copilot <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:12:44 -04:00
Nigel Ihza Firdausy 1a052d6115 Reject overflowing slice rectangles (#768)
* fix(engine): reject overflowing slice rectangles (#715)

* test(engine): cover slice rectangle overflow (#715)

* refactor(engine): keep slice rect validation explicit (#715)

* test(engine): exercise overflowing slice commands (#715)

* fix(engine): reject invalid slice.set geometry before mutation
2026-10-07 17:12:39 -04:00
bd632e7eb3 Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215) (#672)
* Layered TIFF: open and save Photoshop layer data in TIFF files, in either byte order (part of #215)

Photoshop keeps a layered TIFF's layers in two private tags: 37724 (ImageSourceData, the same
layer section a PSD has) and 34377 (the image resources). PhotoCraft now reads and writes both,
so a TIFF opens with its groups, masks, layer styles, type, shapes, smart objects and adjustment
layers exactly as the PSD path would, and saves them back.

* photocraft-psd: a `tiff` module with `ImageSourceData` (parse/write, PSD- and PSB-sized),
  the 34377 resource helpers, and a field-by-field byte-order transcoder. In Intel-order TIFFs
  Photoshop byte-swaps the whole block structure (reversed keys, little-endian integers and
  doubles) while pixel samples stay big-endian; the transcoder knows the layer-info structure,
  masks, blending ranges and every block photocraft-io maps (descriptors, TySh, lrFX, the
  binary adjustments, paths, patterns, filter effects, metadata settings, linked layers,
  annotations...), recognizes descriptor-shaped unknown blocks, and drops the rest with a
  warning instead of passing byte-swapped data to other readers. The reader gained an optional
  little-endian mode. 23 unit tests; a fuzz target in the CI matrix.
* photocraft-codecs: the two tags travel as opaque bytes on `Metadata`, read by a
  bounds-checked walk of IFD0 (classic and BigTIFF, both byte orders) and written back as
  BYTE / UNDEFINED tags. `tiff_writes_little_endian()` tells which order the encoder produces.
* photocraft-io: `tiff_layers` rebuilds a PSD model from the TIFF's pixels plus the tags on
  import, and on export runs the PSD exporter (new `merged_matte` / `merged_compression`
  options), interleaves its merged planes for the TIFF image and stores the layer section in
  the encoder's byte order. `ExportOptions.tiff_layers` (default on). Lab and Multichannel
  documents save flat; extra alpha channels are not written (one transparency sample).
* UI: the TIFF Options prompt ("Including layers will increase file size." / "Discard Layers
  and Save a Copy" / "Don't show again") behind Preferences > File Handling > Ask Before
  Saving Layered TIFF Files; `ExportSettings.tiff_layers`; catalogs updated.
* Tests: io round trips at 8/16/32 bits in Gray/RGB/CMYK, equivalence with the PSD path, every
  TIFF compression, the flat fall-backs, malformed input, and files written by psdtags in both
  byte orders (fixtures from scripts/layered_tiff_fixtures.py); a corpus test (feature
  `corpus`) writes every corpus PSD as a layered TIFF and compares the result with the PSD
  round trip (700 files, 0 differences, 0 crashes). Codecs tests cover the tag walk on
  hand-made files.
* Scorecard FILE-215-7 done, BUG-204-3 partial; README and book updated.

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

* Layered TIFF: de and pt-br strings; neutral wording in the psd tiff docs

The four psdtags fixtures stay out of the repository (no binary test data here): the
generator now writes them to the gitignored corpus/layered-tiff/, and the tests that read
them skip with a message when absent.

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

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: leon <[email protected]>
Co-authored-by: Claude Mythos 5.1 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 17:10:35 -04:00
Alex GorevskiandCopilot 8302f57190 Fix vector pixel bounds overflow (#747)
* Fix vector pixel bounds overflow

Saturate exclusive pixel bounds at i32::MAX and cover extreme and ordinary paths with regression tests.

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

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Cover extreme vector path fill commands

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

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:10:21 -04:00
Alex GorevskiandCopilot d9a0f38280 fix(raw): validate DNG default crop bounds (#745)
* fix(raw): validate DNG default crop bounds

Fixes #692

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

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* fix(raw): fall back for invalid DNG default crops

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 17:10:03 -04:00
Tanish MankuandClaude Opus 5.5 7080e6c56a Korean labels for Relight's Ambient and Warmth options (#811)
korean_covers_generated_filter_options_and_gallery_names (from #604) requires a
Korean label for every generated filter option. Relight (#735) added the options
ambient and warmth; its CI ran on a base without #604's test, so main now fails
it. Adds the two catalog entries.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:04:29 -07:00
2baa95d684 Type: hitTest, caret and navigate commands so agents can edit text by position (part of #219) (#623)
* Type: hitTest, caret and navigate commands so agents can edit text by position (part of #219)

Stateless queries in document pixels. The word, line and hit-test helpers move from type_tool.rs into photocraft-text, and the Type tool calls the same code.

* Regenerate docs/scorecard.md after rebase

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

---------

Co-authored-by: Matt Van Horn <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:02:09 -04:00
9e88c65d33 Support Alt-held polygon segments in the freehand lasso (#711)
* Support Alt-held polygon segments in the freehand lasso

* Translate the Alt lasso hints into Simplified Chinese

* Test mixed freehand and Alt-click lasso gestures

* Lasso: a press inside the selection still drags it; Alt lasso hints in every catalog

The Alt-polygon lasso takes over the Lasso's pointer events, so the selection drag from
#686 (press inside the ants to move the outline, Cmd-drag to float the pixels) no longer
reached the canvas. Hand those presses, and the drag they start, back to the canvas path.
Also adds the two new hint strings to every translation catalog.

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-07 16:57:21 -04:00
2cbb13a79d fix(i18n): follow native UI languages on first launch (#661)
* fix(i18n): follow native UI languages on first launch

* i18n system tests: German and Portuguese are supported now; use Swedish as the unsupported tag

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-07 16:52:55 -04:00
reese 1353917f15 fix(retouch): Spot Healing honours Sample All Layers (#769) 2026-10-07 13:50:13 -07:00
fb924dad16 Shapes: a hairline vector stroke no longer darkens the shape's edge pixels (#397)
* Shapes: a hairline vector stroke no longer darkens the shape's edge pixels

A stroked shape with layer effects (or clipped layers) is drawn as our
fill plus a vector stroke fitted to Photoshop's own pixels: where those
pixels reach past our fill, the stroke takes the alpha the fill doesn't
explain, `(pixels - fill) / (1 - fill)`. It kept our stroke's colour for
that alpha. When Photoshop's fill covers more of an edge pixel than ours
(shapes snapped to whole pixels with Align Edges, our path a fraction off
the grid), the fitted alpha is mostly Photoshop's fill, so a 0.25 px grey
border on a white card was painted as a solid grey line: the edge came out
at the stroke's full colour (122) instead of Photoshop's 220.

The fitted stroke now takes the colour that, drawn Normal over our fill,
gives back Photoshop's pixel (un-compositing the premultiplied colour),
and keeps our colour only where the fitted alpha is negligible. Where
Photoshop's pixels agree with our fill this is our stroke's colour, as
before.

Test: hairline_vector_stroke_keeps_photoshops_edge_pixel_colour (edge
pixel 0.478 before, 0.86 after). The compose, gpu, io and engine tests
and the psd/io/engine corpus tests pass.

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

* Shapes: neutral wording in the hairline-stroke fix; test it at 8, 16 and 32 bits

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

---------

Co-authored-by: EthanSuquet <[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-07 13:50:09 -07:00
a1f4387e63 fix(codecs): correct TIFF polarity and BigTIFF orientation (#746)
Use the TIFF decoder's normalized WhiteIsZero samples without a second inversion, and parse BigTIFF IFD0 Orientation so decode applies the recorded rotation.

Fixes #691

Fixes #693

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

Co-authored-by: Tanish Manku <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 13:50:04 -07:00
702b4aa6b0 Fix ui.set brush size command journaling (#744)
Co-authored-by: Copilot <[email protected]>

Co-authored-by: Tanish Manku <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 13:49:59 -07:00
Nigel Ihza FirdausyandTanish Manku ac57c17b14 Clamp Variables data-set indices from control input (#772)
* fix(ui): bound Variables data-set index from control input (#756)

* test(ui): cover hostile Variables data-set indices (#756)

* style: format regression coverage for #756

---------

Co-authored-by: Tanish Manku <[email protected]>
2026-10-07 13:49:54 -07:00
151afe47b6 fix(automation): downscale bridge screenshots before PNG budget (#753)
Co-authored-by: Copilot <[email protected]>
Co-authored-by: Tanish Manku <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 13:49:50 -07:00
3ad26e3b51 Fix document run and ID overflow (#684, #685) (#749)
Co-authored-by: Copilot <[email protected]>

Co-authored-by: Tanish Manku <[email protected]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 13:49:46 -07:00
Tanish MankuandClaude Opus 5.5 f46c462129 rustfmt crates/codecs/tests/deep_exr.rs (Format red on main since #642) (#804)
Formatting only: cargo fmt --check (rustfmt 1.99, as CI) fails on main at
crates/codecs/tests/deep_exr.rs:360 since #642 landed.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:47:55 -07:00
b70bf524ea Filter › Render › Relight: redirect a photo's lighting (#735)
* Add Filter › Relight to redirect a photograph's lighting

Introduce a tiled Retinex and Lambertian kernel with angle, elevation,
intensity, ambient, warmth and softness. Wire it as filter.render.relight
with a live-preview dialog, selection support, and smart-filter recording.

* Tidy Relight kernel loops

Rewrite the per-channel colour multiply and alpha restore so the filter
builds cleanly under clippy.

* Format the Relight kernel and enablement check

Keep rustfmt's wrapping on the alpha restore and collapse the lock
predicate to a single expression.

* Step Relight shading gradients at the blur scale

A one-pixel difference vanishes on large documents, so the lamp
collapsed to a flat multiply. Sample S at ±σ and keep a 256 px
sphere direction check so that does not regress.

* i18n: German entry for Filter › Render › Relight

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

* i18n: Brazilian Portuguese entry for Filter › Render › Relight

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-07 16:45:57 -04:00
6c7d01a0f3 Selections: drag to move the outline; ⌘-drag floats the selected pixels (engine commands) (#686)
Engine (scripts, the control channel and agents can drive it):
- `select.float {"dx","dy"}`: cuts the selected pixels of the active layer
  into a floating piece the first time, then moves it by whole pixels.
  The document is unchanged while it floats (`DocState::floating`);
  `float_cmds::displayed` gives the document with the piece at its offset.
- `select.drop`: drops the piece into its layer and moves the selection
  with it, one history step ("Move Selected Pixels").
- Any other command drops a floating piece first (a hook at the top of
  dispatch, so it lands where it was shown); Undo puts it back instead.
- The layer is split once, only inside the selection, and the piece is
  cropped to it: placing it costs the piece, not the layer (6000×4000
  layer, 500×400 piece: 18 ms to cut, under 5 ms per move).

Canvas (Marquee / Lasso):
- A drag inside the ants moves the outline (`select.transformSelection`);
  a click inside without moving deselects; ⇧ / ⌥ drags still draw.
- ⌘-drag floats the selected pixels and shows them at the pointer live;
  plain drags on the piece move it again; a press elsewhere drops it.
- A move cursor over the ants; the ants follow the dragged / floating piece.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 16:45:31 -04:00
107ffe9127 codecs: decode deep OpenEXR (deepscanline/deeptile), composited flat or structured (#642)
* codecs: decode deep OpenEXR (deepscanline/deeptile), composited flat or structured

Deep EXR files errored with "not supported: deep data not supported yet"
(the `exr` crate rejects deep headers, and its chunk framing for them is an
unfinished guess), so the chunk stream is walked directly from the bytes, per
the OpenEXR file-layout specification:

- offset tables after the headers; deep chunks of [y | tile coords, packed
  table size, packed data size, unpacked data size, compressed cumulative
  sample-count table, compressed sample data],
- NONE, RLE and ZIPS (zlib, the +128 difference predictor and the half-split
  interleave - applied to the count table and the sample data alike; a stored
  payload of uncompressed length is raw). ZIP-16 and mip/rip-map deep tiles
  decline with `unsupported`,
- samples are channel-grouped, per pixel; u32 counts cap the allocation and
  go through `Limits` before anything is held in memory.

`decode` composites to the flat image (samples sorted by Z, front-to-back
`over` with premultiplied colour, un-premultiplied to straight alpha) and
reports a new DecodeWarning::DeepFlattened; `decode_deep_exr` returns the
structured DeepImage (per-channel samples plus cumulative counts) for callers
that composite deep data themselves. exr::Error::NotSupported now maps to
CodecError::unsupported instead of malformed, like the TIFF codec.

Validated against the OpenEXR repository's deep test images (11/42/64/objectid
.deep.exr: 256x256, up to 8 samples/pixel, plausible z and alpha), and covered
by a synthetic generator (NONE/RLE/ZIPS x scanline/tile) with hand-computed
compositing oracles, truncation sweeps and hostile offsets/sizes (13 tests).

* Deep EXR: match the OpenEXR layout (RLE tokens and predictor, per-row tile tables and data), keep HDR

Checked against deep files written by the OpenEXR 3.5 library (scan line and tiled,
NONE/RLE/ZIPS): RLE tokens were inverted and skipped the predictor and byte split, the
count table of a tile restarts at every row, a tile's sample data is grouped per row, edge
tiles may store a full-size table, and the flattened colour was clamped to 1.0. The
synthetic writer in the tests now follows the same layout; new HDR / edge-tile test.

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:45:02 -04:00
3f637410cb Apply the Interface UI Font Size preference (#616)
* Apply the Interface UI Font Size preference

* Regenerate the scorecard after rebase (58 unread settings); drop the before/after screenshots

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-07 16:43:03 -04:00
yangandClaude Opus 5.5 fee9ff9613 Find and Replace Text: no panic searching a non-type active layer (#703) (#762)
With allLayers: false the search keeps only the active layer; when that
layer isn't a type layer the list is empty, and the find loop indexed
layers[0] on it (find, change and changeFind). Read the layer with
get() and stop when there is none, so the command finds nothing and
returns {"found": null}, as it does when nothing matches.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:42:29 -04:00
Tanish MankuandClaude Opus 5.5 6103da5278 rustfmt xtask/src/i18n_coverage.rs (Format red on main since #663 + #700) (#798)
#663 and #700 each added one entry to the complete-catalog list in
source_key_set_is_covered_by_complete_catalogs. Each line passed rustfmt on
its own branch; together they exceed the width limit, so cargo fmt --check
fails on main and on every PR updated to main. Formatting only.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:39:39 -07:00
Jamie Pates 33c58b5ca0 Prevent "Export As" from saving webp files as "*.webp.psd" (#724) 2026-10-07 16:34:38 -04:00
9d46c8ea80 Arbitrary rotation starts at the ruler line's straightening angle (#660)
* fix(image): Arbitrary rotation starts at the ruler's straightening angle

Fixes #602

* Arbitrary rotation: ignore a non-finite ruler angle (from #659)

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Alex Gorevski <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:33:52 -04:00
yangandClaude Opus 5.5 1e1f78325c type.edit: a reversed runs range is a bad-params error, not a panic (#714) (#754)
A run with end before start reached style_range with a > b, and the
styled run's length (b - a) underflowed: a panic in debug builds (the
dispatch guard turned it into a generic internal error), a huge stored
run length in release.

type.edit now checks every run before changing anything and rejects
end < start with a BadParams error naming the run, so the layer is left
untouched and no history step is recorded. style_range also treats a
reversed range as empty, so no other caller can underflow it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:33:23 -04:00
35ceaefce2 Fix #695: validate Layer Style effect fields (#729)
* Fix #695: validate Layer Style effect fields

Reject non-object effect parameter fields before preview or confirmation, render a visible validation message, and keep the canvas on the original document when preview input is invalid.

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

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* fix(ui): keep global light preview fields compatible

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

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Layer Style: show an invalid-parameters error once

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]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:33:11 -04:00
135ae8cf96 Image Size keeps the canvas border opaque instead of fading it (#646)
Resampling read the pixels beyond a layer's content as the default pixel
(transparent for layers, white for masks). For content that reaches the
canvas edge, such as a Background or any full-canvas layer, the filter
taps past the border mixed that in, so after Image Size the outermost
rows and columns were translucent: a white Background enlarged 200 %
came out with alpha 162/255 in the corners and 203/255 along the edges.
Export As at any scale other than 100 %, and Generate Image Assets at
200 %, run Image Size first, so every such export had a translucent
frame. Channels, the quick mask and the selection faded the same way.

Image Size now resamples layers, masks, channels and the selection with
resize_surface_in_canvas: taps past the canvas edge (or past a layer's
own pixels where they extend beyond the canvas) read the nearest edge
sample. Inside the canvas nothing changes, so a layer away from the edge
keeps its soft border. resize_surface itself, used by transforms and
placing, is unchanged.

Tests: canvas_edges_repeat_instead_of_fading (algo: every filter, up and
down, 8, 16 and 32 bits; inner layers unchanged) and
image_size_keeps_canvas_edges_opaque (engine; fails before: corner alpha
0.635), which also checks a full selection stays full to the edge.

Co-authored-by: EthanSuquet <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:33:04 -04:00
06ec4044ea Improve Korean terminology and dynamic UI translation coverage (#604)
* Improve Korean Photoshop terminology and dynamic UI translations

* Keep untranslated form labels in sentence case; add the new strings to de and pt-br

label_of fell back to the Title Case filter label whenever the sentence-case label had no
translation, which changed English form labels ("Use antialias" -> "Use Antialias"). It now
uses the Title Case translation only when one exists. German and Brazilian Portuguese (merged
since this branch was opened) get "No patterns" and "Enable {name}".

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

---------

Co-authored-by: kijoong <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:32:47 -04:00
ac5338360f JPEG export no longer fails when the document's XMP is over 64 KB (#645)
A JPEG holds XMP in one APP1 segment, at most 65,533 bytes. Documents
from the source application often carry far more: their XMP lists every
document ever placed in them (DocumentAncestors, thousands of ids) and
the text of every type layer (TextLayers). The encoder passed the packet
through whole, the segment was refused, and the export failed with "App
segment exceeds maximum allowed data length of 65533". That stopped
every JPEG path: Export As, Save a Copy, convert, Layers and Artboards to
Files, Batch and Image Processor. 3 of 16 real designer documents in a
sweep hit it.

When the packet doesn't fit, the encoder now leaves out those two
layered-document properties (any namespace prefix), which a flat image
doesn't need; everything else in the packet is kept. If it still
doesn't fit, the XMP is left out, and so is an EXIF block over 64 KB.
Both cases are reported ahead of time by a new fidelity warning,
MetadataTooLarge, so the export shows what was dropped. Formats without
a segment limit (PNG, TIFF, WebP) still keep the packet whole.

Test: jpeg_with_oversized_metadata_still_encodes (fails before: encode
error), at 8, 16 and 32 bits.

Co-authored-by: EthanSuquet <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:32:26 -04:00
Alex GorevskiandCopilot 7a45adc913 Fix Hue/Saturation range drag bounds (#727)
Canonicalize imported bounds before rendering or moving range handles. Keep drag constraints ordered and cover both non-canonical and ordinary ranges.

Fixes #696

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

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:32:22 -04:00
12460d0345 Fix multi-file open dialog (#595) (#657)
* Fix multi-file open dialog (#595)

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

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Multi-file open test: write its fixtures under the temp dir

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]>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:31:57 -04:00
9a448fe23b Type: a forced line break (U+0003) breaks the line instead of drawing a box (#644)
PSD type stores a line break inside a paragraph (Shift+Return) as U+0003,
and the layer text keeps it as is. The layout passed it to the line
breaker as an ordinary character: it isn't a break opportunity there, so
the line ran on, and the font has no glyph for it, so a missing-glyph box
was drawn in its place. Nothing showed until the text was re-laid out,
because an opened layer draws its saved pixels; any edit of the text, an
Image Size or an export at another scale then turned a two-line headline
into one long line with a box in the middle.

The layout now hands U+0003 to the line breaker as a newline (same byte
length, so every text offset stays put): the line ends, the next one
starts with the paragraph's leading and no paragraph spacing, and no
glyph is drawn. The text itself is unchanged, so PSD save writes it back.

Test: forced_line_break_starts_a_line_in_the_same_paragraph (fails
before: one line), covering point, centred and box text and rendering at
8, 16 and 32 bits.

Co-authored-by: EthanSuquet <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:31:33 -04:00
Alex GorevskiandCopilot 2050ca083e Fix #570: connect brush strokes on Shift-click (#654)
Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:31:15 -04:00
Armaan Sandhu 0aabb48687 document.pixel: no panic at i32::MAX, reject coordinates beyond i32 (#629)
Rect::from_xywh(i32::MAX, y, 1, 1) saturates to an empty rect, so the composite has no pixels and .px[0] panicked. Coordinates are also cast with as i32, so 2^32 silently read column 0. Read the coordinates with i32::try_from (BadParams when out of range) and return transparent for the empty rect, which is what any other off-canvas pixel reads as.
2026-10-07 16:30:52 -04:00
a62b6473f3 Transform modes: Skew / Distort / Perspective as real modes; legacy corners as a preference (#671)
- Edit › Transform › Skew, Distort and Perspective now set a mode on the box
  instead of just starting Free Transform: in Distort a corner moves alone
  with no keys held (Skew: edges skew; Perspective: corners move in mirrored
  pairs). While a box is up they switch its mode, as does a right-click on
  the canvas (Free Transform, Scale, Rotate, Skew, Distort, Perspective),
  with the current mode checked.
- Distort moves only corners (or the whole box from inside): dragging
  outside doesn't rotate, edge handles don't move, nothing snaps; the cursor
  shows when a drag would do nothing.
- Corner drags stay proportional by default (⇧ frees them). Preferences ›
  General › Use Legacy Free Transform (already a setting, hidden until now)
  swaps that: free by default, ⇧ keeps proportions.
- The handle under a press is hit-tested where the press was, before
  snapping, which could push it off a corner and move the whole box.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 16:28:17 -04:00
reese 2cdff3afb9 feat(ui): numeric fields evaluate typed arithmetic (#726)
Typing 1280*2 or 20*2+5-2 into a numeric field applies the result on
Enter, Tab or click-away (+ - * /, * and / first). Plain numbers still
apply as they're typed. Arithmetic is held until it's committed, so a
caller that rounds a partial result can't cut it short, and changed()
now means a new value rather than a keystroke.

The floating Properties card scopes its fields per layer, so text still
being typed for one layer can't commit to the next.

Fixes #601
2026-10-07 16:28:08 -04:00
Alex GorevskiandCopilot 681dee8c8f Fix type edit undo grouping (#652)
Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:21:49 -04:00
reese 6c4010cfc4 Color panel: click a chip to edit that color, double-click to open the Color Picker (#655) (#664)
* fix: Color panel chips pick the colour to edit and open the Color Picker on double-click

A click on the foreground or background chip makes the Color panel's field and hue
strip edit that colour; a double-click opens the Color Picker on it, with its RGB,
HSB, Lab, CMYK and hex fields. Each chip remembers its own hue for greys.

Fixes #655

* refactor(ui): keep the Color panel's edited chip in UiState

The chip the Color panel edits is UiState::color_panel.background, so the
control channel and MCP can read it and set it with
ui.set {colorPanel: {background}}. The double-click test now clicks where
the chips overlap, where the foreground chip takes the click.
2026-10-07 16:20:52 -04:00
9aef02cac3 macOS: menus open on press; only the title bar's free gap drags the window (#669)
Menus:
- Titles open their menu on the press and never toggle on the release (no
  blink), so one gesture can press a title, drag to an item and release on
  it to run it. A plain click still opens the menu and leaves it open;
  pressing an open title closes it.

Title bar:
- The window draws under a transparent title bar, so AppKit dragged it from
  anywhere in that strip, menus included. AppKit's own title-bar dragging is
  off (NSWindow.movable = NO via safe objc2 calls, no unsafe; objc2 crates
  pinned to the versions already in Cargo.lock), and the app's drag region
  is only the free gap between the menus and the right-hand controls.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 16:18:14 -04:00
782ea69f35 fix(linux): AppImage registers its desktop entry and icon on launch (#617)
* fix(linux): AppImage registers its desktop entry and icon on launch

On Wayland the compositor resolves the dock icon from the window's app_id
(ai.storyteller.photocraft, already set) against a host-installed .desktop
file. An AppImage installs nothing, so every run showed the generic Wayland
logo (#593). The app cannot set the icon itself: winit 0.30.13 (egui 0.36.2's
pin) has an empty set_window_icon on Wayland; xdg-toplevel-icon-v1 landed on
winit's 0.31 line only.

- packaging/linux/AppRun is a script instead of a symlink: best-effort
  integration into $XDG_DATA_HOME before launch (desktop entry with Exec
  rewritten to the AppImage's path and TryExec dropped, hicolor icons
  copied), content-compared so an unchanged install costs a few cmp calls,
  refreshed when the AppImage moves, PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1
  opts out, and no failure can block the launch (best-effort steps silent).
- packaging/linux/apprun-test.sh: 8-case smoke (passthrough, Exec rewrite
  with spaces, metadata preserved, idempotence, move refresh, opt-out,
  unwritable data dir, extracted AppDir). packaging-lint runs it and now
  shellchecks AppRun.
- Docs: releasing.md Linux section; README install paragraph.

Fixes #593

* AppRun: skip desktop integration when the AppImage path needs Exec escaping

A quote, backquote, dollar, backslash, percent or line break in the path would need
desktop-entry escaping (a line break could even add keys), so integration is skipped
and the app still launches.

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-07 16:12:00 -04:00
8d8ea584b8 Brazilian Portuguese UI translation (pt-br) (#700)
* Brazilian Portuguese UI translation (pt-br)

Add a pt-br catalog covering every key in the existing catalogs (2,492
entries), register it as "Português (Brasil)" with Portuguese plural
rules, and resolve pt, pt-BR and pt-PT locales to it. i18n coverage
1404/1404.

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

* Keyboard Shortcuts dialog: show menus and commands in the UI language

The command list showed the raw English menu paths and labels while the
rest of the dialog was translated. Translate them the way the menus do
(tr / tr_id); search matches the translated and the English names, like
the command palette. Also rustfmt the pt-br registry row.

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

* pt-br: gate it in xtask i18n-coverage (from #637, thanks @RicSchonfelder); licence note covers German too

Co-Authored-By: RicSchonfelder <[email protected]>
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]>
Co-authored-by: RicSchonfelder <[email protected]>
2026-10-07 16:11:38 -04:00
Matt Van Horn 7f486d723f Add Layer Remove Background command (#734)
Isolates the subject of a pixel layer in one undoable step: a layer mask
by default, with optional transparency bake and a solid fill underneath.
The command is available from Properties Quick Actions, the Layer menu,
the CLI, and the control channel.
2026-10-07 16:09:46 -04:00
reese 3bea3f31c5 fix(transform): picking another tool applies an open transform (#732)
Fixes #670.
2026-10-07 16:08:39 -04:00
3a3984075a Surface Blur: 13x to 40x+ faster on 8-bit layers (sliding histogram) (#631)
* Surface Blur: sliding 8-bit histogram instead of the full window sum

Surface Blur summed the whole (2r+1)^2 window for every pixel and channel, so radius 100 read about 40,000 samples per pixel. For 8-bit layers (every colour sample k / 255) keep a 256-bin histogram per channel that slides along the row, one column out and one in per step, and weight only the levels within the threshold, with the same weight expression as the direct sum. 16-bit and float layers, and radii where the direct sum is cheaper, keep the direct sum (now surface_direct).

* Surface Blur test: as_chunks_mut (clippy)

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-07 16:06:23 -04:00
Armaan Sandhu 09b6572578 Minimum/Maximum (Squareness): running extremes, bit-identical (#630)
The square Minimum and Maximum scanned 2r+1 samples per pixel on each axis, so the cost grew with the radius (0.2..500). Preserve: Roundness already uses the van Herk / Gil-Werman running_extreme in the same file; use it for both passes of the square too. A min or max picks one of the same samples whatever the order, so the output is bit-identical; a new test checks it against the direct window.
2026-10-07 16:05:38 -04:00
43c86ef645 Layers panel: drag down the eyes to show or hide layers; empty eye when hidden (#666)
- The eye column takes drags: pressing an eye toggles that layer and every
  row the pointer then sweeps over gets the same visibility (each row once
  per drag). A drag that starts on an eye no longer reorders the layers.
- A hidden layer's eye box is left empty instead of drawing a struck-out
  eye; it is still clickable.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 16:03:21 -04:00
Armaan Sandhu 9270a31f24 Box Blur: running sums, so large radii cost the same as small ones (#628)
Box Blur ran a direct separable kernel of 2r+1 taps per pixel per axis, so its cost grew linearly with the radius (1..2000). Gaussian Blur already blurs with running-sum box passes (gaussian_boxes); generalise that into box_passes(widths) and use it for Box Blur radii above 4, keeping the direct kernel for radius 1..4 where it is quicker and output stays as before.
2026-10-07 16:00:29 -04:00
Alex GorevskiandCopilot 0c40ec17c6 fix(color): match Photoshop blend corner values (#699)
Co-authored-by: Copilot <[email protected]>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 16:00:27 -04:00
Ian Swope fe4243ba1f Text fields stop resetting the input method every frame, so keys no longer repeat endlessly on Wayland (#585) (#723)
The command palette and the channel rename field called request_focus on
every frame. egui interrupts IME composition on each request, so egui-winit
disabled and re-enabled zwp_text_input_v3 every frame. With an input method
such as fcitx5 the compositor regrabbed the keyboard each time, a key release
landing in the gap was lost, and winit's client-side repeat never stopped.

Both fields now request focus only when they do not have it, matching the
other rename fields. Tests assert the IME is not interrupted after the first
frame.
2026-10-07 15:59:51 -04:00
d5923e49db layer.showOnly: ⌥-click a layer's eye to show only that layer (#665)
New engine command `layer.showOnly {"layer"}`: hides every other layer
(keeping the layer's enclosing groups visible); run again on the same
layer, it restores every layer's previous visibility, layers that were
already hidden included. ⌥-clicking another eye while one layer is shown
alone moves the solo and keeps the original snapshot. The snapshot is
per-document view state (`DocState::show_only`), not history.

The Layers panel sends ⌥-clicks on an eye to it; plain clicks still toggle.

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 15:59:29 -04:00
Elvis33LE fc00f1e5bc scorecard: BUG-203 (atomic saves) is done - flip the stale rows (#203) (#687)
The BUG-203-1..6 and REL-4 rows still say missing, but the atomic-save
work landed in #230 (8227071, 2026-10-05) - apparently after the 4b53f62
snapshot these statuses were verified against. Re-verified against the
current tree:

- one shared photocraft_format::atomic_write (same-dir temp, create_new +
  write_all + sync_all, rename, Unix directory fsync, Windows
  PermissionDenied rename retry with backoff), and every document write
  site routes through it (engine file_cmds/preset_store, automation
  files/headless/workspace, app services, format store/autosave);
- no in-place fallback exists; read-only dir, disk-full and cross-device
  errors name the cause and state the original was not changed;
- format/src/atomic/tests.rs failure-injects mid-write, rename and
  read-only-dir errors (11 tests; re-run for this change: green).

The regenerated docs/scorecard.md moves Reliability to 4/1/2 and Open
bugs to 12/3/17. No code changes.
2026-10-07 15:49:13 -04:00
Mawfyy 09f21d99ca fix(ui): the Enter that opens a dialog from the search palette no longer closes it (#640)
Selecting "Keyboard Shortcuts" in Edit > Search did nothing (#536): palette::show
and dialogs::show run in the same frame (lib.rs:959-960), so the dialog's OK footer
(dialogs.rs:183) read the very Enter that launched it as "confirm" and closed the
dialog before it was ever visible. Every palette-launched dialog hit this when
opened with Enter; the mouse path was unaffected.

The palette now consumes the Enter it acts on (input.consume_key, the same idiom
menu_nav already uses), so dialogs::show sees no press in that frame. A second Enter
still confirms and closes the dialog, which the regression test also asserts.

Test: palette_opens_the_keyboard_shortcuts_dialog drives the real UI harness
(search -> type -> Enter -> dialog open, with and without a document). Full ui-egui
suite 677 pass; clippy -D warnings; fmt; wasm32 check of ui-egui.

Fixes #536
2026-10-07 15:49:11 -04:00
e79dcb6f7f i18n: add German (de) UI translation (#309) (#663)
* i18n: add German (de) UI translation (#309)

A complete German catalog plus the registry row:

- de.tsv: the full key set (menus, tl! literals, blend-mode names, Camera
  Raw labels, generated preference labels), plural rule one|other, header
  documenting the format and the clean-room policy like ja.tsv.
- Registered with complete_menus: true; the locale resolver and the
  language-list tests gain de (de-DE/de-AT/de-CH resolve to it).
- ATTRIBUTION.md row and the docs/localization.md language table entry.

Translations are original work from the plain meaning of the English
labels in ordinary German image-editing vocabulary (Ebene, Maske,
Auswahl, Weichzeichnen, Tonwertkorrektur, Smart-Objekt ...); no other
product's localisation resources were consulted. Product and technology
names (PhotoCraft, ArtCraft, OpenType, CMYK, Lab, Camera Raw) stay in
Latin letters; the Camera Raw curve bands are distinct (Schwarz, Tiefen,
Dunkle, Helle, Lichter, Weiss).

Verified: catalog tests and preference-label tests green,
cargo xtask i18n-coverage reports de 1404/1404 (100%), the full
ui-egui suite passes (except the known machine-local wgpu driver
failure that also fails on clean main), clippy and wasm32 clean, and
an offscreen snapshot sequence switches de -> en -> de in one process
and ends fully German.

* de: translate Metadata (Export As gains the label in the #647 fix)

* Revert "de: translate Metadata (Export As gains the label in the #647 fix)"

This reverts commit 948a8d01dd83579a899d938611101daa50a9e1e1.

* de: apply the native-speaker review (#663)

All meaning errors from the review: Auto Tone is the tonal range
(Automatische Tonwertkorrektur), the Divide blend mode is arithmetic
(Dividieren), Note Paper is Notizpapier, Legal is the US paper format,
Fuzziness widens the colour tolerance (Toleranz:), Perspective Warp
planes are Flaechen (not Ebenen, which reads as layers), Revert is
Zuruecksetzen (matching its confirm text), Fade is Verblassen.
Typos: Tonwerthistogramm, Magischer-Radiergummi-Werkzeug,
Einfrier-Lasso. Consistency: Kanalmixer everywhere, Zoom In/Out as
Vergrössern/Verkleinern, a space before % in the scale labels.

* xtask i18n-coverage: gate the German catalog too

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

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 15:48:02 -04:00
Alex GorevskiandCopilot e70deecbe5 fix(cms): check LUT declared sizes before arithmetic (#725)
Fixes #689

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

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
2026-10-07 15:41:09 -04:00
才倾梦华V 9e6491e5f5 Keep Select Linked Layers within the linked groups (#764) 2026-10-07 15:41:01 -04:00
才倾梦华V 03b901cdde Persist migrated presets with preferences (#728)
* Persist migrated presets with preferences

* Check only imported entries after preset reload
2026-10-07 15:38:29 -04:00
Brandon ThomasandClaude Opus 5.5 d7df9d8aa6 Rename PrintCraft to PdfCraft (storytold/pdfcraft) (#766)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:40:36 -04:00
47f9306fd0 Open HEIC/HEIF photos (#355) (#372)
* Open HEIC/HEIF photos (#355)

iPhones save HEIC by default and Photoshop opens it; PhotoCraft had no
HEIF code, so .heic was missing from File > Open and failed to decode.

Add Format::Heif to photocraft-codecs, decoded by heic-rs (pure Rust,
forbid(unsafe), MIT OR Apache-2.0, wasm-ready): single pictures and
grid-tiled photos, 8-bit to U8 and 10/12-bit to U16, auxiliary alpha,
ICC, EXIF and XMP. The container's irot/imir/clap are applied and EXIF
Orientation is rewritten to 1, so photos are never turned twice. Limits
are checked against the declared size before decoding. Read-only (no
pure-Rust HEVC encoder), listed in ASYMMETRIC_EXCEPTIONS.

File > Open (desktop, web) and the batch list accept heic/heif/hif.
Tests compare against Apple's own decode and a 16-bit source, cover
orientation, metadata, limits and every truncation; fixtures from
heic-rs and pillow-heif are attributed in ATTRIBUTION.md.

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

* HEIF: pin heic-rs exactly and add a decode_heif fuzz target

Review follow-up: heic-rs is young and parses untrusted files, so pin it
(=0.1.1) and fuzz the HEIF path. The target covers both orientation
paths and is meant to run with -timeout so a hang is reported, not
only a panic. It is added to the CI fuzz matrix.

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

* HEIF: a heic-rs panic is a malformed-file error, not a crash

Fuzzing decode_heif found two out-of-range slices in heic-rs 0.1.1 on
malformed files:
- in its box parser (boxes.rs:130), reachable from a 72-byte file, even
  through the metadata-only probe;
- during HEVC reconstruction (hevc/decode/recon.rs:70).

The background job path catches panics, but the synchronous ones (jobs
off, the CLI) did not. The HEIF codec now runs the decoder under
catch_unwind and reports a panic as CodecError::Malformed, as
gpu/health.rs already does around wgpu.

The fuzz target also calls heic-rs directly, so its panics still reach
the fuzzer. The 72-byte input is a regression test, which fails
without the guard.

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

* HEIF: move the decoder into an optional photocraft-heif crate

The heic-rs decoding from #372 now lives in `crates/heif` (photocraft-heif, standalone, depends
only on heic-rs =0.1.1): probe, decode with a pixel limit, container transforms, alpha, 8/10-bit,
ICC/EXIF/XMP and the catch_unwind guard, behind a small API of its own (Info, Options, Decoded,
Error). photocraft-codecs uses it behind a new non-default `heif` feature; the apps and
photocraft-io forward it. HEIF detection, extensions and mime stay compiled in, so without the
feature a HEIC file is still recognised and opening it is a clear "HEIC/HEIF support isn't
included in this build of PhotoCraft" error. `cargo xtask layers` allows exactly codecs -> heif
between standalone crates.

The fixtures are no longer committed: `cargo xtask corpus --heif` fetches them from heic-rs
(tests/fixtures, v0.1.1) and pillow-heif (tests/images) at pinned commits, verified against
xtask/heif-corpus.sha256 (same bytes as the PR's copies). The fixture tests run with
`--features corpus,heif` (`cargo xtask test-corpus` enables both).

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

* Build CI and official binaries with HEIC support (--features heif)

CI test and clippy, the FreeBSD job, and the macOS, Linux (and Flatpak), FreeBSD, Windows and
web packaging all build with the `heif` feature; CI also tests codecs/io without it on Linux so
the "not in this build" path stays covered. `cargo xtask wasm` also checks photocraft-codecs with
`--features heif`.

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

* Docs: HEIC open is an optional build feature, on in official builds

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 10:20:40 -04:00
Brandon ThomasandClaude Opus 5.5 7055f0c51b README: remove a stray "ArtCraft" line before Star history (#636)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 10:12:51 -04:00
Brandon ThomasandClaude Opus 5.5 2cd470c08e monitor_profile test: Unix only (it runs /bin/sleep and /bin/sh); fixes Windows CI after #591 (#633)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:59:59 -04:00
b07a2e5b43 Remember desktop window and panel sizes across restarts (#587)
* Remember desktop window and panel sizes across restarts

* Window layout: discard a saved ui.ron that would crash startup

eframe restores the window geometry and egui memory before the app runs, and a
zoom factor of 0, NaN or 1e30, or a NaN/huge window size aborted every launch
(dpi/wgpu assertions). Check the file first and remove it if it holds such values.

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-07 09:12:44 -04:00
Cristian Moroaica b9a24da799 Apply Image onto a transparent layer no longer turns it black (#607)
The blend ran against the colour stored in transparent pixels (black),
so Multiply and the other darkening modes filled an empty layer with
opaque black. The blend now acts only where the layer has pixels, and
the alpha grows by source-over, so an empty layer takes the source in
any mode. Opaque pixels blend exactly as before.
2026-10-07 09:12:11 -04:00
Elvis33LE 09bc2ac0c4 algo: fix flaky cancel/progress test - tile workers report out of order (#609)
`apply_tiled_with` filters tiles with `chunk.par_iter()` and each worker
reports progress (`0.98 * n / total`) from its own thread. The values are
monotonic in the finish counter, but delivery order is not, and the test's
callback did a non-atomic load->assert->store of the last value: when a
worker holding a smaller value was preempted past another worker's store,
the assert fired. Observed once in a full `cargo test --workspace` run;
reproduced 1-in-300 under synthetic CPU load.

`Interrupt` never promised ordered delivery and its engine consumer
already clamps (`JobCtx::progress` keeps `fetch_max` of the f32 bits, see
crates/engine/src/jobs.rs). The test now does the same: fetch_max high-water
mark plus an in-range assert, and requires the mark to reach exactly 1.0.
Renamed to `..._progress_never_regresses` to say what it actually checks.
Stress after the fix: 0 failures in 300 runs.

The identical-looking asserts in content_aware.rs and io/tests/cancel.rs
stay strict: those paths report progress from a single thread.
2026-10-07 09:11:45 -04:00
e77325bd4a Add fixed UI scale steps from 75% to 300% (#611)
* add 75-300% fixed ui scale steps

* UI scale: French and Indonesian labels for the new steps

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

* i18n(ko): UI scale steps

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-07 09:11:35 -04:00
d4086ba11a feat(i18n): add Korean and live language switching (#582)
* feat(i18n): add Korean and live language switching

* i18n(ko): Edit Type (#598)

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-07 09:06:33 -04:00
45bedb0962 Monitor profile auto follows each window's display; what is applied is visible (#569) (#591)
* Monitor profile: say which profile is in use and why; apply a late display reading (#569)

Color Settings › Monitor Profile used to fall back to sRGB without a
word when the display's profile was missing, unreadable, not RGB or
unusable as a destination, and dropped a macOS reading that took longer
than 2 s. `ColorState::monitor_status` now reports the profile actually
applied, its fingerprint, the source (auto / manual / fallback) and the
reason for a fallback. `edit.colorSettings` (`monitorStatus`),
Help › System Info and the Color Settings dialog show it, and a fallback
in auto posts a notice.

The macOS reader also names the display it read. On a dual-display Mac
it reports the menu-bar display ("Built-in Retina Display") while the
canvas is on the external monitor, which is the display-selection bug
in #569; following the window's display is the next step.

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

* Monitor profile auto: each window uses the profile of the display it is on (#569)

The macOS reader now lists every display (id, name, frame, ICC profile
and its System Settings name) instead of asking for the "main screen",
which from a windowless helper is the menu-bar display. Each window
picks the display it overlaps most, as NSWindow.screen does, and its
canvas is shown in that display's profile: moving a window to another
display switches the profile without a restart, and two windows of one
document on different displays each keep their own. The GPU canvas
shares the document texture and keeps a display LUT per (document,
display); CPU textures are per (document, display).

Displays are read again when PhotoCraft comes back to the front (a
profile changed in System Settings or by a calibration tool), and when
a window is on an unknown display or one whose size changed (connected,
removed, rearranged), at most every 5 s. A failed re-read keeps the
previous reading. Help › System Info lists each display's profile.

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

* Monitor profile auto: act on the r1 review; no periodic re-read (#569)

Following the owner's decision on echo round r1:

- No periodic discovery: a read costs about 0.2 s of CPU, and a profile
  reassigned while PhotoCraft stays in front with no window moving
  practically doesn't happen. It is picked up at the next return to the
  front, or when Edit › Color Settings opens, which now reads at once.
- Every document window, not only the main one, asks for a reading when
  it is on an unknown display or one whose size changed.
- The focus re-read is throttled to one per 30 s; a trigger inside that
  window is deferred, not dropped.
- Starting a reading schedules the frame that applies its result, so an
  idle window doesn't wait for unrelated input.
- The osascript helper is stopped after 10 s (killed and reaped, both
  pipes drained meanwhile), so a hung reading can't block later ones.
- The Color Settings dialog recomputes "Monitor profile in use" as it
  draws, and the status says when an earlier reading is kept after a
  failed re-read (detection `retained`).

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

* Monitor profile auto: document windows count for focus and wake the main window (#569)

Echo round r2 on #591:

- Coming back to PhotoCraft through a document window (macOS reports
  focus per window, so the main window stays unfocused) now counts as a
  return to the front and reads the displays again, within the 30 s
  throttle.
- A document window that is on an unknown or resized display, or that
  brings PhotoCraft back to the front, asks the main window for a frame:
  the reading starts there, so it no longer waits for unrelated input.
  One frame per trigger, no continuous repaints.

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

* Monitor profile: pass the notice dismiss preference added on main

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 09:02:58 -04:00
3d23a708c2 Edit type layers by double-clicking their thumbnails (#598)
* Edit type layers by double-clicking their thumbnails

* Translate Edit Type for French and Indonesian

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-07 09:01:39 -04:00
才倾梦华V 8ebca04aa9 Add Edit Contents to Smart Object context menu (#599) 2026-10-07 09:01:20 -04:00
fa0defc56b Add style warp splits and grid presets (#388)
* Add warp splits and grid presets

* Translate the warp grid labels

* Satisfy clippy on the warp section-edge check

* Address the warp review notes

* Format the warp handle arm

* i18n: French and Indonesian warp grid labels

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-07 08:40:00 -04:00
90df9cfc15 Report per-language UI translation coverage (#220) (#427)
* feat(xtask): report UI translation coverage

Add a deterministic per-language coverage report backed by a checked English key inventory. Reject malformed, duplicate, and unknown catalog keys, and document the invocation.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* fix(xtask): derive i18n coverage keys from source

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

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* fix(xtask): handle Windows line endings in i18n scan

Co-authored-by: Copilot <[email protected]>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Scorecard: DIST-220-4 is partial now that xtask reports translation coverage

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

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:54:05 -04:00
786a863f9c i18n: "id" indonesian translation (#449)
* "id" Indonesian

* extra space for fomat (ubuntu-latest)

* Update id.tsv

* Update mod.rs

* i18n(id): translate the labels added on main since this catalog; Mixer Brush terms

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-07 07:52:51 -04:00
a4e96991f1 i18n: add French (fr) UI translation (#410)
* i18n: add French (fr) UI translation

A complete French catalog (menus, dialogs, panels, plurals), registered with its
plural rule (0 and 1 take the singular). Covers the latest commands, Stamp Visible
and Stamp Down included. A test checks the bundled UI fonts carry every French glyph.

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

* i18n(fr): translate labels added on main since this PR (from #582 by neko233-com)

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

* i18n(fr): translate the labels added on main since this catalog

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

* i18n(fr): Camera Raw scopes and the Patch tool

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: neko233-com <[email protected]>
2026-10-07 07:49:30 -04:00
b45dfde498 i18n: complete Simplified Chinese catalog and localize brush sections (#359)
* i18n: complete Simplified Chinese UI catalog and brush labels

* i18n(zh-hans): translate labels added on main since this PR (opening/cancel, layer navigation)

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

* i18n(zh-hans): keep main's Mixer Brush entries, translate the rendering-mode strings added since (#434)

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-07 07:46:52 -04:00
b5eed81a25 Start context-menu parity across canvas, paths, and document tabs (#556)
* Add blend modes to Gradient tool

* Establish context menu parity contract and first workflows

* Align Select menu actions and style selection context rows

* Add Pen canvas Make Selection context action

* Complete Pen path context menu and symmetry actions

* Select menu: no extra top-level Feather (keep Select > Modify > Feather only, for 1:1 menu parity)

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

* Regenerate docs/scorecard.md and docs/parity.md

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

* Regenerate docs/scorecard.md and docs/parity.md

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-07 07:45:26 -04:00
Sean Lavery 8aecbffb5c Add blend modes to Gradient tool (#543) 2026-10-07 07:43:36 -04:00
b4f9d56c93 Add the Patch tool with a live preview (#213) (#381)
* Add the Patch tool (#213)

paint.patch heals the selected area with texture from where the
selection is dragged (Source) or copies the selection onto the
dragged-to area (Destination), seamless-cloned like the Healing Brush.
The solve is clipped to the canvas, so patches at the edge stay opaque.

Tool::Patch joins the J group: outside the selection it draws a lasso
(Shift/Alt add/subtract), inside it drags the patch, the outline follows
the pointer and the offset is clamped to the canvas. Options bar:
Source / Destination. Strings translated in all six catalogs.

Tests at 8/16/32 bit, a UI gesture test, panic_hunt green. Scorecard
TOOL-213-3 is partial: the drag previews the outline, not the pixels.
retouch_bench: patch 250x250 22 ms, 1000x1000 362 ms (release).

generated with Claude Code

* Patch tool: live preview of the heal while dragging (#213)

The canvas shows the document healed at the pointer's offset
(patch_preview.rs, modelled on the Move tool's preview) and
recomposites only the healed area; release commits the exact
paint.patch and its damage rect refreshes the same area.

The preview (retouch_cmds::patch_preview) solves the colour fit on a
coarse grid (at most 128x128 cells) and interpolates it back: the
Poisson result is source + a harmonic correction, so the texture stays
exact and only the smooth correction is approximated (worst pixel 0.022
at 8x coarser in the test). Selection coverage is read tile-wise and
the interpolation runs per row in parallel.

retouch_bench (release, 6016x6016): preview 250x250 7 ms, 500x500
17 ms, 1000x1000 52 ms (exact patch: 21 / 90 / 354 ms). Scorecard
TOOL-213-3 done. Also applies rustfmt to the first commit's code.

generated with Claude Code

* Patch tool: cap patches at 16M pixels, drop product names from comments (#213)

The solve needs roughly 150 bytes per pixel, so the old 64M cap allowed
about 10 GB and risked an allocation abort instead of an error.

generated with Claude Code

* Regenerate docs/scorecard.md

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

* Tool::ALL holds both the Patch tool and the Mixer Brush (rebase onto #433)

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-07 07:42:30 -04:00
cc5de414dc Camera Raw: interactive histogram, clipping warnings and scopes (part of #215) (#407)
* Camera Raw: interactive histogram, clipping warnings and scopes (part of #215)

- RGB histogram of the preview proxy (algo::histogram), drawn as RGB ribbons;
  Before/After, five draggable tonal zones, shadow/highlight clipping warnings
  (U/O) and Alt-drag channel diagnostics.
- Pixel hover readout, up to nine RGB/Lab colour samplers (S), an ICC-managed
  vectorscope (algo::vectorscope) with skin-tone line, and a floating scope panel.
- Settings regrouped as Light, Color, Effects, Curve, Color Mixer, Color Grading
  and Detail; Camera Raw labels translated through the cameraRaw context.
- One point-curve editor (point_curve.rs) shared by Camera Raw, the Curves dialog
  and adjustment-layer Properties; fixes Camera Raw curve points not dragging.
- Double-clicking a Camera Raw smart filter reopens the Camera Raw dialog with its
  settings and updates that filter, instead of the generic parameter form.
- New point curves are validated (2-16 points, increasing inputs); stored Smart
  Filters keep loading.
- camera_raw_bench for P31/P36 (not enforced until a baseline includes them).

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

* Histogram tests: as_chunks for triangle indices (clippy 1.99)

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

* Regenerate docs/scorecard.md after rebasing onto main

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

* i18n tests: close the Mixer Brush test before the Camera Raw one (rebase)

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-07 07:40:55 -04:00
ljkxandClaude Opus 5.5 a8afec4455 Open documents in bounded reads, with a clear out-of-memory error (#375) (#401)
Every open path read the whole file with std::fs::read, which asks
Windows for up to 4 GiB in a single ReadFile. Some file systems (exFAT,
network redirectors) reject a read that large with
ERROR_INVALID_PARAMETER, "The parameter is incorrect" (os error 87),
as reported for a 4.75 GB PSB in #375.

Add photocraft_format::read_file and read_all beside atomic_write:
64 MiB reads into a buffer reserved up front, and an OutOfMemory error
that says how much the file needed. Route every document read through
them:
- the background open job (engine jobs.rs read_path);
- the engine's read_file;
- Open Recent and OS open events;
- desktop drag and drop, which now goes through open_path (and so
  through the background job when jobs are on);
- the File > Open dialog;
- .pcraft loading;
- the automation workspace read and automation::files::open;
- smart object Replace Contents and linked files.

The dialog no longer fails silently: PickOpenFn returns the picked name
with the read result, and a failure is reported through open_failed and
the notices like any other open failure.

Speed is unchanged (1.11 s vs 1.13 s for a warm 4.75 GB file).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:40:35 -04:00
fbe5f80ba4 PSD: read smart-filter masks (FEid) with one section per smart object (#396)
* PSD: read smart-filter masks (FEid) with one section per smart object

Photoshop writes the global Filter Effects block (`FEid`/`FXid`) as a
version followed by one length-prefixed section per filtered smart
object, each padded to 4 bytes outside its length. The reader took the
first section's length as the whole body, so any document with two or
more filtered smart objects failed with "filter effects: N unexpected
bytes after the data": the smart filter masks were not imported and the
open reported a warning. The 28 FEid files in the public corpora each
hold a single section, which is why the corpus never hit it.

The reader now walks every section (still accepting several items in one
section, as earlier PhotoCraft versions wrote), and the writer emits one
padded section per item, as Photoshop does.

Tests: reads_one_section_per_item (fails before: parse error) and
reads_several_items_in_one_section; malformed_data_is_an_error now cuts
only before the end of the section.

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

* PSD FEid: an empty item list still writes one section, so it reads back

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

---------

Co-authored-by: EthanSuquet <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 07:40:10 -04:00
7dc1575efc Content-Aware Scale: 13x faster seam removal, bit-identical output (part of #211) (#385)
* Content-Aware Scale: 13x faster seam removal, bit-identical output (part of #211)

Every removed seam recomputed the whole energy map, ran the seam search with an allocation per seam and copied the whole image into a new buffer, all on one thread: about 320 ms per seam on 24 MP. Now the image, protection and energy buffers keep their row stride and a seam is removed by shifting the rest of each row in place (in parallel); only the pixels next to the removed seam get their energy recomputed, with the same per-pixel arithmetic; the seam search reuses its buffers and its interior loop has no edge tests, so it vectorizes. Ties are broken exactly as before.

A new test pins the output bits of eight carves (shrink, enlarge, both axes, protection, 1/3/4 channels, tie-heavy noise), taken from the previous implementation.

* Content-Aware Scale: oracle test against the previous carver; keep the 1 px enlarge fix

carver_matches_reference compares the new carver bit for bit with the
implementation it replaced (now kept in the tests) over 400 random carves and
200 seam searches and energy maps. The rebase keeps main's #387 fix: a 1 px
wide image's one seam is taken so it can be duplicated.

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-07 07:39:49 -04:00
0cb27712c8 Paste: a copied image file pastes its pixels (part of #338) (#384)
* Paste: a copied image file pastes its pixels (part of #338)

Copying a file in GNOME Files, Finder or Explorer puts its path on the clipboard, not pixels, so Edit > Paste found no image and said the clipboard was empty. When the clipboard has no image, the native clipboard service now reads arboard's file list and pastes the first file that decodes (upright, as RGBA8), skipping non-images after reading only their header. TGA, which has no magic number, also needs its extension.

arboard splits text/uri-list on LF only, but the list is CRLF-terminated (RFC 2483) and GTK writes it that way, so paths copied in GNOME Files arrive with a trailing '\r'; it is trimmed before opening.

* Paste: copied files before the clipboard image; require arboard 3.6

Finder puts a copied file's icon on the clipboard as an image next to
the file URL, so reading the image first pasted the icon on macOS. The
file list is now read first and the plain image is the fallback.
file_list() needs arboard 3.5 or later, so the requirement says 3.6
(what the lockfile already has).

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

---------

Co-authored-by: bflatastic <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:39:30 -04:00
Alex GorevskiandCopilot bf545e0e81 Hide unconfigured layer effects from panel (#559)
Co-authored-by: Copilot <[email protected]>

Copilot-Session: 8fbf5558-5421-4e91-8c33-89a347bb27da
2026-10-07 07:14:04 -04:00
08e3e610c0 Preferences › Interface: Show bounding box when dragging layer (#484)
* Preferences › Interface: Show bounding box when dragging layer

New pref `interface.showBoundingBoxWhenDraggingLayer` (default off).
On: a Move drag shows the layer's outline and an arrow; the pixels stay
put until release. Off: the layer follows the pointer live with no
outline (Photoshop's behaviour). Label translated in all six catalogs.

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

* Keep the drag outline by default; the preference only turns off the live pixels

Off (the default) keeps today's Move drag: pixels follow the pointer and
the moved layers' outline is drawn. On, only the outline and arrow move.

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

* Regenerate docs/scorecard.md

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-07 07:12:58 -04:00
047aeb747a Wayland: explain native file drag-and-drop limitation (#386) (#430)
* Wayland: add persistent file drop guidance (#386)

Co-authored-by: Copilot <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Fix Wayland file-drop guidance review

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

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* fix(app): pass no dismissal preference for portable notice

Co-authored-by: Copilot <[email protected]>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Pass no dismissal preference for the prefs save-failure notice

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

* notices::post takes the dismiss preference: pass None for the GPU retry notice (#434)

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

---------

Co-authored-by: Copilot <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:12:25 -04:00
50d52a0848 Clarify integer and float bit-depth labels (#324) (#428)
* Clarify integer and float bit-depth labels

Add type-specific labels to New Document and Image Mode with translations and focused UI/catalog tests.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Fix bit-depth labels and tooltips

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

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Keep the reference menu names for the depth commands and preference

The Image > Mode items keep their menu-catalogue labels, so the engine
labels (command palette, MCP, Keyboard Shortcuts) and the preference
choice stay "8/16/32 Bits/Channel" to match; the integer / floating point
hint comes from the new tooltips.

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]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:09:51 -04:00
Alex GorevskiandCopilot 2c02d41eaf Show active ICC profile in the status bar (#321) (#432)
* Show active ICC profile in status bar (#321)

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Fix active profile status review feedback

Co-authored-by: Copilot <[email protected]>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

---------

Co-authored-by: Copilot <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:08:51 -04:00
d07eecf66f PSD: read/write the artboard lock at Photoshop's lspf bit 3 (#395) (#406)
* PSD: read/write the artboard lock at Photoshop's lspf bit 3 (#395)

Photoshop writes layer locks with the artboard bit at 0x08 (a Background
layer carries 0x0D). PhotoCraft read it at 0x10, so 0x0D came back as
0x05 and the lock bytes drifted on every re-save. Map artboard to bit 3
on both read and write; still accept 0x10 on read so older PhotoCraft
files keep their lock, rewriting it at 0x08.

Co-Authored-By: Qwen 3.8 Flash <[email protected]>

* style(io): fix PSD artboard lock review comments

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

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

---------

Co-authored-by: Qwen 3.8 Flash <[email protected]>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:08:00 -04:00
Sean Lavery 395a52e7f1 Focus PhotoCraft when unsaved-close prompt appears (#547) 2026-10-07 07:07:00 -04:00
45d0f4ad93 docs: sync scorecard with implemented behavior (#348)
* docs: sync scorecard with implemented behavior

* Scorecard: cite symbols instead of line numbers in the updated bug notes

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

* Regenerate docs/scorecard.md

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-07 07:06:21 -04:00
floflo777 6422219226 Control protocol docs: file.open and file.save are refused over --control (#391)
Two passages said they worked like app.open/app.save. Say what the policy
really refuses, and pin those cases in its test.
2026-10-07 07:05:38 -04:00
d91ab25772 Expose the Mixer Brush as a UI tool (#213) (#433)
* feat(ui): expose Mixer Brush tool (#213)

Wire the existing Mixer Brush command into the selectable UI tool model, options bar, and control-driven painting path. Cover shortcut cycling, options persistence, and selection clipping.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* docs(scorecard): mark Mixer Brush slice complete (#213)

Reflect the new Mixer Brush toolbar, options, control dispatch, and selection tests in TOOL-213-2 and regenerate the scorecard.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* fix(i18n): translate Mixer Brush labels in zh-Hans

Add Simplified Chinese entries for the Mixer Brush tool, its options, and the brush-settings heading. Test all Mixer Brush UI strings against every registered language catalog.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Polish Mixer Brush UI controls

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

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

* Regenerate docs/scorecard.md

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]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 07:04:31 -04:00
bd7d47bf0f Lasso inside liquify mode , with hotkeys L, alt , ctrl+i, ctrl+h for … (#453)
* Lasso inside liquify mode , with hotkeys L, alt , ctrl+i, ctrl+h for visibility

* Liquify Freeze Lasso: scanline fill, tests, one translated tooltip, theme colours

- apply_lasso fills node rows with an even-odd scanline pass (O(rows x edges) plus the nodes
  written) instead of a point-in-polygon test per node; non-finite points are skipped and huge
  or off-canvas polygons clamp to the field.
- LassoMask joins LiquifyTool::ALL, so the strip draws its button in the loop; its tooltip is
  one translated string (all catalogs); the outline uses theme tokens.
- A closed lasso clears Redo (#435).
- filter.liquify params doc lists lassoMask.
- Tests: concave polygon vs. a reference even-odd test at several cell sizes, thaw, degenerate
  and hostile polygons, replay; the UI lasso records a stroke, Alt thaws, undo/redo.

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

---------

Co-authored-by: JJ <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:03:49 -04:00
48bd08150e feat(ui): add bounded Liquify redo history (#435)
* feat(ui): add bounded Liquify redo history

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d

* Regenerate docs/scorecard.md

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]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
2026-10-07 06:59:03 -04:00
5856866f89 Smart objects keep their four-corner (Distort / Perspective) placement (#426)
* Smart objects keep their four-corner (Distort / Perspective) placement

A smart object's placement was an affine transform, so a perspective
placement lost its fourth corner: a mockup PSD's distorted screen looked
right on open (Photoshop's cached pixels) but re-rendered as a
parallelogram after Edit Contents → Save.

- SmartObject gains `perspective`, the full projective map (source px →
  document px); `transform` keeps its affine approximation
- Re-renders go through it (place_source_projective), as do moves,
  canvas rotations and edit.transform, so Distort / Perspective now work
  on smart objects instead of being refused
- PSD import reads the quad (nonAffineTransform / Trnf) and export writes
  it back; .pcraft stores the field (absent for affine placements)
- Test: a distorted smart object survives Edit Contents → Save

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

* Smart objects: warp maps through a distorted placement; perspective round-trip tests

Warp handles and stored warps on a Distort / Perspective smart object go through its full
projective placement (smart_cmds::placement) instead of the affine approximation, so they sit
on the distorted corners. Tests: SoLd perspective round trip and quad_moved (io), a perspective
smart layer in the .pcraft rich_doc, warp on a distorted smart object (engine). Comments no
longer name another product.

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

---------

Co-authored-by: Jason Short <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-07 06:58:01 -04:00
Brandon ThomasandClaude Opus 5.5 da9adf82bc rustfmt discard_ui.rs (CI Format step red since #533) (#597)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 06:55:10 -04:00
7313e08141 32-bit documents: Levels don't clip the input and output ranges (#403)
* 32-bit documents: Levels don't clip the input and output ranges

Photoshop's float Levels keep the curve going past the black and white points
and use a plain power for the gamma. rgb32 and gray32 levels.psd now match
within 0.5/255 (was 10/255).

* Raise the Photoshop oracle floor to 132

* Levels: destructive Image › Adjustments › Levels doesn't clip in 32-bit either; neutral comment wording

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-07 06:53:56 -04:00
reese 25d198ceb1 Free Transform: Undo steps through the transform; handles are easier to grab (#365)
* fix(transform): undo inside Free Transform steps through the transform

Undo while transforming used to undo the document's last step under the
open box. Now, as in Photoshop, Undo and Redo step back and forth through
the session's own changes: each handle drag, nudge or option edit is one
step. Toggle Last State and the History panel's rows wait until the
transform is committed or cancelled. A press on a preset warp turns it into
a custom mesh only when it grabs a point, so a miss records no step.

* fix(transform): widen the Free Transform handle grab radius

Handles now grab within 12 pt instead of 8, so a corner is easier to catch
without rotating instead. The Move tool's transform controls and snapping
use the same radius. On boxes smaller than the grab areas the nearest
handle wins, so the reference point doesn't take presses on corners.

* fix(transform): route every Undo through the open transform

The interception moves from the menu path into PhotocraftApp::run, so edit.undo and edit.redo from the control channel and MCP step through the box like the menu and shortcuts do. edit.toggleLastState is refused while the box is open instead of changing the document under it.

* chore(transform): describe behaviour without naming other products
2026-10-07 06:52:31 -04:00
reese 0b7cd68f8b fix(type): recolour selected type from the Color and Swatches panels (#364)
With characters selected while editing type, a new foreground colour from
the Color panel, the Swatches or the Color Picker now recolours them, as in
Photoshop, within the editing session's history step. The HSB sliders now
act only on an edit: their h/s/v round trip isn't exact, so comparing
values rewrote the foreground every frame.
2026-10-07 06:52:12 -04:00
reese 68831a4336 Polygonal Lasso: double-click closes the polygon; a new polygon replaces the selection in one step (#361)
* fix(ui): close the polygonal lasso on double-click

As in Photoshop, double-clicking closes the polygon at the last point. The
second click is handled in the click dispatch, so it never starts a new
polygon, including when the first click already closed it on the first
vertex. egui reports it as a triple click when a vertex went down shortly
before, so that counts too.

* fix(ui): a new polygonal lasso replaces the selection in one step

The first click fixes the selection mode. A new-selection polygon hides the old outline while it is drawn instead of deselecting, so closing it is one history step and Esc leaves the selection untouched. Enter closes the polygon in the mode it started in.
2026-10-07 06:49:53 -04:00
b41442c466 Color Picker: sample colours from the image with a pipette (#508)
* Color Picker: sample colours from the image with a pipette

With the Color Picker open, the pointer over the image turns into a
pipette, as in Photoshop. A click or drag there samples the composite
colour into the picker's new colour, and OK applies it. This works
whatever tool is selected; Space and middle-drag still pan. Over the
dialog the pointer is the normal arrow.

Agents get the same: `ui.pointer` (MCP `ui_pointer`) samples into an
open picker instead of driving the tool.

* Color Picker tests: rename the eyedropper test's colour helper after the rebase

#485 added a `color` test helper in the same module.

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-07 06:46:25 -04:00
6237f6e46e Layer Style: the shadow/bevel Angle slider now moves the light (#350) (#411)
* Layer Style: the shadow/bevel Angle slider now moves the light (#350)

The Drop Shadow, Inner Shadow and Bevel & Emboss dialogs exposed an Angle
slider but no "Use Global Light" checkbox. The engine defaults
use_global_light to true, so the compositor used the document's fixed
global light angle and ignored the per-effect angle — dragging Angle did
nothing.

Add Photoshop's "Use Global Light" checkbox to those three effects,
round-trip useGlobalLight through the dialog fields, show the effective
(global light) angle when it is on, and — when it is on — drive the
document's shared light angle from the slider so the shadow follows it,
exactly as in Photoshop.

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

* fix(ui): preserve edited layer-style global light

Apply a layer-style global-light angle only when it differs from the dialog's initial angle, so later unchanged effects cannot override a Bevel & Emboss edit. Use the current light angle for new global-light effects and add translations for Use Global Light.

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

Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc

---------

Co-authored-by: Claude Haiku 4.5 <[email protected]>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
2026-10-07 06:12:23 -04:00
sommerperandClaude Opus 5.5 9f0c049b53 Unsaved-changes prompt: (D)on't Save / (C)ancel / (S)ave keys, Tab order (#533)
The buttons show their key in parentheses and the D, C and S keys answer
the prompt (Revert prompt: (C)ancel, (R)evert). Translated labels that
don't start with the key letter get it appended ("Guardar (S)"), so the
catalogs are unchanged.

Tab / Shift+Tab move right / left through the buttons and wrap. egui's
own Tab order follows the right-to-left layout, so Tab used to move left.

Buttons from widgets::primary_button / secondary_button now draw an
accent ring while they have keyboard focus.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 06:12:18 -04:00
yangandClaude Opus 5.5 3c3dd881ce Type layers keep their name in step with their text (#483) (#501)
* Type layers keep their name in step with their text (#483)

A type layer is named after its first line when it's created, but
only the Type tool's first editing session renamed it again. Editing
the text later (clicking back into the layer, type.edit, Paste Lorem
Ipsum into it) left the old name, so a layer re-typed from the
placeholder kept saying "Lorem Ipsum".

Like Photoshop, a type layer whose name is still the one its text
gives it now follows every text edit, in the same history step. A
layer named anything else (given a name at creation or renamed later)
keeps it. Paste Lorem Ipsum no longer gives its new layer a fixed
"Lorem ipsum" name, so that layer follows its text too.

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

* Type layer names: the first non-empty line, the same in the Type tool and the engine

Review on #501: the Type tool named a new layer after its first
non-empty line, the engine after its first line, so text starting with
a blank line stopped the name following the text. layer_name now takes
the first non-empty line and commit() uses it. Also drop a product name
from a doc comment.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 06:12:13 -04:00
eb2fb2634f Add rendering modes and CPU fallback recovery (#434)
* Add rendering policy with legacy preference migration and safe adapter selection

* Add rendering mode preferences and GPU fallback recovery warning

* Complete CPU fallback recovery and validate rendering mode UI

* Simplify GPU recovery and reuse preference persistence

* Rendering modes: rebase onto the preferences merge/retry save path; drop docs screenshots

save_preferences now writes through write_prefs (merging other instances' changes) and
only marks the revision saved when the write succeeds, so a failed recovery choice is
still retried. Removes a leftover diff3 marker in es.tsv, the docs/images/rendering-*.png
screenshots and their ATTRIBUTION row (no binary assets in the repo), and regenerates
docs/scorecard.md for the new performance.renderingMode preference.

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-07 06:11:06 -04:00
BFlatandClaude Opus 5.5 60224d3fb7 Release 0.3.0 (#577)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 17:10:08 +07:00
Carter Smith 06b0ad7af0 Color Picker and Color panel fields accept typed values (#485)
The Color Picker's hex field was rebuilt from the colour every frame,
so anything short of six valid digits was thrown away and it stayed at
000000. It now shows what is being typed while it has focus.

The Lab and CMYK fields applied every keystroke through clamped sRGB,
so typing "60" into L went through L = 6 first and changed a and b.
The dialog now keeps the typed Lab or CMYK values while they still
produce the colour, as it already did for HSB.

The Color panel's Hue and Saturation fields (Studio and Classic
themes) reset to 0 on black and greys; they now keep the typed values.

parse_hex no longer panics on multi-byte text.
2026-10-07 05:56:00 -04:00
51d85c1170 Linux: AppImage update information and a .zsync beside it (#349) (#399)
* Linux: AppImage update information and a .zsync beside it (#349)

appimagetool now embeds
gh-releases-zsync|<owner>|<repo>|latest|photocraft-*-linux-<arch>.AppImage.zsync
(owner/repo from GITHUB_REPOSITORY, default storytold/photocraft), so
AppImageUpdate, AppImageLauncher and the like can find the next release and
fetch only the blocks that changed. With zsyncmake on the host it also writes
the .zsync, into its working directory, so the script runs it in $DIST and
warns when the file is missing.

The release job installs zsync and checks the embedded string and the .zsync
before uploading; docs/releasing.md lists the new asset.

Verified locally with packaging/linux/package.sh --skip-build --formats
appimage and zsyncmake from Ubuntu 22.04: --appimage-updateinformation prints
the string, and the .zsync's URL is the AppImage's basename.

* package.sh: absolute AppDir and appimagetool paths before running in $DIST

appimagetool now runs inside $DIST so zsyncmake writes the .zsync there,
which broke relative CARGO_TARGET_DIR / APPIMAGETOOL paths (AGENTS.md
suggests target/agent-<name>). Resolve both first, remove a stale .zsync
so a missing zsyncmake is reported, and list the .zsync and zsyncmake in
the header.

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

---------

Co-authored-by: bflatastic <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 05:26:01 -04:00
BFlatandClaude Opus 5.5 8a8371395b Import warns when it keeps only the first frame or page, or a JPEG ends early (#518, #523) (#563)
Decoders now report what the decoded image doesn't show in a new
`Image::warnings` (`photocraft_codecs::DecodeWarning`), and `image_to_document`
turns them into import warnings, so they reach `photocraft-cli info` /
`convert` stderr, `doc.open` over MCP and serve, and the app's open notice
through the existing import-warnings path.

* #523: APNG (acTL frames, plus a default image outside the animation),
  animated GIF (image descriptors counted by walking the blocks), animated
  WebP and multi-page TIFF (IFDs walked without decoding; reduced-resolution
  copies and transparency masks are not pages) say "only the first of N
  frames/pages was imported". Single images stay quiet; the kept frame's
  pixels are unchanged.
* #518: a JPEG whose data runs out before its end-of-image marker (marker
  segments skipped by length, scan data by byte stuffing) still decodes
  leniently but warns "JPEG data ends early (the file is truncated or
  damaged); part of the image is missing". A JPEG that ends before any scan
  data is an error.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:20:02 +07:00
BFlatandClaude Opus 5.5 d9413cec0f FreeBSD CI: run the GPU integration tests one at a time (#576)
The VM's software Vulkan driver crashes (SIGSEGV) when several tests set up a GPU
device at once: about half of recent FreeBSD runs died within 0.05 s of a GPU test
binary starting 2-3 tests in parallel (drag_preview_canvas, color_managed_canvas,
adjust_preview_gpu), before any of them finished. Run photocraft-ui-egui's
integration tests with --test-threads=1 there; everything else is unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 16:19:48 +07:00
BFlatandClaude Opus 5.5 2628b0dd3d Preferences merge with other tabs/windows and retry failed saves; web startup shows a Reload hint (#475, #476, #477) (#561)
* Preferences: saves merge with what another tab or window stored, and failed writes are retried (#475, #476)

A save now writes only the values this instance changed since it last
loaded or saved them over what storage holds (a three-way merge over the
JSON tree; arrays are leaves), so a second browser tab no longer reverts
the other's changes. The saved revision moves only when the write
succeeds: a failure is retried after 2 s, doubling to 30 s (sooner after a
newer change), with a status message on the first failure and a notice
while it keeps failing. Shared by the desktop and web builds.

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

* Web: a startup that never gets going shows what may have failed and a Reload link (#477)

When the .wasm fails to download or doesn't match the page, no Rust runs,
so the loading page itself (CSS only, no script) adds the hint after 20 s.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 15:07:19 +07:00
BFlatandClaude Opus 5.5 2868220032 Formats: deep .pcraft groups reopen; zero-size PSD rejected; JPEG mattes over white; no-ICC formats get sRGB (#517, #519, #521, #522) (#555)
* PSD: reject a header with zero width or height (#519)

A 0xN header opened as an empty document and saved back as another
zero-sized PSD or .pcraft. Header::validate now requires at least 1x1
(the spec range starts at 1), and PSD export validates the header so a
zero-sized canvas is never written.

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

* .pcraft: documents with deeply nested groups load again (#517)

serde_json's 128-level recursion limit made bundles with 40 or more
nested groups unreadable after a successful save. The manifest is now
parsed with that limit disabled behind a linear JSON depth check, layers
are decoded and converted one level at a time with small stack frames,
and groups may nest up to MAX_GROUP_DEPTH (100) levels, which load and
save within a few hundred KiB of stack. Saving a deeper document fails
instead of writing a bundle that can't be opened.

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

* Export: convert non-sRGB RGB to sRGB for formats without a profile (#522)

GIF, BMP, QOI, TGA, ICO and Netpbm can't embed ICC, so a linear-light
or Display P3 document was written with values that only meant something
under the dropped profile (a linear EXR came out dark). export_flat now
converts such RGB to sRGB through photocraft-cms (perceptual, as Quick
Export does) for every photocraft_io::export route. The EXR/HDR linear
conversion shares the same banded helper.

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

* Export: composite transparency over white for formats without alpha (#521)

The CLI, MCP, Save As, Layers to Files and Layer > Export As dropped the
alpha channel when writing JPEG (or Radiance HDR), exposing the colours
stored under transparent pixels, while Export As and Quick Export
composite over white. export_flat now flattens over white too (no ink
for CMYK) and says so in its warnings.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 15:06:55 +07:00
BFlatandClaude Opus 5.5 7eb3b2e072 Automation: non-object params rejected; headless sees finished jobs; serve stdio survives a bad line; MCP batch keeps results (#503, #504, #505, #506, #529) (#546)
* Command params that are not a JSON object are an error (#506)

`Session::dispatch` now rejects array, string, number and boolean params
with "invalid parameters for `<id>`: params must be a JSON object" instead
of running the command with every default; `null` still means no params.
The one check covers the control channel, `serve`, MCP (headless and
bridge) and the CLI, and batch steps fail per step. `photocraft-cli run
--new` prefixes its error with `--new`.

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

* Headless requests apply finished background jobs first (#503)

A `wait: false` job was applied to its document only by a later command
or `jobs.list`, so `doc.save`, export, `doc.inspect`, `doc.render` and
`session.list` (and their MCP tools) still read and wrote the document
without it. `Headless::sync_jobs` now runs at the top of every JSON-lines
request and before every headless MCP tool call.

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

* `serve` stdio keeps the session after a rejected request line (#505)

An over-long request line (or one that is not UTF-8) ended the stdio
session, losing every open document. It now gets one error reply, the
rest of that line is skipped without being dispatched, and the next
request is served. `read_bounded_line` reads bytes, so a cut inside a
multi-byte character is reported as too long instead of an I/O error.
TCP connections still close after an oversized line.

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

* MCP `command_batch` keeps its step results when the reply budget stops it (#504)

The batch budget charged each step's plain JSON size, but MCP sends the
batch reply as escaped text content, so a batch the budget had already
stopped could still exceed the tool-result ceiling and come back as a
bare "response exceeds" error. MCP (headless and bridge) now uses
`BatchReplyBudget::escaped`, which charges the escaped size, through the
new `Headless::batch_with_budget`; `serve` keeps the plain budget.

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

* docs: the 1 MiB request-line limit does not apply to MCP stdio (#529)

The book stated the 1 MiB request-line limit without naming a transport.
It applies to desktop and headless TCP and to the headless JSON-lines
stdio server; MCP over stdio reads its own lines and only its tool
results are size-checked. Say so in the automation security pages and in
the control protocol's transport limits.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 14:00:36 +07:00
BFlatandClaude Opus 5.5 8f0f95959d Control channel: menu/dialog requests wait for their job; right-click menus at the point; MCP ui_pointer button (#510, #511, #512, #514) (#548)
* ui.inspect descriptions point to ui.menu.list for the menu tree (#510)

The control-protocol doc, the control.rs method list and the MCP
`ui_inspect` description said `ui.inspect` returns the menu tree; it
never did. They now say the menu tree comes from `ui.menu.list` (over
MCP, through `control_call`).

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

* ui.menu.invoke and ui.dialog.confirm wait for the job they start (#511)

Since filters became background jobs, invoking a no-dialog filter from
the menu or confirming a filter dialog over the control channel replied
`{job, pending: true}` before the filter was applied, unlike v0.2.0 and
unlike `engine.execute`. All three now share one helper that runs the
command with script events off and, when it started a job, defers the
reply until the job's result lands; `"wait": false` keeps the immediate
`{job, pending}` reply. The now-unused `run_automation` is gone.

The new test drives Blur More through `ui.menu.invoke` and Gaussian Blur
through its dialog's OK, with and without `wait` (it failed before).

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

* ui.pointer right-click opens its menu at the pressed point (#512)

A `ui.pointer` right-click listed the layers at the requested document
point but drew the layer menu at the canvas centre, and the Brush Preset
picker opened there too. Both now open at the point's screen position,
where a mouse right-click on it opens them. `ViewXform::active` gives the
active canvas's document-to-screen mapping as last laid out (the
construction several tests repeat); the centre stays the fallback when
the point can't be mapped.

The ui.pointer doc now lists `button`, both menus, and that top-level
modifier flags are ignored when `modifiers` is given.

The new test checks two points against the mouse path and the picker
position (it failed before).

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

* MCP ui_pointer forwards button and rejects unknown arguments (#514)

Bridge clients couldn't right-click through `ui_pointer`: it forwarded
only `events` and `modifiers` and silently dropped anything else
(`button`, a typo, a top-level `space`). It now forwards an optional
`button`, and `PointerParams` denies unknown fields, so the schema says
`additionalProperties: false` and a stray argument is an error.

The new bridge test checks the forwarded params, the rejection of
`buton` and top-level `space`, and the tool schema (it failed before).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:55:01 +07:00
BFlatandClaude Opus 5.5 13b0d856b6 Shortcuts run in the order pressed; Shift+Delete fills on Windows; colour pickers stay put while dragging (#440, #530, #534) (#552)
* Shortcuts pressed in one frame run in the order they were pressed (#440)

Undo then Save in the same frame dropped the Undo: only the first binding in table order ran. Each key press now dispatches in event order, and a shortcut that opens a dialog, prompt, menu or inline type leaves the frame's later presses to it.

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

* Shift+Delete opens Edit › Fill on Windows instead of cutting (#530)

egui-winit reports Shift+Delete as a Cut on Windows. Outside text fields, a Cut with Shift and without Ctrl is now the Shift+Delete press again, so it fills like Shift+Backspace; Ctrl+X still cuts and text fields keep their Cut.

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

* Shape and text colour pickers stay put while dragging (#534)

The picker's width follows its RGB readouts, and egui placed the popup anew every frame: near the right edge of the window it flipped between opening right and left of the swatch, under the pointer. A swatch's picker now keeps the left edge it opened with, shifted left of the swatch when the window edge needs it.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:54:31 +07:00
BFlatandClaude Opus 5.5 e30b121969 History: tool and preset names, Frame from Layers as one step, undo/redo restore the targeted layers (#513, #528, #446, #496, #495) (#551)
* Elliptical Marquee selections record "Elliptical Marquee" in History (#513)

Both marquee tools commit through `select.rect`, which named every step
"Rectangular Marquee" whatever its `ellipse` param. The step is now named
after the tool that made it; the rectangular marquee keeps its name.

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

* Preset filters record their own names in History (#528)

Blur, Blur More, Sharpen, Sharpen More, Sharpen Edges and Despeckle run
a generic algorithm with fixed parameters and recorded its name
("Gaussian Blur", "Unsharp Mask", "Surface Blur"). The presets now live
in one table with their names, used for the history step and the job
progress text; Gaussian Blur, Unsharp Mask and Surface Blur keep theirs.

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

* Vertical type command no longer claims it renders horizontally (#446)

Vertical type has rendered in columns since #199, but the params text of
`type.orientation.vertical` still said it was "rendered horizontally for
now". It now documents the same `{"layer":id?}` as Horizontal.

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

* Frame from Layers is one history step (#496)

It grouped the layers through `layer.new.groupFromLayers` (its own
"Group Layers" step) and then recorded "Frame from Layers", so one undo
left the group behind. The grouping step is now folded into the frame
step with `purge_last`, as Transform Again on a copy does; if the group
can't be framed, the grouping is undone with nothing left to redo.

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

* Undo and redo restore the layers each history state targeted (#495)

History states held only the document, so after undo or redo the active
layer stayed whatever it was: redoing New Layer left the old layer
active (a Fill then painted it), and undoing Delete Layer left the
restored layer unselected. Each state now also keeps the active and
selected layers it had when it was created (when the document was
opened, or right after the step that made it), and undo and redo target
them again where they still exist. Selecting a layer is still not a
step and doesn't change any state's target, so selecting another layer
and then undoing or redoing goes back to that state's target, as the
reference app's Step Backward does.

The History panel lists redo labels from a new `redo_labels` accessor
instead of cloning and replaying the history every frame.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:54:10 +07:00
BFlatandClaude Opus 5.5 bd1322a5e4 Recovered documents keep their recovery data until saved or closed (#444) (#549)
Launch no longer deletes recovery entries right after loading them. A
recovered document adopts the entry it came from: its next autosave
replaces it in place (atomically) and saving or closing the document
removes it, so a second crash before the next autosave loses nothing.
New documents autosave under per-launch keys, so a document id reused
from an earlier launch never overwrites a surviving entry, and sidecars
whose key doesn't match their file name are ignored.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:53:48 +07:00
BFlatandClaude Opus 5.5 faaa42db95 Gradients honour explicit transparency stops; smart filter commands accept an explicit layer target (#465, #466) (#544)
* Gradients honour explicit transparency stops with colors, a preset or the current gradient (#465)

`paint.gradient` and `gradient.fill.create` dropped `transparency` unless `stops` was
given. Explicit transparency stops now replace the gradient's own opacity stops whatever
supplies the colours. One shared parser (`transparency_param`) serves paint.gradient,
gradient.fill.create/set and the presets; malformed stops are an error, not dropped.

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

* Smart filter commands check an explicit layer target, not the active layer (#466)

`layer.smartFilter.*` with `"layer":id` were refused when the active layer was not a
smart object with filters, although they edit the named layer. Their precondition is now
checked on that layer; the active layer stays unchanged, and without `layer` (menus)
nothing changes.

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

* Gradients: reword the opacity-merge doc comment

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:21:22 +07:00
BFlatandClaude Opus 5.5 1df68e9a5d CLI batch: recorded-action steps, --format .ext, strict --quality, refuse --out = --in (#489, #490, #491, #492) (#545)
* CLI --quality refuses every value outside 1-100 (#491)

--quality was parsed as a u8 and clamped, so 0 and 101-255 were silently
accepted while 256 and above were rejected. Every value outside 1-100 is now
a bad --quality error, checked before any file is read or written.

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

* CLI batch: --format with a leading dot names outputs <stem>.<ext> (#490)

batch built output names from the raw --format value, so --format .jpg wrote
a..jpg. One leading dot is now dropped, as the save path and
file.automate.batch already do.

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

* CLI batch --actions takes recorded-action and droplet steps (#489)

batch read only {"command", "params"} objects, so the [id, params] pairs a
recorded action or droplet stores failed with 'missing "command"'. It now
uses the engine's action parser (automate_cmds::parse_action, split out of
parse_script), which takes [id, params], objects and bare ids, bare or
wrapped in {"steps": ...} or a droplet; {"actions": ...} still works.

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

* CLI batch refuses an --out folder that is the --in folder (#492)

Results are saved as <out>/<stem>.<ext>, so --out naming the input folder
silently replaced the originals. batch now compares the canonical folders
(relative spellings, trailing separators, symbolic links) and fails before
writing anything, naming the folder; the new --in-place flag opts in.

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 13:21:07 +07:00
BFlatandClaude Opus 5.5 29c280a2c6 Docs: roadmap, scorecard and README after the 2026-10-07 user-report fixes (#538)
- Roadmap (updated 2026-10-07): the M11 row notes the stricter CLI,
  batch collision reports, per-step `wait` in MCP batches and safe
  headless saves; the UI / UX row lists the keyboard-only shortcuts
  (layer navigation, number keys, hardness, step and repeat), Alt-click
  sampling, double-click for Layer Style, New from Clipboard, the
  centred window and remembered Liquify settings.
- Scorecard: UI-217-1 is partial (Fill and Liquify settings persist);
  UI-217-2's note points at the new `native_options()`. docs/scorecard.md
  regenerated; docs/parity.md is unchanged (no new catalogue items).
- README: `<subcommand> --help` in the CLI snippet.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 12:57:23 +07:00
sommerperandClaude Opus 5.5 4337a6227a Ctrl+Insert copies and Shift+Insert pastes on Linux; Shift+Insert pastes images on Windows (#531)
On Linux, Ctrl+Insert / Shift+Insert become Copy / Paste on the canvas (the pixel
commands' Ctrl+C / Ctrl+V) and in text fields (Event::Copy; Shift+Insert asks
egui-winit for the clipboard text with ViewportCommand::RequestPaste).

On Windows egui-winit already maps both keys, but drops Shift+Insert when the
clipboard holds only an image. Its key release now pastes, like the existing
Ctrl+V fallback.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 12:05:59 +07:00
floflo777 8a5e89faa6 32-bit documents: Exposure works on the stored linear values (#400)
* 32-bit documents: Exposure works on the stored linear values

Their samples are linear already, so going through the 2.2 curve applied
the gamma twice. rgb32 and gray32 exposure.psd now match Photoshop within
0.5/255 (was 69/255 and 44/255).

* Raise the Photoshop oracle floor to 130
2026-10-07 11:49:29 +07:00
Alex GorevskiandCopilot a312c3d0c7 PSD export: select PSB from size estimate (#431)
Estimate encoded raster and retained-data size with checked arithmetic, and select PSB when the PSD limit is exceeded. Keep forced PSB choices and document the completed scorecard slice.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
2026-10-07 11:41:08 +07:00
sommerperandClaude Opus 5.5 4fdb71dfc3 Dialogs keep their top-left when their content resizes (#487)
Dialogs were anchored at the screen centre, so a body that grew (Layer
Style switching effects) re-centred the dialog and moved its title bar.
They now open centred, then pin their top-left after the first laid-out
frame: they grow down and right, and title-bar drags move the pinned
corner (still clamped on screen).

The Color Range resize test now asserts the top-left stays put (it
failed before the fix).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 11:40:54 +07:00
nsn 72e2eb9644 Spanish: add missing tool, filter and path translations (#488) 2026-10-07 11:40:40 +07:00
sommerperandClaude Opus 5.5 2051c0caf4 Move drag reveals a larger-than-canvas layer's off-canvas pixels (#526)
The live Move preview redrew only the moving layers' bounds clipped to
the canvas at drag start, then translated. For a layer larger than the
canvas, the strip sliding in from beyond the edge was never
recomposited, so the edge pixels smeared along the drag path. Keep the
bounds unclipped and clip them to the canvas after translating.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 11:40:23 +07:00
Coy GeekandContributor 5824e3afeb docs: add star history chart to README (#469)
Show PhotoCraft star growth at the bottom of the README with a linked Star History chart using the date view and top-left legend.

Co-authored-by: Contributor <[email protected]>
2026-10-07 11:32:34 +07:00
Carter Smith f373fdb52f Raise rust-version to 1.95, the minimum egui 0.36 needs (#471) 2026-10-07 11:32:23 +07:00
BFlatandClaude Opus 5.5 d38eefc8f7 Free Transform a Copy (Cmd+Alt+T); Esc takes the copy back (part of #352) (#482)
The other hidden "quick transform" shortcut. Cmd+Alt+T (no menu item,
in Keyboard Shortcuts as "Free Transform a Copy") duplicates the active
layer, or with a selection copies the selected pixels to a new layer
(Layer via Copy), and opens Free Transform on the copy. OK leaves the
original in place and the transformed copy as one history step (the
duplicate's state is folded away); an untouched OK keeps the copy where
it is; Esc, or a transform that fails, undoes the copy and leaves
nothing to redo. `TransformSession::copy` marks such a session.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 10:56:58 +07:00
Alex GorevskiandCopilot 7b3d131bc8 docs: correct VectorCraft sibling name (#429)
Replace the stale DrawCraft name in the contributor sibling list.

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

Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
2026-10-07 10:43:46 +07:00
Pedro Jose Soto Rivera cb00e2921b Type: dragging a paragraph box handle resizes the box (#351) (#376)
The frame drawn around paragraph text while editing had eight handles,
but a press on them only placed the caret. They now resize the box:
corners move two edges, side handles one, with a 4 px minimum. The drag
goes through type.edit with the session's coalesce key, so it is one
history step.
2026-10-07 10:43:31 +07:00
ljkxandClaude Opus 5.5 d2576b27eb Profile mismatch: compare profiles by colour, not bytes (#394)
Opening any file Photoshop tagged with sRGB IEC61966-2.1 asked
"Embedded Profile Mismatch: sRGB IEC61966-2.1 vs sRGB IEC61966-2.1":
open_policy compared a hash of the ICC bytes, and Photoshop's v2 profile
(1024-entry tables) and the built-in v4 sRGB (parametric curves) are the
same colours in different bytes. 32-bit documents in Photoshop's linear
sRGB missed the stay-linear exception for the same reason.

Add Profile::same_colors: same colour space and every sample of a device
grid within dE76 0.5 through both (re-encoding costs 0.025; moving the
red primary 2% costs 1.0). Colours are compared rather than round-tripped
because CMYK -> PCS -> CMYK is not an identity even for one profile.

Of the 75 profile-tagged PSDs in the test corpora, all 75 prompted on
open; now 6 do (Adobe RGB, Gray Gamma 2.2, SWOP), each a real mismatch.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 10:43:18 +07:00
Nauman Ahmed 870741a5cd fix: use wasm-safe timing for Liquify (#393) 2026-10-07 10:38:32 +07:00
Alex GorevskiandCopilot 1cb5af9218 Build desktop app by default (#425)
Include apps/photocraft in workspace default-members so a plain cargo build also builds the native UI.

Co-authored-by: Copilot <[email protected]>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
2026-10-07 10:34:59 +07:00
Armaan Sandhu 9cf7c42f1d Content-Aware Scale: enlarging a 1 px wide or tall layer no longer panics (#387)
To enlarge, carve_width_with finds seams on a shrinking copy and duplicates them, but it stopped once the copy was 1 px wide, so a 1 px wide image found no seam and came back at its old width. The caller then wrote a short buffer into the new size (the "region data length mismatch" assertion in write_region) or, with a height change too, transpose indexed past the end. The session's guard turned both into "failed with an internal error". The single column is now taken as the seam, so it is repeated; wider images are unaffected, because a round takes at most half the columns.
2026-10-07 10:34:44 +07:00
HEOJUNFOandClaude Opus 5.5 533566c5ad prefs.reset resets a single colorSettings key (#445) (#467)
`prefs.get` and `prefs.set` route `colorSettings.*` to the colour state,
but `prefs.reset` only special-cased the whole section. A nested path
fell through to `Preferences::reset`, which has no colorSettings, so it
failed with "unknown preference". Take the default from
`ColorSettings::default()` and set it the way `prefs.set` does.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 10:34:30 +07:00
HEOJUNFOandClaude Opus 5.5 778e8e187d Image Size shows the one-pixel minimum it applies (#441) (#468)
`dim_field` allows negatives for Canvas Size's relative mode, so a typed
0 width stayed 0 in the Image Size dialog, which previewed "0 px × 1 px"
while `image.imageSize` resized to 1×1. Clamp edited dimensions to one
pixel so the preview shows the size that will be applied.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-07 10:34:17 +07:00
floflo777 9ae984c0ae Use the real Gaussian Blur id in the docs and MCP schema (#390)
filter.blur.gaussian isn't registered, so the documented CLI run and the
agent loop failed with "unknown command". Pin the schema examples with a test.
2026-10-07 10:34:03 +07:00
Pedro Jose Soto Rivera a0997942d3 Export: TGA in Save As and Export As (#363) (#377)
The TGA codec already encoded and decoded, but neither Save As nor
Export As listed it. Add it to both and cover the round trip at
RGBA, RGB and gray.
2026-10-07 10:28:01 +07:00
BFlatandClaude Opus 5.5 93bd44899b Number keys set opacity, Shift+[ / Shift+] step hardness, Shift+Cmd+A opens Camera Raw (part of #352) (#473)
* Number keys set opacity, Shift+[ / Shift+] step hardness, Shift+Cmd+A opens Camera Raw (part of #352)

More of the shortcuts users expect without a menu item:

- 1–9 set 10–90% opacity and 0 sets 100%; two digits typed quickly
  give an exact value (4 5 = 45%, 0 0 = 0%). With the Brush, Pencil or
  Eraser that's the brush's Opacity, and Shift+digit its Flow (Brush,
  Eraser); with the Gradient or Paint Bucket their Opacity; with tools
  that have no opacity of their own (Move, selection, Type…) the active
  layer's Opacity, Shift for Fill. The locked Background, and tools
  whose number is a strength or exposure, are left alone. Digits are
  read by physical key, so Shift+digit works on any layout.
- Shift+[ / Shift+] step a painting tool's hardness by 25%. They're
  checked before [ and ], whose egui key test ignores Shift.
- Filter › Camera Raw Filter… gets its default Shift+Cmd+A.

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

* opacity_keys: module doc without a product name

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 10:19:04 +07:00
BFlatandClaude Opus 5.5 f697e43ef7 Transform Again on a Copy (Cmd+Alt+Shift+T): step and repeat (part of #352) (#474)
The hidden shortcut for step-and-repeat was missing. A new command,
`edit.transform.againCopy` (no menu item, Cmd+Alt+Shift+T), duplicates
the active layer and repeats the last transform on the copy, as one
history step labelled "Transform Again", so repeated presses lay out a
row (or a ring) of copies one transform apart. The transform is worked
out before anything changes (`transform_again_params`, now shared with
Edit › Transform › Again), so nothing to repeat leaves no copy; a copy
that can't be transformed (position locked) is taken back with nothing
left to redo (`History::clear_redo`).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:55:50 +07:00
BFlatandClaude Opus 5.5 f49b5a4853 Keyboard layer navigation: Alt+[ / Alt+] / Alt+, / Alt+. and Shift to add (part of #352) (#470)
The layer shortcuts with no menu item were missing. A new module,
`layer_nav_cmds`, adds six commands that walk the Layers panel's rows
(a group's row before its children, collapsed groups as one row):
Select Layer Above / Below (Alt+] / Alt+[), Select Top / Bottom Layer
(Alt+. / Alt+,), and Add Layer Above / Below to Selection
(Alt+Shift+] / Alt+Shift+[). They stop at the first and last rows,
never dirty the document, and are listed under Layer in Keyboard
Shortcuts so they can be rebound. Agents run them like any command.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:31:15 +07:00
BFlatandClaude Opus 5.5 58bfb4ee8c The main window opens centred on the screen (#419) (#462)
Windows places a new window that has no position by cascading it from
the top-left corner, so PhotoCraft opened at a different offset from
the corner on every launch. The window now starts centred on the main
monitor (eframe's `centered`), at the same 1440 × 900, which eframe
shrinks to the monitor first and `work_area::fit_window` still
maximizes when it doesn't fit (#315). Wayland compositors keep placing
windows themselves. The window options moved into `native_options()` so
a test can check them.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:19:57 +07:00
BFlatandClaude Opus 5.5 c8e0a34792 Liquify remembers its brush settings between uses and launches (#418) (#461)
Every time Liquify opened, the brush went back to the defaults (Density
50 and so on). Its settings (tool, size, density, pressure, rate, mesh
and backdrop options) are now kept in `prefs.dialogs["filter.liquify"]`
whenever the dialog closes (OK, Cancel, Esc or the control channel) and
restored the next time it opens, also after a restart. The first use
still starts from the defaults with a brush about a tenth of the image.

`LiquifyOpts::apply` and `to_json` read and write the settings; the
control channel's setter now uses `apply` too, and a damaged saved value
is clamped or skipped, never a failure to open. The three cancel paths
share one `cancel`.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:19:38 +07:00
BFlatandClaude Opus 5.5 14207b76c8 CLI: unknown flags are usage errors, <subcommand> --help prints usage, usage lists --quality (#421, #423) (#460)
Any `--flag` a subcommand didn't know was recorded and ignored, so
`batch --fromat jpg` wrote the source formats and exited 0, `run
--param '{…}'` turned the JSON into a stray file name, and
`<subcommand> --help` ran the subcommand (`mcp --help` started a
server). One table, `SUBCOMMANDS`, now lists each subcommand's value
flags, bare flags and handler, replacing the shared VALUE_FLAGS list:
an unknown flag in either form (`--flag value` or `--flag=value`) is a
usage error naming it (exit 2) before any work, a value on a bare flag
is an error, and `--help` or `-h` after a subcommand prints usage and
exits 0. Every documented flag keeps working in both forms.

The usage text now lists `--quality` for `run` and `batch`, which
already honoured it, and the book's CLI page describes the stricter
parsing.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:00:38 +07:00
BFlatandClaude Opus 5.5 3c6310e559 New from Clipboard, and Paste with nothing open makes a document (#368) (#458)
File › New from Clipboard (`file.newFromClipboard`, right after New…)
opens the clipboard image as a document of its own: sized to the image,
holding it as one layer, in the pixel format it was copied in. It has
no default shortcut; one can be assigned in Keyboard Shortcuts. Edit ›
Paste (Ctrl+V) with no document open does the same, so a screenshot is
one keystroke from the start screen. Both read the OS clipboard only
when invoked, stay enabled from the clipboard service like Paste, and
are a quiet no-op when it holds no image.

Agents reach it through MCP and `serve` too: the command only uses the
session clipboard, so it joins the automation allowlist of file
commands. The menu label is in every catalog.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:00:22 +07:00
BFlatandClaude Opus 5.5 c0a7f8e7e4 Batch runs report inputs that share an output name instead of overwriting (#420, #422) (#448)
`file.automate.batch`, `file.scripts.imageProcessor`,
`file.automate.lensCorrection`, droplets (which run batch) and
`photocraft-cli batch` named every output `<stem>.<ext>` in one folder.
When two inputs mapped to the same name (`a.png` and `a.jpg` saved as
JPEG, or the same file name in two input folders), the later save
replaced the earlier result while both were listed as written and the
run exited 0.

`engine::file_cmds::OutputClaims` remembers the outputs written in a
run, ignoring case as macOS and Windows file systems do. An input whose
output was already written is skipped before any work and reported in
`errors` (the CLI prints FAIL and exits non-zero); the first result is
kept. Runs whose names don't collide, including the same stem kept in
its own formats, are unchanged. The commands' params docs say so.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 09:00:05 +07:00
BFlatandClaude Opus 5.5 6119c00962 Layers panel: double-click beside a layer's name opens Layer Style (#350) (#452)
A double-click anywhere on a Layers row started a rename. Now only a
double-click on the name renames; the rest of the row follows the
layer's kind: an adjustment or fill thumbnail opens its Properties
(Layer Content Options), a Smart Object thumbnail opens its contents
(Edit Contents), and anywhere else opens Layer Style (Blending Options).
The Background still becomes a normal layer, mask thumbnails keep their
own clicks, and a command that's disabled for the layer does nothing.
docs/ui-design.md lists the row's double-click targets.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 08:37:47 +07:00
BFlatandClaude Opus 5.5 b926677549 Alt-click with the Brush, Pencil, Gradient or Paint Bucket samples a colour (#417) (#450)
Holding Alt (Option) with a painting tool now works as the Eyedropper
for that press: a click sets the foreground colour and a drag keeps
sampling, without painting or recording a history step. The choice is
made when the press starts, so Alt pressed or let go mid-stroke never
switches between painting and sampling, and Control+Alt stays the
brush-resize drag. While Alt is held the cursor shows the Eyedropper's
crosshair. Agents get the same through `ui.pointer` with `alt`.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 08:27:06 +07:00
BFlatandClaude Opus 5.5 3443833532 Headless saves without path never flatten over the opened file (#416) (#447)
MCP `doc_save {}` and `serve` `doc.save {}` fell back to the document's
own path for any format, so an agent that saved its work replaced the
input PNG or JPEG with a flattened, re-encoded copy, and a `format`
override wrote PNG bytes under a `.psd` name. The desktop's File › Save
already writes back only to layered files.

The rule now lives in one place, `engine::file_cmds::saves_in_place`
(plus `extension`), shared by File › Save, the control channel's
`app.save` and the headless save. Without `path`, a save writes back
only to a PSD, PSB or .pcraft file in its own format; anything else is
an error naming `path` and the file is left unchanged. An explicit
`path`, even the opened file's own, still writes as before. The MCP
`doc_save` description and `path` doc, and docs/control-protocol.md,
state the rule.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 08:20:43 +07:00
BFlatandClaude Opus 5.5 55054b5fcc MCP command_batch and serve batch honour each step's wait (#415) (#439)
`command_batch` steps reuse `command_run`'s params, so its schema
advertised `wait`, but the tool mapped each step to `{command, params}`
and ran it to completion: `"wait": false` blocked on the filter and
started no job. Steps now carry `wait` (default true): headless batches
start the command through `command_start`, and bridge mode forwards it
to `engine.execute`, which already honours it. `serve`'s `batch` reads a
step's `wait` the same way. The schema doc and docs/control-protocol.md
describe the per-step `wait`.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:59:57 +07:00
BFlatandClaude Opus 5.5 7930dd0adf serve: methods lists jobs.list and jobs.cancel (#414) (#438)
The headless server dispatched `jobs.list` and `jobs.cancel` but left
them out of `METHODS`, so a client discovering the server through
`methods` couldn't find the job methods `engine.execute {"wait": false}`
depends on. Add them, and name them in the rpc.rs module doc and the
`photocraft-cli serve` usage text. A test checks that the job methods
are listed and that every listed name is dispatched.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:59:39 +07:00
BFlatandClaude Opus 5.5 2620460267 ui.set: reject unknown fields; the theme error names all five themes (#412, #413) (#437)
`ui.set` skipped keys it doesn't read and replied with success, so the
`dark` field advertised by the MCP `ui_set` tool (and any typo) changed
nothing while reporting ok. It now checks every key against
`UI_SET_FIELDS` before applying any of them and names the accepted
fields in the error. The MCP tool description, its `fields` doc, the
control.rs method list and docs/control-protocol.md list the real fields.

An unknown theme listed only studio, studioLight and classic; the error
now comes from `ThemeKind::ALL` through the new `ThemeKind::id`, so it
names pro and proMedium too, and each listed name selects its theme.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-07 07:59:14 +07:00
Brandon ThomasandClaude Opus 5.5 ba9272be7d Alt+scroll zooms the canvas in 5% steps around the pointer (#293) (#371) (#371)
Merged with #367: the wheel also navigates while hovering the canvas scrollbars.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 18:22:47 -04:00
Brandon ThomasandClaude Opus 5.5 0940f82d34 Alt+right-drag resizes the brush and changes its hardness (#297) (#370) (#370)
Merged with #360's right-click layer menu: Cmd/Ctrl+right-click lists layers, Alt+right-drag resizes, plain right-click keeps the brush picker / erase.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 18:22:35 -04:00
Brandon Thomas fbcd0c238e Canvas scrollbars, and Preferences > Tools > Overscroll works (#300) (#367) (#367) 2026-10-06 18:22:23 -04:00
Brandon Thomas 0da7d4b0f5 Right-click on the canvas lists the layers under the pointer (#307) (#360) (#360) 2026-10-06 18:22:11 -04:00
Brandon Thomas 90a46d3f89 Stamp Visible (Cmd+Alt+Shift+E) and Stamp Down; Alt+Merge keeps the originals (#217) (#356) (#356) 2026-10-06 18:21:59 -04:00
Brandon Thomas c161eaec73 Fonts: use craft-fonts as an optional build input; move the Japanese fonts out (#353) (#353) 2026-10-06 18:21:46 -04:00
Brandon Thomas 0349b0c0f1 Harden EXIF orientation: limits after rotation, fallible allocation, strict IFD parsing (#345) (#345) 2026-10-06 18:21:33 -04:00
Brandon ThomasandClaude Opus 5.5 909efc0f6d Background jobs in the app: progress, Cancel, open-as-job (part of #210) (#373)
Re-lands #290, which was merged into its stacked base branch (jobs/engine-210) instead of main.
Same tree as the gated #290 commit 39152db.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 15:44:40 -04:00
Brandon ThomasandClaude Opus 5.5 3b7870bee1 Background jobs: engine job API, progress and cancel (part of #210) (#289)
* Test: retry the GPU startup sentinel check while a forked child briefly shares the lock

monitor_profile tests spawn osascript in the same binary; between fork and exec the child holds
a copy of the marker file descriptor, so flock reports WouldBlock and begin() returns Busy.

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

* Background jobs: engine job API, progress and cancel (part of #210) (#289)

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-06 15:41:38 -04:00
Brandon Thomas a0d49de047 Menus stay above the taskbar; oversized windows open maximized (#315) (#343) (#343) 2026-10-06 11:30:10 -04:00
Brandon Thomas f8563856a3 Menus: no menu or submenu covers the menu bar (#319) (#337) (#337) 2026-10-06 11:29:57 -04:00
Petr Leo Compel 5a6dd79852 i18n: add Czech (cs) UI translation (#328) (#328) 2026-10-06 11:29:44 -04:00
才倾梦华V b3c44cf070 Preferences: add an Apply button (#325) (#325) 2026-10-06 11:29:06 -04:00
TiredIsa abd6f6e591 Localise the UI into Russian: a complete catalog and the missed tl! call sites (#298) (#298) 2026-10-06 11:28:52 -04:00
Mawfyy edc441a8d4 Add a Spanish catalog (menus, dialogs, panels) (#299) (#299) 2026-10-06 11:28:37 -04:00
Andrew Shen 321e986f7b i18n: add Traditional Chinese (zh-hant) UI catalog (#302) (#302) 2026-10-06 11:28:23 -04:00
Sebastian Solano 4b76aff35d Fix logic Show the clone crosshair only while Option is held (#301) (#301) 2026-10-06 11:28:09 -04:00
leonaci 7544b649f2 fix(ui): feed every pointer move per frame so fast strokes stay smooth (#317) (#317) 2026-10-06 11:27:54 -04:00
Brandon Thomas dd2f566daa macOS CLI: verify the shipped CLI is Developer ID signed and notarized in the release workflow (#308) (#320) (#320) 2026-10-06 10:48:05 -04:00
Brandon Thomas ae9e1896cf Keyboard Shortcuts: Ctrl+F records as Ctrl+F, not Ctrl+ControlLeft (#292) (#323) (#323) 2026-10-06 10:47:50 -04:00
Brandon Thomas 48e6e668b9 Layers panel: rename commits on click elsewhere, Enter or Tab; one at a time (#314) (#326) (#326) 2026-10-06 10:47:33 -04:00
Brandon Thomas b6e9ec97e5 Cmd+Enter (Ctrl+Enter) loads the path as a selection (#306) (#329) (#329) 2026-10-06 10:47:11 -04:00
Brandon Thomas c046337ddf Move tool: Auto-Select on by default; a click picks and drags the layer under the pointer (#296) (#330) (#330) 2026-10-06 10:46:55 -04:00
JGF f091c7fbf0 Windows: Alt-click clone hint and Ctrl+N channel errors (#251 follow-up) (#336) (#336) 2026-10-06 10:46:41 -04:00
才倾梦华V 4e2a83ddbd Fix preference usage scanning on CRLF checkouts (#331) (#331) 2026-10-06 10:46:21 -04:00
Brandon Thomas e8677ff851 Windows on ARM64: release builds, PE header check, install test on ARM64 hardware (#333) (#333) 2026-10-06 10:09:55 -04:00
Brandon Thomas 44bf13e890 PSD export: 32-bit files open in Photoshop (HDR toning Color Mode Data, #291) (#327) (#327) 2026-10-06 09:23:20 -04:00
Brandon Thomas 7ee4bb2858 Apply EXIF orientation on open, write Orientation = 1 on export (#285) (#318) (#318) 2026-10-06 08:20:36 -04:00
Brandon Thomas eaf92dcd9d Smart Objects and Smart Filters survive PSD save and open (#288) (#288) 2026-10-06 07:46:37 -04:00
midasdfandmidasdf e5e2a8f2eb Enable dictionary segmentation for Japanese type (#250) (#250)
Co-authored-by: midasdf <[email protected]>
2026-10-06 07:44:08 -04:00
b5b5ce8394 i18n: add Simplified Chinese localization (#282)
* i18n: add Simplified Chinese translation catalog (#282)

* i18n: register the Simplified Chinese catalog (#282)

Adds the zh-hans row to LANGUAGES. It's marked partial (complete_menus: false): strings added
to main since the catalog was written fall back to English until translated.

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-06 07:43:52 -04:00
Multipad-cyberandMultipad-cyber 8713e26f5a Background layer: double-click, masks and cut behave like Photoshop (#266) (#266)
Co-authored-by: Multipad-cyber <[email protected]>
2026-10-06 07:43:37 -04:00
Multipad-cyberandMultipad-cyber 4c94e4d63a Marquee: stop the selection at the canvas edge (#263) (#263)
Co-authored-by: Multipad-cyber <[email protected]>
2026-10-06 07:43:21 -04:00
Cocoon-Breakandkuishou68 c34facea4d fix: forward document index in bridge inspection (#287) (#287)
Co-authored-by: kuishou68 <[email protected]>
2026-10-06 07:43:03 -04:00
Brandon Thomas a96a621dee FreeBSD release tarball and README platform lines (#284) (#284) 2026-10-05 20:54:00 -04:00
Brandon Thomas 5ada60c35b Compositor: effect maps never block inside parallel tiles (fixes #276 deadlock) (#281) (#281) 2026-10-05 20:07:16 -04:00
Brandon Thomas 3423864502 Fast Select › Modify › Smooth and Feather (part of #211) (#280) (#280) 2026-10-05 19:59:11 -04:00
Brandon Thomas 7db46219ce Pencil tool, Edit › Fill… dialog and "Rasterize?" prompt (#213) (#279) (#279) 2026-10-05 19:48:03 -04:00
17d4cd0d78 Localise the UI: i18n foundation and a Japanese catalog (menus, dialogs, panels) (#169)
* Localise the UI: i18n foundation and a Japanese catalog

Adds a small i18n layer and a complete Japanese catalog for the menus, the
command palette, dialogs and panels. English stays the default and the source
language; command ids, the control protocol, the CLI and MCP are unchanged.

- i18n (crates/ui-egui/src/i18n): one TSV catalog per language, registered with
  one row in LANGUAGES. Lookups: tr/tl! (plain), tr_ctx (context), tr_id
  (command-id keyed, English fallback), trn (plurals), fmt ({name}
  placeholders). Locale tags resolve most-specific-first; macOS falls back to
  the preferred-languages list.
- New preference interface.language (auto | language code), a dropdown in
  Preferences > Interface. The engine stores a validated code and does not
  know the language list.
- Widgets (checkbox, slider_row, dropdown, buttons, tabs, icon tooltips)
  translate their labels; direct egui calls use tl!("...").
- Japanese catalog (about 2,000 entries) written from the meaning of the
  English labels; no string table was extracted from any other product.
- Tests: catalog well-formedness (duplicates, placeholders, ellipses, plural
  forms, command ids) and coverage of menus, tl! literals, blend modes and
  generated preference labels.

Status-bar messages and errors stay English. Per-language font fallback,
right-to-left layout and locale-aware number formats are not done yet.

Rebased onto main (maintainer): conflicts resolved keeping main's newer UI
(tab strips, platform shortcut labels, menu navigation, Properties layout)
with the tl!/tr wrapping. command_label/tip_label translate their label
before appending the shortcut, shortcut hints use {key} placeholders, and
strings main added since are wrapped and translated where unambiguous.
The macOS locale lookup runs /usr/bin/defaults by absolute path and
ignores failures.

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

* Menus widen to fit translated labels instead of wrapping them

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

* Workspace switcher keeps English keys so translated labels still select a workspace

The options' keys were translated too, so in Japanese the current workspace
showed as "—" and picking one never matched apply_workspace.

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

* Regenerate docs/scorecard.md for interface.language

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

---------

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
2026-10-05 19:40:10 -04:00
Brandon Thomas c08322b7bc PSD manual and optical kerning: import, export, render, edit (#206) (#259) (#259) 2026-10-05 19:31:53 -04:00
Brandon Thomas 352c849139 Shortcuts: rebindable fill keys, D/X and held temporary tools (#249) (#275) (#275) 2026-10-05 18:50:47 -04:00
Brandon Thomas 1520d1c527 Selection drag previews: regression tests for #253 (visible mid-drag, 1x and 2x) (#274) (#274) 2026-10-05 18:29:06 -04:00
Brandon Thomas 65c934f374 Shift rubber band: preview the straight line before the click (#257) (#273) (#273) 2026-10-05 18:28:53 -04:00
Brandon Thomas fb297945cd Ctrl+Alt-drag resizes the brush on the canvas (#231) (#271) (#271) 2026-10-05 18:28:40 -04:00
Brandon Thomas 6dc11904bd Brush Settings for every brush: options-bar toggle and real preset picker (#258) (#268) (#268) 2026-10-05 18:28:27 -04:00
Brandon Thomas 315be69dd5 Portable mode: keep settings beside the exe in the Windows portable zip (#228) (#267) (#267) 2026-10-05 18:15:29 -04:00
Brandon Thomas 9a76e74c24 Menus are as tall as their items; scroll only when the window is too short (#235) (#272) (#272) 2026-10-05 18:15:16 -04:00
Brandon Thomas ea0beac9fd New Document: a typed Width/Height reaches Create (#254) (#270) (#270) 2026-10-05 18:15:03 -04:00
TiredIsa 6266b7a96b UI: platform shortcut notation in every label and tooltip (#251) (#262) (#262) 2026-10-05 18:14:50 -04:00
Kentaro Tsutsumi 07074634f6 Align the CJK fallback font's baseline with Latin text (#260) (#260) 2026-10-05 18:14:35 -04:00
storybored379andstorybored379 3c1bab7cc1 Colours: handle non-hex Unicode values without an internal error (#261) (#261)
Co-authored-by: storybored379 <[email protected]>
2026-10-05 18:14:20 -04:00
Brandon Thomas 433b6a7184 Windows taskbar icon: .exe icon id for the MSI shortcut + icon regression tests (#248) (#269) (#269) 2026-10-05 18:14:05 -04:00
才倾梦华V 2172d085c6 Fix Windows MSI advertised shortcut icons (#238) (#238) 2026-10-05 18:13:51 -04:00
Brandon Thomas 47f4abfed4 Stylus pressure, tilt, rotation and eraser on macOS and X11 (#255) (#255) 2026-10-05 17:47:59 -04:00
Brandon Thomas 02c07eb021 Masks: Alt-click to view mask, Cmd-click vector mask to select, vector-mask target brackets (#196) (#256) (#256) 2026-10-05 17:47:46 -04:00
Brandon Thomas 968baf639e Photoshop oracles via storytold/photocraft-corpus: pinned corpora, corpus feature, test-corpus, CI job (#190) (#190) 2026-10-05 17:47:33 -04:00
Brandon Thomas c454af8d96 Text: deterministic font family order; hide private system families (fixes a flaky test) (#264) (#264) 2026-10-05 17:47:20 -04:00
Brandon Thomas 7c6a78b05a GPU: crash-safe startup and device-loss fallback (#243, #4) (#252) (#252) 2026-10-05 12:05:22 -04:00
Brandon ThomasandClaude Opus 5.5 415362df6f Marquee: Shift squares, Alt draws from the centre, live W × H readout (#208) (#247) (#247)
Merged with #240 (stroke trail reset on drag end and start).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 12:05:10 -04:00
Brandon Thomas dbb8911f2a Live gradients: Gradient tool makes editable Gradient Fill layers (on-canvas handles, stops, midpoints) + Classic mode (#180) (#197) (#197) 2026-10-05 12:04:52 -04:00
Brandon Thomas 8bac8926a3 PSD stroke effects: outlines of filled shapes, vector stroke above interior effects, joined subpaths (#233) (#233) 2026-10-05 11:48:28 -04:00
Brandon Thomas ae80df2eb4 Vertical type (tategaki): lay out, render, edit and round-trip (#199) (#246) (#246) 2026-10-05 11:48:14 -04:00
Brandon Thomas 5a5faccd4a Free Transform: selection moves only its part of a linked mask; masked preview; target masks and channels (#205) (#242) (#242) 2026-10-05 11:26:33 -04:00
Brandon Thomas e2444ceb68 Retouch tools paint the mask/channel target; Sample All Layers for Blur/Sharpen/Smudge; premultiplied Smudge (#207) (#234) (#234) 2026-10-05 11:26:20 -04:00
Brandon Thomas 866c33f7c1 Eraser on locked layers: no lock-out from the simple lock button; Stroke Path erases to the background colour (#76) (#229) (#229) 2026-10-05 11:26:06 -04:00
Brandon Thomas 34de1a5dfc Preferences: wire tooltips, Home screen, recent files; hide what nothing reads (#245) (#245) 2026-10-05 11:16:27 -04:00
Brandon Thomas 82270711de Atomic saves: one crash-safe atomic_write for every document write (#230) (#230) 2026-10-05 11:16:13 -04:00
Brandon Thomas eb11e1af46 CR2: measure the colour filter row phase instead of assuming RGGB (#193) (#244) (#244) 2026-10-05 11:15:58 -04:00
Brandon Thomas 15eb3a8ba5 PSD export: write tagged blocks other readers can parse (#200) (#236) (#236) 2026-10-05 11:15:44 -04:00
Brandon ThomasandClaude Opus 5.5 f114f62179 Layout documents: fix GPU device loss, live Move tool, local damage, no per-frame work (#125, #128) (#226) (#226)
Merged with #241 (menu_bar returns the menus' right edge) and #240 (stroke trail field).

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 10:53:15 -04:00
Brandon Thomas 0f955e6c82 Scorecard: cargo xtask perf + scorecard, budgets, checklists, nightly perf (#221) (#239) (#239) 2026-10-05 10:53:02 -04:00
Brandon Thomas 1d9d92341f PSD effects fidelity: spread metric, emboss, gradient geometry, text gamma (113 -> 118 oracle passes) (#224) (#224) 2026-10-05 10:52:47 -04:00
Brandon Thomas 109498a746 Fix live brush preview drawing a polygon (#189) (#240) (#240) 2026-10-05 10:52:33 -04:00
Brandon Thomas 7f3d9ea68a Packaging: Linux runtime deps + start-up library check (#201); wasm 26.3 -> 18.8 MiB (#198) (#237) (#237) 2026-10-05 10:52:20 -04:00
Brandon Thomas faf52b5657 Title bar: never draw the window title over the menus or controls (#241) (#241) 2026-10-05 10:52:06 -04:00
Brandon ThomasandClaude Opus 5.5 5e6db41fc2 FreeBSD: serialise canvas GPU tests, CI template, RAM detection, CJK font paths (#227) (#227)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 09:58:58 -04:00
John HassandJohn Hass a502a227a9 Fix automatic UI scaling on 4K monitors (#225) (#225)
Co-authored-by: John Hass <[email protected]>
2026-10-05 09:58:45 -04:00
Brandon Thomas a0823c5e41 FreeBSD: build and test the workspace in a FreeBSD VM (#223) (#223) 2026-10-05 09:02:29 -04:00
shou 349dfa0e57 fix(clipboard): use the wayland clipboard for images (#222) (#222) 2026-10-05 09:02:14 -04:00
Goran MekićandClaude Opus 5.5 2ad42d028a Fix tests on FreeBSD with RADV (#194) (#194)
Rebased onto the paged GPU compositor (#187): the first-frame empty submit moves into encode_inner, and the test GPU lock covers the paged compositor too.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 09:01:58 -04:00
Brandon Thomas 4832e08aad PSD fidelity: 16/32-bit colour modes, Lab, Levels, Exposure, Dissolve oracle (corpus 133 → 140, psd-tools 212 → 222) (#202) (#202) 2026-10-05 08:42:05 -04:00
Brandon Thomas 4b53f620ef Tool feedback: selection-mode cursor badges, scrubby zoom, contrasting outlines, Shift lines (#188) (#188) 2026-10-05 08:01:52 -04:00
Brandon Thomas 574d0d3d53 PSD corpus: psd-tools set (pinned, verified), an oracle for every file, and the fixes it found (#195) (#195) 2026-10-05 08:01:39 -04:00
Brandon Thomas a4380b2953 Properties: uniform sections, per-kind Quick Actions, full-width controls (#155) (#192) (#192) 2026-10-05 08:01:25 -04:00
Brandon ThomasandClaude Opus 5.5 8877a812f9 GPU compositor: page layer surfaces beyond 16384 px with a memory budget (#187) (#187)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:49:38 -04:00
Brandon ThomasandClaude Opus 5.5 b22deaa944 Layers mask thumbnails + chains; Channels/Paths temporary rows and footers (#153, #154) (#191)
* Layers rows: vector-mask thumbnail, link chains, red X on disabled masks (#153)

Each mask gets a chain (click toggles layer.layerMask.linked /
layer.vectorMask.linked) and a thumbnail; disabled masks are crossed out
and Shift-click toggles them. Vector masks render white/grey, cached and
pruned like the other row thumbnails. Thumbnails shrink on narrow rows so
the name keeps its minimum width.

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

* Channels/Paths: temporary layer mask and layer path rows, aspect thumbnails, bottom footers (#154)

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:45:12 -04:00
Brandon ThomasandClaude Opus 5.5 f5c6f4bb65 UI and Type tool CJK fallback: Japanese, Simplified/Traditional Chinese and Korean system fonts, lazily loaded in locale order (#149) (#185)
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:23:03 -04:00
Brandon ThomasandClaude Opus 5.5 f857b4a31f Move/Free Transform modifier keys (#90); sharp full-resolution transform preview (#91) (#183)
* Move/Free Transform modifier keys (#90); sharp full-resolution transform preview (#91)

Move tool: Shift locks the drag to the dominant axis (re-evaluated with
hysteresis, so moving far enough the other way switches it); Alt at drag
start duplicates the selected layers once the pointer moves and moves the
copies, folded into one "Duplicate + Move" history step; arrow keys nudge
1/10 px (Alt duplicates first) and nudge the Free Transform box. All in a new
move_mods module with two one-line hooks in canvas::tool_event.

Free Transform: Shift on the body locks to 8 directions, Alt-click moves the
reference point, Cmd on an edge skews (Shift: along the edge, Alt: about the
centre), Cmd+Alt+Shift on a corner = perspective.

Preview (#91): the moving pixels were sampled down to 2048 px (nearest
decimation) and drawn bilinear, so a 24 MP layer previewed at 1/3
resolution. Now uploaded at full resolution (up to the device limit, box
filtered beyond), with a lazily built box-filtered mip chain for zoomed-out
views and nearest filtering at >= 200% (or Nearest Neighbor interpolation).

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

* test: clear texture deltas in transform gpu_ctx helper

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:22:50 -04:00
Brandon ThomasandClaude Opus 5.5 b9809ca63c Dock polish: Layers gets the height, Character/Paragraph group, eliding tab strips, reveal the active layer (#147, #150, #151, #152) (#181)
- #147: default-sized dock groups give way (down to compact heights) so the
  filler (Layers) keeps a preferred 500 pt: 10+ rows at 1440x900, ~5 at
  1280x720 (was ~2.5 and 0). Heights the user dragged to are untouched; the
  first splitter drag pins the other groups at the heights they show.
- #150: Window > Character / Paragraph (and Type > Panels) open a new
  Character | Paragraph dock group below Properties instead of switching
  Properties; choosing the visible one again closes it; workspace presets and
  Reset close it. Saved layouts missing a group slot it below its default
  predecessor, so it never lands under Layers.
- #151: dock tab strips (Pro and Studio) fit left of the panel menu button:
  padding shrinks, then labels elide with '...', then overflowing tabs move
  into a >> chevron menu; the selected tab always stays on the strip.
- #152: when the active layer changes by any means (canvas auto-select,
  Select commands, undo, automation) the Layers panel opens its closed parent
  groups (layer.setExpanded) and scrolls the row into view if it is hidden.
- Tests: rows visible at 900/720 pt; Character keeps Properties and toggles;
  tab rects never intersect the menu button in every theme at 1x/2x and
  180-420 pt; chevron menu switches tabs; reveal expands and scrolls (and
  doesn't scroll for visible rows); undo reveals. Adjustment-editor and
  shortcut tests no longer depend on default group heights.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:22:37 -04:00
Nigel Ihza Firdausy d94a8f617a fix(ui): avoid duplicate sliders for wide filter ranges (#179) 2026-10-05 07:22:22 -04:00
Nigel Ihza Firdausy 043e050237 fix(ui): pad Curves graph around endpoint handles (#177) 2026-10-05 07:22:07 -04:00
Nigel Ihza Firdausy d8cf255c47 fix(ui): sample eyedropper while dragging (#176) 2026-10-05 07:21:52 -04:00
Nigel Ihza Firdausy 107302f05c fix(ui): rerender Liquify proxy after undo (#175) 2026-10-05 07:21:37 -04:00
Brandon ThomasandClaude Opus 5.5 1c4cb8307c Brushes: Tilt Scale, Brush Projection, Spacing checkbox, section locks, Mixer Wetness/Mix; Brushes grid + drag and drop; options bar through tools.setBrush (#174)
* paint: Tilt Scale, Brush Projection, speed spacing, section locks, mixer settings in the brush

Shape Dynamics gains Tilt Scale (pen tilt squashes the tip height when the size
control is Pen Tilt) and Brush Projection (tilt foreshortens the tip along the
tilt direction, barrel rotation turns it). Spacing gets Photoshop's checkbox:
off spaces dabs by stroke time, so speed sets the spacing. Section locks keep a
section when another preset is picked. Transfer gains Wetness/Mix jitter, and
the brush carries the Mixer Brush options (Wet, Load, Mix, Flow, Sample All
Layers).

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

* engine, io: mixer settings from the brush, preset organising commands, one journal entry per setBrush gesture, ABR mapping of the new fields

- paint.mixerBrush defaults to the brush's Mixer settings (Wet/Load/Mix/Flow/Sample All Layers).
- brush.presets.rename/move/moveGroup/renameGroup/deleteGroup (drag and drop and context menus in
  the Brushes panel); the preset store index keeps the full panel order, built-ins included.
- tools.setBrush calls sharing a coalesce key journal as one entry (the patch from the brush before
  the gesture), so a slider drag is one command.
- .abr: Tilt Scale, Brush Projection, the Spacing checkbox (Intr), Wetness/Mix jitter and mixer
  tool options map instead of warning.

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

* ui: Tilt Scale, Brush Projection, Spacing checkbox, section locks, mixer controls; Brushes grid/list, drag and drop, rename/delete; options bar through tools.setBrush

- Brush Settings: Tilt Scale (enabled with Size on Pen Tilt) and Brush Projection in Shape
  Dynamics, the Spacing checkbox, Wetness/Mix Jitter plus the Mixer Brush options in Transfer,
  and a lock beside every section.
- Brushes tab (new brushes_tab.rs): list/grid toggle, drag presets within and between groups,
  drag group headers to reorder, context menus to rename or delete presets and groups; all via
  brush.presets.* commands.
- Options bar, the canvas brush picker and the [ ] keys edit the brush through tools.setBrush;
  edits of one gesture share a coalesce key, so a drag is one journal entry.
- ui.set accepts brushesView (list|grid).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 07:21:23 -04:00
Brandon ThomasandClaude Opus 5.5 433b84fae7 Docs: where we stand and where we're going (roadmap, README, AGENTS.md) (#186)
roadmap.md: the honest parity assessment now breaks parity down by dimension with dated
measurements (PSD oracle 115/170, round trips 169/169, shortcut audit 0 failures, perf numbers,
RAW coverage, AI 0%, ~20 missing tools...), an overall estimate (feature surface ~60-70%,
pro-switchable ~25-35%), and a priority order for what's next.
README: the status note says plainly that PhotoCraft isn't yet a daily-driver Photoshop
replacement, what the biggest gaps are, and links the assessment.
AGENTS.md: agents read the assessment first when picking work and update it when numbers move.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 06:14:53 -04:00
Brandon ThomasandClaude Opus 5.5 3389ba558b Menus scroll and navigate by keyboard on small displays; Color Range dialog on the no-GPU path (#145, #138) (#168)
* Menus on small displays: scroll arrows, keyboard navigation, reachability tests (#138)

Builds on #160 (scrollable menu levels). Each menu level is now bounded
from the menu bar down (so a top-level menu no longer slides over the
bar), shows native-style ▲/▼ scroll arrows when it overflows (resting on
one scrolls; the wheel works over them too), and the keyboard drives an
open menu: ↑/↓ move a highlight that is kept in view, → opens a submenu
(highlighting its first row) or the next menu, ← closes it or opens the
previous menu, ↩/Space run the item, Esc closes. Keyboard shortcuts
wait while a menu is open; a resting pointer no longer undoes ←/→.

Tests: every enabled row of Filter, Layer, Image and Edit is reachable
and visible at 1280×720 and 1024×600 at 1× and 1.5×, by keyboard, wheel
and the scroll arrows; submenus open/close by keyboard; ↩ runs a command.

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

* Color Range: test the dialog in the full app without a GPU; show preview failures (#145)

The dialog opens from Select › Color Range… in the whole app on the CPU
canvas (no wgpu render state, as on a Linux adapter that can't run the
GPU canvas) at 1024×600, on screen, with both previews drawn, and OK
applies. v0.1.1 and earlier had no Color Range dialog (it shipped in
0.2.0 with #80): the menu item ran select.colorRange directly with the
foreground colour, which matches the report (a selection appears, no
window).

A preview that can't be computed used to stay silently blank; it now
logs the reason once and shows "Preview unavailable: <reason>" in the
preview (also in its accessibility label).

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-05 06:08:25 -04:00
616 changed files with 118453 additions and 6678 deletions

No files matched your search

+57 -2
View File
@@ -51,15 +51,68 @@ jobs:
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
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.
- 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
- name: Clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
run: cargo clippy --workspace --all-targets --locked --features heif -- -D warnings
- name: Dependency layering
run: cargo xtask layers
# docs/scorecard.md must match its sources (checklists, budgets, baseline, corpus floors,
# prefs audit). Static: it reads committed files only, no benchmarks run here.
- name: Scorecard up to date
if: runner.os == 'Linux'
run: cargo xtask scorecard --check
corpus:
# 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
# pin or manifest does, so normally this restores in seconds (fork PRs read the base cache).
name: corpus tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: corpus
- name: Restore test corpora
uses: actions/cache@v4
with:
path: corpus/
key: corpus-${{ hashFiles('xtask/src/corpus_pins.rs', 'xtask/*.sha256') }}
- name: Fetch and verify corpora
run: cargo xtask corpus --all
- name: Corpus tests
run: cargo xtask test-corpus
wasm:
name: wasm32 check
runs-on: ubuntu-latest
@@ -85,11 +138,13 @@ jobs:
target:
- { crate: psd, name: parse }
- { crate: psd, name: descriptor }
- { crate: psd, name: image_source_data }
- { crate: codecs, name: decode_any }
- { crate: codecs, name: decode_png }
- { crate: codecs, name: decode_jpeg }
- { crate: codecs, name: decode_tiff }
- { crate: codecs, name: decode_exr }
- { crate: codecs, name: decode_heif }
- { crate: format, name: load }
steps:
- uses: actions/checkout@v4
+65
View File
@@ -0,0 +1,65 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the
# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is
# uploaded and no secrets are used.
name: FreeBSD
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- .github/workflows/freebsd.yml
- .github/workflows/release.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
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$'
+4 -1
View File
@@ -34,7 +34,9 @@ jobs:
run: |
"$RUNNER_TEMP/actionlint" -color
- name: shellcheck
run: shellcheck packaging/*.sh packaging/*/*.sh
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
- name: AppImage AppRun desktop integration
run: bash packaging/linux/apprun-test.sh
- name: PowerShell
shell: pwsh
run: |
@@ -44,6 +46,7 @@ jobs:
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
Write-Output "ok $($f.Name)"
}
./packaging/windows/check-icons.ps1
- name: XML, desktop entry, AppStream, Flatpak manifest
run: |
xmllint --noout packaging/windows/photocraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
+91
View File
@@ -0,0 +1,91 @@
name: Perf (nightly)
# The performance scorecard (#221): `cargo xtask perf` on a fixed macOS runner (Apple silicon,
# Metal), every night and on demand. It never runs on pull requests, so it can't block one.
# It fails when an enforced budget in perf/budgets.toml breaks or a scenario's p50 regresses
# more than 15 % against perf/baseline.json from the same machine class (other classes are
# never compared). The table goes to the job summary; results.json is uploaded as an artifact.
#
# To give this runner class its own baseline: run it by hand with "update_baseline", download
# the `perf-baseline` artifact and commit it as perf/baseline.json in a PR.
on:
schedule:
- cron: "23 3 * * *"
workflow_dispatch:
inputs:
quick:
description: "Quick subset (small documents, no budgets)"
type: boolean
default: false
update_baseline:
description: "Also write perf/baseline.json from this run (uploaded as an artifact, not committed)"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: perf-nightly
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
perf:
name: perf (macOS, Apple GPU)
# Pinned image, not -latest: the machine class must stay fixed for comparisons.
runs-on: macos-15
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: perf-release
- name: Run benchmarks
id: perf
env:
QUICK: ${{ inputs.quick }}
UPDATE: ${{ inputs.update_baseline }}
run: |
args=()
if [ "$QUICK" = "true" ]; then args+=(--quick); fi
if [ "$UPDATE" = "true" ]; then args+=(--update-baseline); fi
cargo xtask perf "${args[@]}"
- name: Job summary
if: always()
run: |
if [ -f target/perf/summary.md ]; then
cat target/perf/summary.md >> "$GITHUB_STEP_SUMMARY"
else
echo "No perf summary: the run failed before writing one (see the log)." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: perf-results
path: |
target/perf/*.json
target/perf/summary.md
if-no-files-found: warn
retention-days: 30
- name: Upload baseline
if: always() && inputs.update_baseline
uses: actions/upload-artifact@v4
with:
name: perf-baseline
path: perf/baseline.json
if-no-files-found: warn
retention-days: 30
+100 -2
View File
@@ -30,6 +30,10 @@ env:
RUST_BACKTRACE: 1
PHOTOCRAFT_BUILD_SHA: ${{ github.sha }}
INPUT_VERSION: ${{ inputs.version }}
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
# embeds (today the Japanese fonts). An optional build input for everyone else; see
# docs/development.md › Fonts. Bump deliberately; every desktop build job checks out this commit (not web).
CRAFT_FONTS_REF: abb83316d96aa59c1cf64784289e378fe9fa5695
jobs:
version:
@@ -70,6 +74,12 @@ jobs:
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin, x86_64-apple-darwin
@@ -90,7 +100,18 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/macos/package.sh --arch universal
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
# stapled ticket. Strict when the signing secrets are present, warnings otherwise.
- name: Verify signatures and notarization
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: packaging/macos/verify.sh --arch universal
- uses: actions/upload-artifact@v4
with:
name: release-macos
@@ -112,11 +133,20 @@ jobs:
include:
- { arch: x64, target: x86_64-pc-windows-msvc }
- { arch: x86, target: i686-pc-windows-msvc }
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
- { arch: arm64, target: aarch64-pc-windows-msvc }
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
@@ -141,6 +171,8 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
@@ -167,12 +199,18 @@ jobs:
NFPM_VERSION: 2.47.0
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Install build and packaging tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
desktop-file-utils appstream python3-yaml
desktop-file-utils appstream python3-yaml zsync
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
@@ -184,6 +222,9 @@ jobs:
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
- name: Build and package (AppImage, deb, rpm, tar.gz)
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
CRAFT_FONTS_REQUIRED: "1"
run: packaging/linux/package.sh
- name: Check the packages
run: |
@@ -194,6 +235,9 @@ jobs:
for appimage in dist/release/*.AppImage; do
chmod +x "$appimage"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
# Update information (#349) is embedded, and the .zsync for delta updates sits beside it.
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
test -s "$appimage.zsync"
done
- uses: actions/upload-artifact@v4
with:
@@ -239,6 +283,58 @@ jobs:
path: dist/release/*.flatpak
if-no-files-found: error
freebsd:
name: FreeBSD x86_64
needs: version
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
# .github/workflows/freebsd.yml.
runs-on: ubuntu-latest
timeout-minutes: 180
# No `environment: release`: this job signs nothing, so it gets no release secrets.
env:
PHOTOCRAFT_VERSION: ${{ needs.version.outputs.version }}
PHOTOCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
steps:
- uses: actions/checkout@v4
- name: Check out craft-fonts (embedded fonts)
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
# Passed into the VM by name; never templated into the script below.
envs: "PHOTOCRAFT_VERSION PHOTOCRAFT_BUILD_DATE PHOTOCRAFT_BUILD_SHA INPUT_VERSION"
# The FreeBSD CI packages, plus bash for the packaging script.
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
run: |
set -e
. "$HOME/.cargo/env"
git config --global --add safe.directory "$PWD"
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
export CARGO_TARGET_DIR=/var/tmp/photocraft-target CARGO_BUILD_JOBS=4
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$PHOTOCRAFT_VERSION"; fi
# craft-fonts was checked out next to the sources and synced into the VM with them.
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
bash packaging/freebsd/package.sh
tar -tzf dist/release/photocraft-*-freebsd-x86_64.tar.gz \
| grep -E '/bin/photocraft$|/bin/photocraft-cli$|/share/applications/ai\.storyteller\.photocraft\.desktop$|/share/doc/photocraft/LICENSE-MIT$'
- uses: actions/upload-artifact@v4
with:
name: release-freebsd-x86_64
path: dist/release/*
if-no-files-found: error
web:
name: Web (wasm)
needs: version
@@ -264,6 +360,8 @@ jobs:
- name: Apply version override
if: inputs.version != ''
run: cargo xtask version set "$PHOTOCRAFT_VERSION"
# No craft-fonts here: the wasm32 build never embeds it (crates/text/build.rs), since even
# one Japanese font takes the wasm over packaging/web/package.sh's 24 MiB gate.
- name: Build and zip
run: packaging/web/package.sh
- uses: actions/upload-artifact@v4
@@ -274,7 +372,7 @@ jobs:
release:
name: Draft GitHub Release
needs: [version, macos, windows, linux, flatpak, web]
needs: [version, macos, windows, linux, flatpak, freebsd, web]
runs-on: ubuntu-latest
environment: release
permissions:
+95
View File
@@ -0,0 +1,95 @@
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
on:
push:
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
group: windows-arm64-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
package:
name: package (cross-compiled on x64)
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
with:
targets: aarch64-pc-windows-msvc
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
key: windows-arm64-package
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.0.2
- name: Build and package (unsigned)
shell: pwsh
env:
DIST: ${{ github.workspace }}\dist\arm64
run: ./packaging/windows/package.ps1 -Arch arm64
- uses: actions/upload-artifact@v4
with:
name: photocraft-windows-arm64
path: dist/arm64/*
if-no-files-found: error
retention-days: 7
install:
name: install and run on ARM64
needs: package
runs-on: windows-11-arm
timeout-minutes: 30
steps:
- uses: actions/download-artifact@v4
with:
name: photocraft-windows-arm64
path: dist
- name: Install the MSI, check it, run it, uninstall it
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
$dir = Join-Path $env:ProgramFiles 'PhotoCraft'
foreach ($exe in 'photocraft.exe', 'photocraft-cli.exe') {
$path = Join-Path $dir $exe
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
$bytes = [System.IO.File]::ReadAllBytes($path)
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
Write-Output "ok $path (ARM64)"
}
# The installed CLI runs natively on ARM64.
$cli = Join-Path $dir 'photocraft-cli.exe'
$out = & $cli --version 2>&1 | Out-String
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "photocraft-cli --version exited $LASTEXITCODE" }
Write-Output "ok photocraft-cli --version: $($out.Trim())"
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
if (Test-Path (Join-Path $dir 'photocraft.exe')) { throw 'uninstall left photocraft.exe behind' }
Write-Output 'ok uninstalled'
+1
View File
@@ -8,3 +8,4 @@ target/
/secrets
/temp
/book/output
/craft-fonts
+26 -8
View File
@@ -1,6 +1,6 @@
# AGENTS.md: guide for AI agents and contributors
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, DrawCraft, EffectCraft, FilmCraft, LightCraft, PrintCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
PhotoCraft is an open-source, native, Photoshop-comparable image editor written in **Rust only** (no JavaScript or TypeScript). **No Tauri, Electron or webview shells:** the desktop app is native egui/eframe on wgpu, and the web build is the same Rust compiled to WebAssembly (trunk + wasm-bindgen). Never add Tauri (or any webview/JS UI framework) as a dependency, build step or packaging target. The product name is always written **PhotoCraft** (`{Function}Craft` in PascalCase, like its siblings ArtCraft, ArtCraftX, DesignCraft, VectorCraft, EffectCraft, FilmCraft, LightCraft, PdfCraft) in user-facing text: UI, window titles, About, installers, release names, docs prose. Machine names stay lowercase: crates (`photocraft-*`), binaries, file names, ids (`ai.storyteller.photocraft`). Standards and learnings shared across the crafting apps live in `../craftrules` (read its `README.md`). Contribute reusable learnings there, never code; repos don't share code. The goal is 1:1 Photoshop parity (same menus, shortcuts, behaviour and file fidelity) with better performance, and every feature drivable by agents. Read this file first, then `docs/`.
## 1. Orientation (5 minutes)
@@ -11,9 +11,11 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `docs/contributing.md` | Rules: clean-room, tests, layering, style, commits; the "add a command" checklist |
| `docs/control-protocol.md` | JSON control channel: how agents drive and screenshot the running app |
| `docs/ui-design.md` | Design tokens, themes, widgets, and how to match Photoshop's look |
| `docs/roadmap.md` | Milestones and the **current focus** |
| `docs/roadmap.md` | Honest parity assessment (where we're lacking, where we're going), milestones, **current focus** |
| `docs/parity.md` | Generated list of every Photoshop menu item, live or missing |
| `docs/scorecard.md` | Generated scorecard: performance budgets and numbers, corpus floors, per-area checklists (tools, files, UI, type, automation, reliability, distribution), settings that do nothing |
| `crates/<name>/README.md` (where present) | Public API of that crate |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md` › Test corpora | Real-file test oracles (our Photoshop-authored PSDs); with psd-tools, ag-psd and PngSuite fetched into `corpus/` by `cargo xtask corpus --all` at the pins in `xtask/src/corpus_pins.rs` |
## 2. Workspace map
@@ -21,6 +23,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
crates/
geom cms color raster L0 foundation (geometry, ICC colour management, pixel formats + blend math, COW tiles)
psd codecs L0 standalone format crates (no workspace deps; publishable)
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2); the one isolated unsafe crate
doc L1 document model (layers, masks, adjustments, effects, smart objects: pure data)
ops paint algo text vector L2 history, brush engine, imaging algorithms, type engine, paths/shapes
compose gpu format L3 CPU compositor (the oracle), wgpu compositor, .pcraft native format
@@ -32,7 +35,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)
xtask/ cargo xtask layers | wasm | ci | stats | corpus | parity
xtask/ cargo xtask layers | wasm | ci | stats | corpus | test-corpus | parity | perf | scorecard
```
**Layering is enforced** by `cargo xtask layers`. A crate may depend only on lower layers. `psd`, `codecs` and `cms` depend on nothing in the workspace. Nothing below `ui-egui` may use egui, eframe, winit or rfd. A new crate must be registered in `xtask/src/layers.rs`.
@@ -44,7 +47,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | p
People trust PhotoCraft with their work, and a crash loses it. A malformed file, a bad command or MCP param, a corrupt settings file, an odd keystroke or a full disk must produce an error the user or agent can act on, never a panic. Don't ship a feature by adding a panic path; fix a crash before building on top of it. The shared standard is `../craftrules/standards/never-crash.md`.
- **Non-test code never panics.** No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or `unimplemented!`. Return the crate's error type and propagate with `?`; use `ok_or(..)?`, `let .. else { return Err(..) }`, `if let`, or `unwrap_or*` where a fallback is truly correct (never one that silently corrupts a document). Unfinished features return an "unsupported" error. The only exception is a provably infallible literal: `#[allow(clippy::expect_used)]` plus `.expect("why it can't fail")`.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`.
- **No `unsafe`.** The workspace sets `unsafe_code = "forbid"`. The one exception is the isolated helper crate `photocraft-tablet` (`crates/tablet`): winit drops pen tablet data, and reading it on macOS needs an AppKit event monitor (Objective-C interop). Only its `src/macos.rs` allows `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s); its X11 path and all mapping code are safe. Don't add `unsafe` anywhere else.
- **Input-derived numbers are hostile.** Use `get()` rather than `[i]`/`[a..b]` for indices from files, params, selections or arithmetic on them; slice strings only at char boundaries; use `checked_*`/`saturating_*` for lengths, offsets and counts; guard division by zero and NaN/inf casts; cap allocations sized by input.
- **Bound recursion** with depth limits or seen-sets (documents can be deep or cyclic).
- **Don't cascade.** Handle lock poisoning (`lock().unwrap_or_else(PoisonError::into_inner)`) and treat thread joins as `Result`s.
@@ -54,7 +57,7 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
1. **Everything is a command.** New user-visible behaviour = a command in the engine (`crates/engine/src/*_cmds.rs`, registered in `commands.rs`) with id, label, menu path, shortcut, params doc, `enabled` and `run`, plus tests. The UI, CLI, control channel and MCP all dispatch commands by id. Use the **exact id from `crates/ui-egui/src/menu_catalog.rs`** and the menu item goes live automatically. Only pure view/window state (zoom, panels, screen mode) belongs to the shell (`menus.rs` `UI_COMMANDS`).
2. **No format or colour assumptions.** Bit depth (8/16/32f) and colour model (RGB/Gray/CMYK/Lab…) are runtime data. Never introduce a `u8`-only pixel path in public APIs. Never assume sRGB: colour conversions go through `photocraft-cms` (`Transform`, `transform::cached`). Test at several depths.
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`).
3. **Clean-room.** We studied Photoshop and other proprietary editors for *behaviour and look only*. Never copy their code, shaders, profiles or assets. Implement from public specs (Adobe PSD spec, ICC, ISO 32000 blend modes, papers) and observation. Third-party assets must be permissively licensed, keep their license file next to them, and get a row in `ATTRIBUTION.md` (path, title, author, source, license) in the same change; so do original assets. The ArtCraft logos in `docs/brand/` are not open source (`docs/brand/LICENSE-brand.txt`). **Never commit font files:** fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: `../craftrules/standards/fonts.md`). PhotoCraft uses it through the optional build input `CRAFT_FONTS_DIR=<craft-fonts checkout>` (read by `crates/text/build.rs`, exposed as `photocraft_text::CRAFT_FONTS`; empty without it, and everything must still work). Build with it: `CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo build`; details in `docs/development.md` › Fonts.
4. **Tests are the gate.** Every change comes with tests. Format crates use round-trip, synthetic-generator, oracle and fuzz tests. Keep the PSD corpus results and the parity floor (`crates/ui-egui/src/parity.rs`) from regressing.
5. **The UI is thin and data-driven.** UI state lives in `ui-egui/src/state.rs` (serde), so the control channel can read and drive it. Colours and radii come from `theme::Tokens`, never hard-coded.
6. **Verify UI changes visually.** Render offscreen with `cargo run -p photocraft-ui-egui --example snapshot` (no window, no focus stealing), or launch with `--control` and take `ui.screenshot`. Look at the PNG. Demo images must be public-domain art, never personal photos. When fetching assets, never put a person's name, email or other personal details in requests (User-Agent, headers, URLs); use a generic `Photocraft-dev` User-Agent.
@@ -67,9 +70,16 @@ People trust PhotoCraft with their work, and a crash loses it. A malformed file,
Priorities: important infrastructure first, then low-hanging parity, then the long tail.
1. `docs/roadmap.md` → **Current focus**.
2. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
3. `log/devlog.md` → the "Still open" bullets of recent entries.
0. **Read `docs/roadmap.md` → "Honest parity assessment" first.** It says, dimension by dimension,
where PhotoCraft is lacking and the priority order of where we're going. `docs/parity.md`
(menu wiring) is not a measure of behaviour. When your work moves a measured number (PSD oracle,
round trips, workflow tests, performance), update that section with the dated figure.
1. **Check `docs/scorecard.md`** before picking work: each area's `missing` and `partial` rows,
the performance scenarios that are over budget or not measurable yet, and the count of
settings that do nothing. Its numbers are measured; prefer them to estimates.
2. `docs/roadmap.md` → **Current focus**.
3. `cargo xtask parity` → `docs/parity.md` lists every missing menu item, grouped by menu. Low-hanging fruit is usually a missing command whose algorithm already exists in `algo`, `paint`, `vector` or `text`.
4. `log/devlog.md` → the "Still open" bullets of recent entries.
When parity rises, raise `FLOOR` in `crates/ui-egui/src/parity.rs` (never lower it).
@@ -82,8 +92,15 @@ cargo xtask layers
cargo xtask wasm # if you touched L0–L6
cargo xtask parity # if you added commands; commit the regenerated docs/parity.md
cargo test -p photocraft-engine --test panic_hunt -- --ignored # if you added/changed commands: no panic on adversarial input (Rule 9)
cargo xtask scorecard # if you moved a number: flip the checklist row in scorecard/*.toml, raise a
# corpus floor, fix a dead preference, or meet a budget (then set enforce = true
# in perf/budgets.toml); commit the regenerated docs/scorecard.md (CI checks it)
cargo xtask perf --quick # if you touched a hot path; `cargo xtask perf --update-baseline` publishes a full run
cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text or format (or: --changed decides)
```
**Test corpora.** Real-file corpora live in `corpus/` (gitignored, never committed), fetched at pinned commits and sha256-verified by `cargo xtask corpus --all`: our Photoshop-authored oracles from https://github.com/storytold/photocraft-corpus plus psd-tools, ag-psd and PngSuite from their upstreams. Pins: `xtask/src/corpus_pins.rs`. The corpus tests are opt-in (cargo feature `corpus`): plain `cargo test` skips them, and with the feature on a missing corpus fails ("run `cargo xtask corpus --all`"). `cargo xtask test-corpus` fetches and runs them all. CI always runs them (the `corpus` job, cached by pin). Never commit corpus files; new oracles go to photocraft-corpus (its `AGENTS.md`), then a pin bump here. Details: `docs/development.md` › Test corpora.
Commands must **never panic** on bad input (Rule 9): every `run` closure and the code it calls returns `Err`, not a panic, for any params or document state. New commands come with a graceful-failure test (empty/out-of-range/wrong-type params → `Err`, not a crash).
Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still open). Sessions can end abruptly (crashes, context limits), so the dev log plus a green tree is how the next agent picks up. Keep the tree building at every step.
@@ -101,6 +118,7 @@ Then append a terse entry to `log/devlog.md` (what landed, numbers, what's still
- `docs/roadmap.md`: milestones M0–M12, status and the current focus.
- `docs/parity.md`: generated Photoshop menu coverage.
- `docs/scorecard.md`: generated scorecard (sources: `scorecard/*.toml`, `perf/budgets.toml`, `perf/baseline.json`, corpus floors, prefs audit).
- `docs/releasing.md`: cutting a release (`cargo xtask version`, the `release` branch), signing secrets, packaging scripts in `packaging/`.
- `../craftrules/release/playbook.md`: how every storytold app builds signed release binaries (the canonical recipe; `docs/release-playbook.md` just points there); `docs/releasing.md` is PhotoCraft's specifics.
- `plan/` (local, gitignored): research, parity plan, execution plan, estimates.
+32 -4
View File
@@ -10,9 +10,12 @@ next to them.
| Path | Title | Author | Source | License |
|---|---|---|---|---|
| `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/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/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `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`, `eraser-background.svg` and `eraser-magic.svg` (108 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,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/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) |
@@ -20,6 +23,19 @@ next to them.
| `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 |
| `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) |
### Optional build input: craft-fonts (not files in this repo)
Builds made with `CRAFT_FONTS_DIR` (all official releases) embed fonts from
[storytold/craft-fonts](https://github.com/storytold/craft-fonts) at a pinned commit; no font file
from it is committed here. Per-file authors, sources and licences:
[craft-fonts `ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md).
| Font (embedded at build time) | Use | License |
|---|---|---|
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI and sans Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-pgothic.txt` |
| Shippori Mincho Regular (FONTDASU) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-shippori-mincho.txt` |
| BIZ UDMincho Regular (Morisawa) | Japanese serif Type fallback (desktop) | SIL OFL 1.1, shipped as `OFL-biz-ud-mincho.txt` |
The other built-in ICC profiles and the generated LUT looks are produced by code at runtime
(`crates/cms/src/builtin.rs`, `crates/cms/src/lutfile.rs`) and dedicated to the public domain
(CC0-1.0); they are not separate files.
@@ -30,6 +46,7 @@ The other built-in ICC profiles and the generated LUT looks are produced by code
|---|---|---|---|---|
| `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) |
| `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below |
| `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 |
Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in
[`docs/images/SOURCES.md`](docs/images/SOURCES.md)):
@@ -48,6 +65,17 @@ Artwork shown in the screenshots (all public domain, via Wikimedia Commons; deta
## Test data (not committed, not shipped)
`corpus/` is gitignored. `corpus/psd` holds MIT-licensed test PSDs (ag-psd, psd-tools), listed in
`corpus/psd/SOURCES.md`; `cargo xtask corpus --download` fetches PngSuite (public domain). Files
copied in by hand must be MIT, BSD or CC0. No test fixtures are committed to the repository.
`corpus/` is gitignored and no test fixtures are committed to the repository.
`cargo xtask corpus --all` fetches every corpus at the pinned commits in `xtask/src/corpus_pins.rs`
and verifies each file against the sha256 lists in `xtask/*.sha256`, with the upstream licence
next to the files:
| Path (fetched) | Title | Author | Source | License |
|---|---|---|---|---|
| `corpus/photoshop/` (256 PSDs) | Photoshop oracle corpus: smart filters, layer-style effects, type, adjustments in every mode and depth | PhotoCraft contributors (authored with Adobe Photoshop 2026 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`photoshop/`, with its generator and README) | MIT OR Apache-2.0 |
| `corpus/psd-tools/` (309 files) | psd-tools test set | Kota Yamaguchi and contributors | [psd-tools `tests/psd_files`](https://github.com/psd-tools/psd-tools/tree/main/tests/psd_files) | MIT, Copyright (c) 2019 Kota Yamaguchi |
| `corpus/psd/` (170 files) | Small selection of the psd-tools and ag-psd test files | Kota Yamaguchi; Agamnentzar | psd-tools (above) and [ag-psd `test/`](https://github.com/Agamnentzar/ag-psd/tree/master/test) | MIT (both) |
| `corpus/heif/` (9 files, 0.1 MB) | HEIC/HEIF test files: `heic-rs/` checkerboards, RGB strips and a grid-tiled photo with EXIF and XMP (synthetic pixels encoded by macOS `sips`, each `.ref.png` Apple's decode); `pillow-heif/` the 10-bit RGBA `RGBA_10__29x100.heif` and its source `RGBA_16__29x100.png` | Thomas Braun (heic-rs); Pillow-Heif contributors | [heic-rs `tests/fixtures`](https://github.com/tbraun96/heic-rs/tree/main/tests/fixtures), [pillow-heif `tests/images`](https://github.com/bigcat88/pillow_heif/tree/master/tests/images) | MIT OR Apache-2.0 (heic-rs); BSD-3-Clause (pillow-heif) |
| `corpus/pngsuite/` | PngSuite | Willem van Schaik | <http://www.schaik.com/pngsuite/> | Public domain |
Files copied into `corpus/` by hand (tiff, exr, raw) must be MIT, BSD or CC0.
Generated
+208 -28
View File
@@ -8,6 +8,8 @@ version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
dependencies = [
"enumn",
"serde",
"uuid",
]
@@ -130,6 +132,7 @@ dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
"serde",
"version_check",
"zerocopy",
]
@@ -248,6 +251,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -574,6 +578,9 @@ name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
dependencies = [
"serde_core",
]
[[package]]
name = "bitstream-io"
@@ -1181,6 +1188,7 @@ checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
dependencies = [
"bytemuck",
"emath",
"serde",
]
[[package]]
@@ -1209,6 +1217,8 @@ dependencies = [
"pollster 1.0.1",
"profiling",
"raw-window-handle",
"ron",
"serde",
"static_assertions",
"wasm-bindgen",
"web-sys",
@@ -1233,6 +1243,8 @@ dependencies = [
"log",
"nohash-hasher",
"profiling",
"ron",
"serde",
"smallvec",
"unicode-segmentation",
"web-sys",
@@ -1274,6 +1286,7 @@ dependencies = [
"objc2-ui-kit 0.3.2",
"profiling",
"raw-window-handle",
"serde",
"smithay-clipboard",
"web-time",
"winit",
@@ -1342,6 +1355,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
dependencies = [
"bytemuck",
"serde",
]
[[package]]
@@ -1391,6 +1405,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "enumn"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "epaint"
version = "0.36.2"
@@ -1409,6 +1434,7 @@ dependencies = [
"parking_lot",
"profiling",
"self_cell",
"serde",
"skrifa",
"smallvec",
"unicode-general-category",
@@ -1556,6 +1582,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -1609,6 +1641,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
dependencies = [
"bytemuck",
"serde",
]
[[package]]
@@ -1997,6 +2030,15 @@ dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "heic-rs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adf9dde634bb6d5b718bacd01bef80192f2334306b2f785519a0136cca7b9618"
dependencies = [
"rayon",
]
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -2614,6 +2656,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "memory-stats"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c73f5c649995a115e1a0220b35e4df0a1294500477f97a91d0660fb5abeb574a"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -3204,6 +3256,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -3303,6 +3365,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.13.1"
@@ -3348,13 +3421,16 @@ dependencies = [
[[package]]
name = "photocraft"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"arboard",
"eframe",
"egui",
"fmv-macos-events",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"photocraft-automation",
"photocraft-codecs",
@@ -3366,15 +3442,19 @@ dependencies = [
"photocraft-geom",
"photocraft-io",
"photocraft-raster",
"photocraft-tablet",
"photocraft-ui-egui",
"rfd",
"ron",
"serde_json",
"winresource",
"winsafe",
"x11rb",
]
[[package]]
name = "photocraft-algo"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -3386,7 +3466,7 @@ dependencies = [
[[package]]
name = "photocraft-automation"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"base64",
"cap-std",
@@ -3408,7 +3488,7 @@ dependencies = [
[[package]]
name = "photocraft-cli"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"photocraft-automation",
"photocraft-codecs",
@@ -3421,7 +3501,7 @@ dependencies = [
[[package]]
name = "photocraft-cms"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"moxcms",
"rayon",
@@ -3429,7 +3509,7 @@ dependencies = [
[[package]]
name = "photocraft-codecs"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"exr",
"flate2",
@@ -3437,8 +3517,10 @@ dependencies = [
"image",
"image-webp",
"jpeg-encoder",
"photocraft-heif",
"png 0.18.1",
"proptest",
"rayon",
"thiserror 2.0.21",
"tiff",
"zune-core",
@@ -3447,7 +3529,7 @@ dependencies = [
[[package]]
name = "photocraft-color"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"photocraft-cms",
"serde",
@@ -3455,7 +3537,7 @@ dependencies = [
[[package]]
name = "photocraft-compose"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"criterion",
"photocraft-color",
@@ -3470,7 +3552,7 @@ dependencies = [
[[package]]
name = "photocraft-doc"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -3481,7 +3563,7 @@ dependencies = [
[[package]]
name = "photocraft-engine"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"blake3",
"flate2",
@@ -3511,7 +3593,7 @@ dependencies = [
[[package]]
name = "photocraft-format"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"blake3",
"flate2",
@@ -3529,14 +3611,14 @@ dependencies = [
[[package]]
name = "photocraft-geom"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"serde",
]
[[package]]
name = "photocraft-gpu"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"half",
"log",
@@ -3552,9 +3634,18 @@ dependencies = [
]
[[package]]
name = "photocraft-io"
version = "0.2.0"
name = "photocraft-heif"
version = "0.3.0"
dependencies = [
"heic-rs",
]
[[package]]
name = "photocraft-io"
version = "0.3.0"
dependencies = [
"blake3",
"photocraft-algo",
"photocraft-cms",
"photocraft-codecs",
"photocraft-color",
@@ -3569,19 +3660,20 @@ dependencies = [
"photocraft-text",
"photocraft-vector",
"proptest",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "photocraft-ops"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"photocraft-doc",
]
[[package]]
name = "photocraft-paint"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -3592,7 +3684,7 @@ dependencies = [
[[package]]
name = "photocraft-plugins"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -3607,7 +3699,7 @@ dependencies = [
[[package]]
name = "photocraft-psd"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"flate2",
"photocraft-psd",
@@ -3618,7 +3710,7 @@ dependencies = [
[[package]]
name = "photocraft-raster"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"photocraft-color",
"photocraft-geom",
@@ -3628,7 +3720,7 @@ dependencies = [
[[package]]
name = "photocraft-raw"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"photocraft-raw",
"png 0.18.1",
@@ -3636,17 +3728,31 @@ dependencies = [
]
[[package]]
name = "photocraft-testkit"
version = "0.2.0"
name = "photocraft-tablet"
version = "0.3.0"
dependencies = [
"block2 0.6.2",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"x11rb",
]
[[package]]
name = "photocraft-testkit"
version = "0.3.0"
dependencies = [
"memory-stats",
"photocraft-color",
"photocraft-geom",
"photocraft-raster",
"serde_json",
]
[[package]]
name = "photocraft-text"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"flate2",
"parley",
@@ -3660,7 +3766,7 @@ dependencies = [
[[package]]
name = "photocraft-ui-egui"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"base64",
"eframe",
@@ -3669,6 +3775,7 @@ dependencies = [
"egui_kittest",
"log",
"photocraft-algo",
"photocraft-cms",
"photocraft-codecs",
"photocraft-color",
"photocraft-compose",
@@ -3679,16 +3786,20 @@ dependencies = [
"photocraft-gpu",
"photocraft-io",
"photocraft-plugins",
"photocraft-psd",
"photocraft-raster",
"photocraft-testkit",
"photocraft-text",
"photocraft-vector",
"rayon",
"serde",
"serde_json",
"sys-locale",
]
[[package]]
name = "photocraft-vector"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"photocraft-color",
"photocraft-doc",
@@ -3699,7 +3810,7 @@ dependencies = [
[[package]]
name = "photocraft-web"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"eframe",
"egui",
@@ -4334,6 +4445,20 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "ron"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
dependencies = [
"bitflags 2.13.2",
"once_cell",
"serde",
"serde_derive",
"typeid",
"unicode-ident",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
@@ -4649,6 +4774,9 @@ name = "smallvec"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
dependencies = [
"serde",
]
[[package]]
name = "smithay-client-toolkit"
@@ -4827,6 +4955,15 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -5084,6 +5221,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph",
]
[[package]]
name = "twox-hash"
version = "2.1.4"
@@ -5099,6 +5247,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -6105,6 +6259,12 @@ dependencies = [
"version_check",
]
[[package]]
name = "winsafe"
version = "0.0.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6"
[[package]]
name = "winx"
version = "0.36.4"
@@ -6121,6 +6281,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "writeable"
version = "0.6.4"
@@ -6198,9 +6376,11 @@ checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xtask"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"serde",
"serde_json",
"toml",
]
[[package]]
+34 -3
View File
@@ -1,13 +1,13 @@
[workspace]
resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/photocraft-cli", "xtask"]
default-members = ["crates/*", "apps/photocraft", "apps/photocraft-cli", "xtask"]
[workspace.package]
version = "0.2.0"
version = "0.3.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
[workspace.dependencies]
# internal
@@ -33,6 +33,7 @@ photocraft-plugins = { path = "crates/plugins" }
photocraft-engine = { path = "crates/engine" }
photocraft-ui-egui = { path = "crates/ui-egui" }
photocraft-testkit = { path = "crates/testkit" }
photocraft-tablet = { path = "crates/tablet" }
# external
thiserror = "2"
@@ -68,3 +69,33 @@ opt-level = 1
[profile.release]
lto = "thin"
codegen-units = 1
# The browser build (apps/photocraft-web, `data-cargo-profile` in its index.html). Hosts cap the
# size of a single file (Cloudflare Pages/Workers: 25 MiB), and the wasm is downloaded on every
# visit, so this profile optimizes for size: fat LTO, and opt-level "s" for everything except the
# pixel-processing crates, which keep opt-level 3 so filters and compositing stay fast. panic
# stays the target default (wasm32-unknown-unknown aborts; AGENTS.md's `panic = "unwind"` is for
# the native app shell). packaging/web/package.sh fails the build above 24 MiB. Issue #198:
# this took the wasm from 26.3 MiB (release profile) to 18.8 MiB.
[profile.wasm-release]
inherits = "release"
opt-level = "s"
lto = "fat"
codegen-units = 1
strip = "debuginfo"
[profile.wasm-release.package."*"]
opt-level = "s"
[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
[profile.wasm-release.package.photocraft-compose]
opt-level = 3
[profile.wasm-release.package.photocraft-algo]
opt-level = 3
[profile.wasm-release.package.photocraft-paint]
opt-level = 3
+5
View File
@@ -16,3 +16,8 @@ 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.
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
BIZ UDMincho; not the web build), under the SIL Open Font License 1.1. Their licence texts
ship with the package as OFL-<family>.txt.
+44 -8
View File
@@ -17,7 +17,7 @@
<p align="center">
<img alt="100% Rust" src="https://img.shields.io/badge/100%25-Rust-b7410e?style=flat-square&logo=rust">
<img alt="macOS · Windows · Linux · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-2f7bf5?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
</p>
@@ -70,7 +70,7 @@
</td>
<td width="25%" valign="top">
<h3>🗂️ Real PSD files</h3>
Open, edit and save layered Photoshop documents. 134 of 135 real-world PSDs round-trip byte for byte.
Open, edit and save layered Photoshop documents. Re-saving keeps the render of 307 of the 309 psd-tools test files.
</td>
<td width="25%" valign="top">
<h3>🤖 Agent-ready</h3>
@@ -198,7 +198,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, plus PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, and the native <code>.pcraft</code> format.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), plus flat PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and AVIF, with symmetric read and write at 8, 16 and 32 bits, HEIC photos from iPhone and Mac (read; in official builds, an optional <code>--features heif</code> build feature), and the native <code>.pcraft</code> format.
</td>
<td width="33%" valign="top">
<h4>🪄 The everyday essentials</h4>
@@ -213,7 +213,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
PhotoCraft's PSD support is a standalone crate written from Adobe's public specification and tested against a corpus of real-world files.
- **Byte-exact round trips:** 134 of 135 corpus files are written back identical, and anything we don't model yet (raw blocks, descriptors, extras) is preserved verbatim instead of being dropped.
- **Faithful round trips:** opening and re-saving a document renders the same for 307 of the 309 files in the psd-tools test set and 169 of 170 in our mixed ag-psd/psd-tools set (`crates/io/tests/corpus.rs`; fetch the psd-tools set with `cargo xtask corpus --psd-tools`), and anything we don't model yet (raw blocks, descriptors, extras) is carried over instead of being dropped. A re-saved file is not byte-identical to its source: PhotoCraft rewrites image resources, layer records and the composite. Only the standalone `photocraft-psd` crate, parsing and writing a file without the document model, reproduces every parseable corpus file byte for byte (`crates/psd/tests/corpus.rs`).
- **Pixels that match:** a composite oracle compares our render with Photoshop's own merged image, covering gradient interpolation (Classic, Perceptual and Linear), layer effects, shape strokes, clipping and fill opacity.
- **Large documents:** PSB, 16 and 32-bit files, and CMYK and Lab documents open natively.
@@ -231,6 +231,9 @@ photocraft-cli run wave.psd \
# Apply one action list to a folder of images
photocraft-cli batch --actions grade.json --in ./raw --out ./graded
# Every subcommand explains itself
photocraft-cli batch --help
# Let an agent drive it over MCP (headless, or bridged to the running app)
photocraft-cli mcp
```
@@ -255,19 +258,44 @@ cargo run --release -p photocraft -- image.psd # the desktop app
cargo test --workspace # the test suite
```
Japanese fonts for the UI and Type tool come from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (desktop release builds always include it). Without it PhotoCraft uses your system's CJK fonts:
```sh
git clone https://github.com/storytold/craft-fonts ../craft-fonts
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p photocraft
```
New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs/`](docs/).
Installers for macOS, Windows, Linux and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
Installers for macOS, Windows, Linux, FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/photocraft/releases). On Linux you can pick an AppImage, a `.deb`, an `.rpm`, a tarball or a Flatpak bundle. The bundle needs the freedesktop runtime from [Flathub](https://flathub.org/setup), which `flatpak` offers to install along with it:
```sh
flatpak install --user photocraft-<version>-linux-x86_64.flatpak # or -linux-aarch64
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 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:
```sh
ditto -x -k photocraft-cli-<version>-macos-universal.zip .
spctl --assess --type install -vv photocraft-cli-<version>-macos-universal/photocraft-cli
# ... accepted, source=Notarized Developer ID
```
On FreeBSD 14 (x86_64), the release has a tarball laid out like `/usr/local`. Install the runtime libraries, then unpack it there:
```sh
pkg install libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
tar -xzf photocraft-<version>-freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local
photocraft
```
Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha. The core editing workflow is here, and we're working toward full Photoshop parity, milestone by milestone (see [`docs/roadmap.md`](docs/roadmap.md)). Progress is measured, not guessed: `cargo xtask parity` checks every item in Photoshop's menu tree against the live command registry and writes [`docs/parity.md`](docs/parity.md). Expect rough edges, and please file issues. You can also tell us what broke on [Discord](https://discord.gg/artcraft).
> **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, about twenty missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity.md`](docs/parity.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap's parity assessment](docs/roadmap.md#honest-parity-assessment-2026-10-05). 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).
## Documentation
@@ -277,6 +305,13 @@ Developer, architecture, automation, format, and security documentation is maint
Security architecture, threat modeling, parser hardening, fuzzing, and vulnerability reporting are covered in the [security documentation](book/src/security/) and the repository [security policy](SECURITY.md).
## Test corpora
PhotoCraft is tested against real files: our own Photoshop-authored oracle PSDs in
[photocraft-corpus](https://github.com/storytold/photocraft-corpus) plus the psd-tools, ag-psd and PngSuite sets, pinned and
sha256-verified. Fetch them with `cargo xtask corpus --all` and run the tests with
`cargo xtask test-corpus` (details in [docs/development.md](docs/development.md#test-corpora)).
## The Crafting Apps
PhotoCraft is one of the **Crafting Apps**: free, open-source creative tools from the
@@ -289,7 +324,7 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/printcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.printcraft.png" alt="" width="32" height="32"> | **PrintCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/printcraft) | [Website](https://getartcraft.com/apps/printcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
@@ -343,5 +378,6 @@ Forks and modified versions must remove them.
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
## Star history
ArtCraft
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/photocraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Fphotocraft&type=date&legend=top-left)
+1 -1
View File
@@ -49,4 +49,4 @@ General bugs without a security impact should use the project's normal issue and
## Current limitations
TCP control connections require a 256-bit bearer token before method dispatch and enforce request-line, active-connection, and batch-step limits. These protections do not provide a capability model: an authenticated client still receives the complete exposed control surface. Automation file operations are not restricted to configured workspace roots, and JSON-depth, render, document-memory, command-duration, client-identity, encryption, and audit controls are not yet implemented. See the [security documentation](book/src/security/overview.md) for the source-backed status and hardening roadmap.
TCP control connections require a 256-bit bearer token before method dispatch and enforce request-line, active-connection, and batch-step limits. Authentication does not provide per-client or per-tool capabilities, so an authenticated client still receives the exposed control surface. Remote filesystem methods use separately configured read and write workspace roots, and engine commands that still use ambient filesystem paths are denied at the automation boundary. Automation can change individual safe preferences, but whole-preferences updates and filesystem-bearing preference sections are refused; applying the Preferences dialog through automation is also refused. JSON-depth, render, document-memory, command-duration, client-identity, encryption, and audit controls are not yet implemented. See the [security documentation](book/src/security/overview.md) for the source-backed status and hardening roadmap.
+5
View File
@@ -5,6 +5,11 @@ version.workspace = true
edition.workspace = true
license.workspace = true
[features]
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
# it; official builds (CI, packaging/, release) build with `--features heif`.
heif = ["photocraft-codecs/heif"]
[lints]
workspace = true
+111 -68
View File
@@ -14,15 +14,19 @@ pub const USAGE: &str = "\
photocraft-cli: headless Photocraft
USAGE:
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>]
photocraft-cli convert <in> <out> [--format <ext>] [--quality <1-100>] [--tiff-layers]
Convert between formats (.pcraft, .psd, .png, .jpg, .tif, .webp, .exr, …).
TIFF output is flat unless --tiff-layers keeps the layers (Photoshop layer data).
photocraft-cli info <file> [--compact]
Print the document as JSON (size, mode, depth, layer tree).
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>]
photocraft-cli run (<file> | --new <json>) --cmd <id> [--params <json>] [--cmd …] [--out <file>] [--format <ext>] [--quality <1-100>] [--tiff-layers]
Open a file, run engine commands in order, save the result. Each --params
applies to the preceding --cmd. Prints each command's JSON result.
photocraft-cli batch --actions <actions.json> --in <dir> --out <dir> [--format <ext>]
Apply an action list ([{\"command\": id, \"params\": {…}}, …]) to every image in a directory.
photocraft-cli batch --actions <actions.json> --in <dir> --out <dir> [--format <ext>] [--quality <1-100>] [--in-place] [--tiff-layers]
Apply an action list to every image in a directory. Steps are [id, params] pairs,
{\"command\": id, \"params\": {…}} objects or bare ids, as a recorded action or droplet stores them
(a list, or wrapped in {\"actions\": …}, {\"steps\": …} or a droplet). An --out folder that is the
--in folder is refused, as the results would replace the originals; --in-place allows it.
photocraft-cli droplet <file.pcdroplet> <file-or-dir>… [--out <dir>]
Run a droplet (File › Automate › Create Droplet) on images and folders.
photocraft-cli commands [--json] [--filter <text>]
@@ -33,8 +37,12 @@ USAGE:
photocraft-cli serve [--port <port>] [--control-token <64-hex> | --control-token-file <path>]
[--automation-read-root <dir>] [--automation-write-root <dir>]
Keep one headless session open and answer JSON lines ({\"id\",\"method\",\"params\"}) on stdio,
or on 127.0.0.1:<port>. Methods: engine.execute, engine.commands, doc.open/new/save/inspect/render/
select/close, session.list, batch, methods (docs/control-protocol.md#headless-server).
or on 127.0.0.1:<port>. Methods: engine.execute, jobs.list/cancel, engine.commands,
doc.open/new/save/inspect/render/select/close, session.list, batch, methods
(docs/control-protocol.md#headless-server).
photocraft-cli <subcommand> --help (or -h) prints this text. A flag the subcommand doesn't take is
an error.
";
struct Args {
@@ -42,43 +50,67 @@ struct Args {
flags: Vec<(String, Option<String>)>,
}
const VALUE_FLAGS: &[&str] = &[
"--format",
"--quality",
"--new",
"--cmd",
"--params",
"--out",
"--actions",
"--in",
"--filter",
"--bridge",
"--port",
"--control-token",
"--control-token-file",
"--automation-read-root",
"--automation-write-root",
/// A subcommand: the flags it reads (taking a value, or given bare) and what it runs. A flag
/// outside these is a usage error, so a typo such as `--fromat` can't be silently ignored (#423).
struct Subcommand {
name: &'static str,
values: &'static [&'static str],
bare: &'static [&'static str],
run: fn(&Args, &mut dyn Write, &mut dyn Write) -> R,
}
const SUBCOMMANDS: &[Subcommand] = &[
Subcommand { name: "convert", values: &["--format", "--quality"], bare: &["--tiff-layers"], run: convert },
Subcommand { name: "info", values: &[], bare: &["--compact"], run: |a, out, _| info(a, out) },
Subcommand { name: "run", values: &["--new", "--cmd", "--params", "--out", "--format", "--quality"], bare: &["--tiff-layers"], run: run_cmds },
Subcommand { name: "batch", values: &["--actions", "--in", "--out", "--format", "--quality"], bare: &["--in-place", "--tiff-layers"], run: batch },
Subcommand { name: "droplet", values: &["--out"], bare: &[], run: droplet },
Subcommand { name: "commands", values: &["--filter"], bare: &["--json"], run: |a, out, _| commands(a, out) },
Subcommand {
name: "mcp",
values: &["--bridge", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
bare: &[],
run: |a, _, _| mcp(a),
},
Subcommand {
name: "serve",
values: &["--port", "--control-token", "--control-token-file", "--automation-read-root", "--automation-write-root"],
bare: &[],
run: |a, _, err| serve(a, err),
},
];
fn parse(args: &[String]) -> Result<Args, String> {
/// The subcommand's arguments, or `None` when they ask for help (`--help` or `-h`).
fn parse(sub: &Subcommand, args: &[String]) -> Result<Option<Args>, String> {
let mut a = Args { positional: Vec::new(), flags: Vec::new() };
let unknown = |flag: &str| format!("unknown flag {flag} for {}", sub.name);
let mut i = 0;
while i < args.len() {
let s = &args[i];
while let Some(s) = args.get(i) {
if s == "--help" || s == "-h" {
return Ok(None);
}
if let Some((k, v)) = s.split_once('=').filter(|(k, _)| k.starts_with("--")) {
if sub.bare.contains(&k) {
return Err(format!("{k} takes no value"));
}
if !sub.values.contains(&k) {
return Err(unknown(k));
}
a.flags.push((k.to_owned(), Some(v.to_owned())));
} else if VALUE_FLAGS.contains(&s.as_str()) {
} else if sub.values.contains(&s.as_str()) {
let v = args.get(i + 1).ok_or_else(|| format!("{s} needs a value"))?;
a.flags.push((s.clone(), Some(v.clone())));
i += 1;
} else if s.starts_with("--") {
} else if sub.bare.contains(&s.as_str()) {
a.flags.push((s.clone(), None));
} else if s.starts_with("--") {
return Err(unknown(s));
} else {
a.positional.push(s.clone());
}
i += 1;
}
Ok(a)
Ok(Some(a))
}
impl Args {
@@ -98,22 +130,7 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let _ = write!(err, "{USAGE}");
return 2;
};
let parsed = match parse(&args[1..]) {
Ok(p) => p,
Err(e) => {
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
return 2;
}
};
let r = match cmd.as_str() {
"convert" => convert(&parsed, out, err),
"info" => info(&parsed, out),
"run" => run_cmds(&parsed, out, err),
"batch" => batch(&parsed, out, err),
"commands" => commands(&parsed, out),
"droplet" => droplet(&parsed, out, err),
"mcp" => mcp(&parsed),
"serve" => serve(&parsed, err),
match cmd.as_str() {
"-h" | "--help" | "help" => {
let _ = write!(out, "{USAGE}");
return 0;
@@ -122,12 +139,25 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
let _ = writeln!(out, "photocraft-cli {}", photocraft_engine::build_info::long_version());
return 0;
}
other => {
let _ = writeln!(err, "error: unknown command `{other}`\n\n{USAGE}");
_ => {}
}
let Some(sub) = SUBCOMMANDS.iter().find(|s| s.name == cmd) else {
let _ = writeln!(err, "error: unknown command `{cmd}`\n\n{USAGE}");
return 2;
};
let parsed = match parse(sub, &args[1..]) {
Ok(Some(p)) => p,
// `<subcommand> --help`: usage, without reading inputs or starting a server.
Ok(None) => {
let _ = write!(out, "{USAGE}");
return 0;
}
Err(e) => {
let _ = writeln!(err, "error: {e}\n\n{USAGE}");
return 2;
}
};
match r {
match (sub.run)(&parsed, out, err) {
Ok(()) => 0,
Err(e) => {
let _ = writeln!(err, "error: {e}");
@@ -137,10 +167,11 @@ pub fn run(args: &[String], out: &mut dyn Write, err: &mut dyn Write) -> i32 {
}
fn export_opts(a: &Args) -> Result<ExportOptions, String> {
let mut o = ExportOptions::default();
// TIFF output is flat unless asked: scripted conversions keep predictable sizes.
let mut o = ExportOptions { tiff_layers: a.has("--tiff-layers"), ..Default::default() };
if let Some(q) = a.get("--quality") {
let q: u8 = q.parse().map_err(|_| format!("bad --quality `{q}`"))?;
o.encode.jpeg_quality = q.clamp(1, 100);
o.encode.jpeg_quality =
q.parse().ok().filter(|q| (1..=100).contains(q)).ok_or_else(|| format!("bad --quality `{q}`: expected a whole number from 1 to 100"))?;
}
Ok(o)
}
@@ -160,9 +191,10 @@ fn convert(a: &Args, _out: &mut dyn Write, err: &mut dyn Write) -> R {
let [input, output] = a.positional.as_slice() else {
return Err("convert needs <in> <out>".into());
};
let opts = export_opts(a)?;
let o = files::open(Path::new(input)).map_err(|e| e.to_string())?;
warn_all(err, &o.warnings);
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &export_opts(a)?, None).map_err(|e| e.to_string())?;
let ws = files::save(&o.document, Path::new(output), a.get("--format"), &opts, None).map_err(|e| e.to_string())?;
warn_all(err, &ws);
Ok(())
}
@@ -207,6 +239,7 @@ fn command_list(a: &Args) -> Result<Vec<(String, Value)>, String> {
}
fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let opts = export_opts(a)?;
let mut h = Headless::trusted_local();
match (a.positional.as_slice(), a.get("--new")) {
([file], None) => {
@@ -216,7 +249,7 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
}
([], Some(new)) => {
let p: Value = serde_json::from_str(new).map_err(|e| format!("bad --new JSON: {e}"))?;
h.command_run("file.new", p).map_err(|e| e.to_string())?;
h.command_run("file.new", p).map_err(|e| format!("--new: {e}"))?;
}
_ => return Err("run needs exactly one of <file> or --new <json>".into()),
}
@@ -229,29 +262,25 @@ fn run_cmds(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
print_json(out, &json!({"command": id, "result": r}), true)?;
}
if let Some(o) = a.get("--out") {
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &export_opts(a)?).map_err(|e| e.to_string())?;
let r = h.save(None, Some(Path::new(o)), a.get("--format"), &opts).map_err(|e| e.to_string())?;
let ws: Vec<String> = serde_json::from_value(r["warnings"].clone()).unwrap_or_default();
warn_all(err, &ws);
}
Ok(())
}
/// Parse an actions file: `[{"command": id, "params": {…}}]` or
/// `{"actions": [...]}`; `"id"` is accepted for `"command"`.
/// Parse an actions file: the steps of a recorded action or a droplet (`[id, params]` pairs,
/// `{"command": id, "params": {…}}` objects or bare ids; `"id"` is accepted for `"command"`), as a
/// list or wrapped in `{"actions": […]}`, `{"steps": […]}` or a droplet (#489).
pub fn parse_actions(text: &str) -> Result<Vec<(String, Value)>, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("actions JSON: {e}"))?;
let list = match &v {
Value::Array(a) => a.clone(),
Value::Object(m) => m.get("actions").and_then(Value::as_array).cloned().ok_or("actions JSON: expected an array or {\"actions\": [...]}")?,
_ => return Err("actions JSON: expected an array".into()),
};
list.into_iter()
.enumerate()
.map(|(i, a)| {
let id = a.get("command").or_else(|| a.get("id")).and_then(Value::as_str).ok_or_else(|| format!("action {i}: missing \"command\""))?;
Ok((id.to_owned(), a.get("params").cloned().unwrap_or_else(|| json!({}))))
})
.collect()
photocraft_engine::automate_cmds::parse_action(v.get("actions").unwrap_or(&v), "batch --actions").map_err(|e| e.to_string())
}
/// Whether `a` and `b` name the same existing folder, however they are spelt (relative, with a
/// trailing separator, through a symbolic link).
fn same_dir(a: &Path, b: &Path) -> bool {
matches!((std::fs::canonicalize(a), std::fs::canonicalize(b)), (Ok(a), Ok(b)) if a == b)
}
fn is_input(p: &Path) -> bool {
@@ -266,16 +295,29 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
let text = std::fs::read_to_string(actions_path).map_err(|e| format!("{actions_path}: {e}"))?;
let actions = parse_actions(&text)?;
let opts = export_opts(a)?;
// Results are saved as `<out>/<stem>.<ext>`, so an `--out` that is the `--in` folder would
// replace the originals (#492).
if !a.has("--in-place") && same_dir(&in_dir, &out_dir) {
return Err(format!(
"--out {} is the --in folder, so the results would replace the originals: choose another --out folder, or pass --in-place to overwrite them",
out_dir.display()
));
}
std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
let mut inputs: Vec<PathBuf> =
std::fs::read_dir(&in_dir).map_err(|e| format!("{}: {e}", in_dir.display()))?.flatten().map(|e| e.path()).filter(|p| is_input(p)).collect();
inputs.sort();
let (mut ok, mut failed) = (0, 0);
let mut written = photocraft_engine::file_cmds::OutputClaims::default();
// `--format .jpg` names outputs `<stem>.jpg`, as `--format jpg` does (#490).
let format = a.get("--format").map(|f| f.strip_prefix('.').unwrap_or(f));
for input in &inputs {
let ext = a.get("--format").map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let ext = format.map(str::to_owned).or_else(|| input.extension().map(|e| e.to_string_lossy().into_owned())).unwrap_or_else(|| "png".into());
let stem = input.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
let target = out_dir.join(format!("{stem}.{ext}"));
let target_text = target.to_string_lossy();
let r = (|| -> Result<Vec<String>, String> {
written.check(&target_text)?;
let mut h = Headless::trusted_local();
h.open(input).map_err(|e| e.to_string())?;
for (id, p) in &actions {
@@ -287,6 +329,7 @@ fn batch(a: &Args, out: &mut dyn Write, err: &mut dyn Write) -> R {
match r {
Ok(ws) => {
ok += 1;
written.record(&target_text, &input.to_string_lossy());
let _ = writeln!(out, "ok {} -> {}", input.display(), target.display());
warn_all(err, &ws);
}
+309
View File
@@ -43,6 +43,63 @@ fn usage_and_unknown_command() {
assert!(out.starts_with("photocraft-cli "));
}
/// #423: `<subcommand> --help` and `-h` print usage and return at once (no inputs read, no files
/// written, no server started), whatever else is on the line.
#[test]
fn every_subcommand_answers_help() {
let d = tmp("help");
for sub in ["convert", "info", "run", "batch", "droplet", "commands", "mcp", "serve"] {
for help in ["--help", "-h"] {
let o = bin().current_dir(&d).arg(sub).arg(help).arg("--out").arg("x.png").stdin(Stdio::null()).output().unwrap();
let out = String::from_utf8_lossy(&o.stdout);
assert_eq!(o.status.code(), Some(0), "{sub} {help}: {}", String::from_utf8_lossy(&o.stderr));
assert!(out.contains("USAGE") && out.contains("photocraft-cli batch"), "{sub} {help}: {out}");
}
}
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0, "help wrote nothing");
}
/// #423: a flag the subcommand doesn't take is a usage error naming it, before any work.
#[test]
fn unknown_flags_are_usage_errors() {
let d = tmp("flags");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
let actions = d.join("actions.json");
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
let out_dir = d.join("out");
let fails = |cmd: &mut Command, flag: &str| {
let o = cmd.output().unwrap();
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(2), "{err}");
assert!(err.contains(&format!("unknown flag {flag}")), "{err}");
};
for typo in [&["--fromat", "jpg"][..], &["--fromat=jpg"][..]] {
fails(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(typo), "--fromat");
assert!(!out_dir.exists(), "nothing written");
}
let x = d.join("x.png");
fails(bin().arg("run").arg(input.join("a.png")).args(["--cmd", "image.adjustments.invert", "--param", "{}", "--out"]).arg(&x), "--param");
assert!(!x.exists());
// A flag one subcommand takes is still unknown to another; a bare flag takes no value.
fails(bin().args(["info", "--json"]).arg(input.join("a.png")), "--json");
let o = bin().args(["commands", "--json=yes"]).output().unwrap();
assert_eq!(o.status.code(), Some(2));
assert!(String::from_utf8_lossy(&o.stderr).contains("--json takes no value"));
// Every flag still works in both forms.
let (out, _) = ok(bin().args(["commands", "--json", "--filter=gaussian"]));
assert!(out.contains("filter.blur.gaussianBlur"), "{out}");
ok(bin().args(["batch", "--actions"]).arg(&actions).arg(format!("--in={}", input.display())).arg("--out").arg(&out_dir).args([
"--format=jpg",
"--quality",
"80",
]));
assert!(out_dir.join("a.jpg").exists());
let (out, _) = ok(bin().arg("info").arg(input.join("a.png")).arg("--compact"));
assert!(!out.trim().contains('\n'), "compact JSON is one line");
}
#[test]
fn convert_png_pcraft_png_is_lossless() {
let d = tmp("convert");
@@ -86,6 +143,37 @@ fn info_prints_layer_tree_json() {
std::fs::remove_dir_all(d).unwrap();
}
/// #518, #523: a truncated JPEG and an animated GIF open, but `info` lists why the image is
/// incomplete and `convert` prints it on stderr (and still succeeds); complete files stay quiet.
#[test]
fn info_and_convert_report_decode_warnings() {
let d = tmp("decode-warnings");
let noise: Vec<u8> = (0..64 * 48 * 3).map(|i: u32| (i.wrapping_mul(7919) % 251) as u8).collect();
let img = photocraft_codecs::Image::from_u8(64, 48, photocraft_codecs::ChannelLayout::Rgb, noise).unwrap();
let jpeg = photocraft_codecs::encode(&img, photocraft_codecs::Format::Jpeg, &Default::default()).unwrap();
// Three 1x1 frames.
let mut gif = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec();
for _ in 0..3 {
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
}
gif.push(0x3B);
let cases = [
("whole.jpg", &jpeg[..], None),
("half.jpg", &jpeg[..jpeg.len() / 2], Some("JPEG data ends early (the file is truncated or damaged); part of the image is missing")),
("anim.gif", &gif[..], Some("only the first of 3 frames was imported")),
];
for (name, bytes, warning) in cases {
let file = d.join(name);
std::fs::write(&file, bytes).unwrap();
let (out, _) = ok(bin().arg("info").arg(&file).arg("--compact"));
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["warnings"], json!(warning.into_iter().collect::<Vec<_>>()), "{name}");
let (_, err) = ok(bin().arg("convert").arg(&file).arg(d.join(format!("{name}.png"))));
assert_eq!(err.trim(), warning.map(|w| format!("warning: {w}")).unwrap_or_default(), "{name}");
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_commands_and_save() {
let d = tmp("run");
@@ -121,6 +209,15 @@ fn run_errors() {
assert_eq!(o.status.code(), Some(1));
let o = bin().args(["run", "--new", "{", "--cmd", "x"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
// Params must be a JSON object, for `--new` and for each `--params`.
let o = bin().args(["run", "--new", "[3]", "--cmd", "layer.new.layer"]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
let e = String::from_utf8_lossy(&o.stderr);
assert!(e.contains("--new") && e.contains("must be a JSON object"), "{e}");
let o = bin().args(["run", "--new", "{}", "--cmd", "layer.new.layer", "--params", "\"x\""]).output().unwrap();
assert_eq!(o.status.code(), Some(1));
let e = String::from_utf8_lossy(&o.stderr);
assert!(e.contains("layer.new.layer") && e.contains("must be a JSON object"), "{e}");
}
#[test]
@@ -155,6 +252,157 @@ fn parse_actions_formats() {
assert!(photocraft_cli::parse_actions(r#"{"actions":[{"command":"c"}]}"#).unwrap().len() == 1);
assert!(photocraft_cli::parse_actions(r#"[{"params":{}}]"#).is_err());
assert!(photocraft_cli::parse_actions("7").is_err());
// #489: the step shapes a recorded action and a droplet store, bare or wrapped.
let want = vec![("a".to_string(), json!({"x":1})), ("b".to_string(), json!({}))];
for text in [
r#"[["a",{"x":1}],["b"]]"#,
r#"[["a",{"x":1}],"b"]"#,
r#"{"steps":[["a",{"x":1}],["b",{}]]}"#,
r#"{"photocraftDroplet":1,"action":{"steps":[["a",{"x":1}],{"command":"b"}]}}"#,
r#"{"actions":[["a",{"x":1}],["b",{}]]}"#,
] {
assert_eq!(photocraft_cli::parse_actions(text).unwrap(), want, "{text}");
}
for text in ["[42]", "[[]]", "[[7,{}]]", r#"{"name":"x"}"#] {
assert!(photocraft_cli::parse_actions(text).is_err(), "{text}");
}
}
/// Runs `batch` on `input` with `actions` (saved as `<name>.json`), writing to the folder `<d>/<name>`.
fn batch_with(d: &Path, input: &Path, name: &str, actions: &str, extra: &[&str]) -> std::process::Output {
let file = d.join(format!("{name}.json"));
std::fs::write(&file, actions).unwrap();
bin().args(["batch", "--actions"]).arg(&file).arg("--in").arg(input).arg("--out").arg(d.join(name)).args(extra).output().unwrap()
}
/// #489: `batch --actions` takes `[id, params]` steps and a droplet's steps, with the same results
/// as the object form; a malformed step fails before anything is written.
#[test]
fn batch_accepts_recorded_action_steps() {
let d = tmp("batch-steps");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
write_png(&input.join("b.png"), 6, 5, 9);
let shapes = [
("objects", r#"[{"command":"image.adjustments.invert"}]"#),
("pairs", r#"[["image.adjustments.invert",{}]]"#),
("ids", r#"["image.adjustments.invert"]"#),
("droplet", r#"{"photocraftDroplet":1,"action":{"steps":[["image.adjustments.invert",{}]]}}"#),
];
for (name, actions) in shapes {
let o = batch_with(&d, &input, name, actions, &[]);
assert!(o.status.success(), "{name}: {}", String::from_utf8_lossy(&o.stderr));
assert!(String::from_utf8_lossy(&o.stdout).contains("2 succeeded, 0 failed"), "{name}");
for f in ["a.png", "b.png"] {
assert_eq!(std::fs::read(d.join(name).join(f)).unwrap(), std::fs::read(d.join("objects").join(f)).unwrap(), "{name}/{f}");
}
}
let o = batch_with(&d, &input, "bad", "[42]", &[]);
assert_eq!(o.status.code(), Some(1));
assert!(!d.join("bad").exists(), "nothing written");
}
/// #490: a leading dot on `--format` doesn't double the dot in output names.
#[test]
fn batch_format_with_leading_dot() {
let d = tmp("batch-dot");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
write_png(&input.join("b.png"), 6, 5, 9);
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
for (name, format) in [("dot", ".jpg"), ("plain", "jpg")] {
let o = batch_with(&d, &input, name, invert, &["--format", format]);
assert!(o.status.success(), "{format}: {}", String::from_utf8_lossy(&o.stderr));
let mut names: Vec<String> = std::fs::read_dir(d.join(name)).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
assert_eq!(names, ["a.jpg", "b.jpg"], "{format}");
assert_eq!(photocraft_codecs::detect(&std::fs::read(d.join(name).join("a.jpg")).unwrap()), Some(photocraft_codecs::Format::Jpeg));
}
}
/// #491: `--quality` outside 1-100 is an error for every subcommand, whatever the number's size,
/// and nothing is written.
#[test]
fn quality_outside_1_to_100_is_refused() {
let d = tmp("quality");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
let a = input.join("a.png");
write_png(&a, 8, 4, 3);
let invert = r#"[{"command":"image.adjustments.invert"}]"#;
for q in ["0", "101", "255", "256", "1000", "-1", "abc", "50.5", ""] {
let out = d.join("q.jpg");
let (mut convert, mut run) = (bin(), bin());
convert.arg("convert").arg(&a).arg(&out).args(["--quality", q]);
run.arg("run").arg(&a).args(["--cmd", "image.adjustments.invert", "--out"]).arg(&out).args(["--quality", q]);
for cmd in [&mut convert, &mut run] {
let o = cmd.output().unwrap();
assert_eq!(o.status.code(), Some(1), "--quality {q}");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("bad --quality `{q}`: expected a whole number from 1 to 100")), "--quality {q}");
assert!(!out.exists(), "--quality {q} wrote a file");
}
let o = batch_with(&d, &input, "qb", invert, &["--format", "jpg", "--quality", q]);
assert_eq!(o.status.code(), Some(1), "batch --quality {q}");
assert!(!d.join("qb").exists(), "batch --quality {q} wrote a folder");
}
// The documented range still encodes, lower quality making a smaller file.
let sizes: Vec<usize> = ["1", "50", "100"]
.iter()
.map(|q| {
let out = d.join(format!("q{q}.jpg"));
ok(bin().arg("convert").arg(&a).arg(&out).args(["--quality", q]));
std::fs::read(out).unwrap().len()
})
.collect();
assert!(sizes[0] <= sizes[1] && sizes[1] <= sizes[2], "{sizes:?}");
}
/// #492: an `--out` folder that is the `--in` folder, however it is spelt, is refused before
/// anything is written, unless `--in-place` asks for it.
#[test]
fn batch_refuses_to_write_over_its_inputs() {
let d = tmp("batch-in-place");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
std::fs::write(input.join("a.tif"), photocraft_codecs::encode(&img, photocraft_codecs::Format::Tiff, &Default::default()).unwrap()).unwrap();
let actions = d.join("actions.json");
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
let snapshot = || {
let mut files: Vec<(PathBuf, Vec<u8>)> =
std::fs::read_dir(&input).unwrap().map(|e| e.unwrap().path()).map(|p| (p.clone(), std::fs::read(p).unwrap())).collect();
files.sort();
files
};
let before = snapshot();
let batch = |out: &Path, extra: &[&str]| {
bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out"]).arg(out).args(extra).output().unwrap()
};
#[cfg(unix)]
std::os::unix::fs::symlink(&input, d.join("link")).unwrap();
let link = cfg!(unix).then(|| PathBuf::from("link"));
for out in [PathBuf::from("in"), PathBuf::from("./in/"), input.clone(), input.join(".")].iter().chain(&link) {
for extra in [&[][..], &["--format", "tif"][..]] {
let o = batch(out, extra);
let err = String::from_utf8_lossy(&o.stderr);
assert_eq!(o.status.code(), Some(1), "--out {} {extra:?}: {err}", out.display());
assert!(err.contains("is the --in folder") && err.contains("--in-place"), "{err}");
assert_eq!(snapshot(), before, "--out {} {extra:?} changed the originals", out.display());
}
}
// A separate folder runs as before, leaving the originals alone.
let (out, _) = ok(bin().current_dir(&d).args(["batch", "--actions"]).arg(&actions).args(["--in", "in", "--out", "out"]));
assert!(out.contains("2 succeeded, 0 failed"), "{out}");
assert_eq!(snapshot(), before);
// `--in-place` opts in: each result replaces its original.
let o = batch(Path::new("in"), &["--in-place"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let after = snapshot();
assert_eq!(after.len(), 2);
assert!(after.iter().zip(&before).all(|(a, b)| a.0 == b.0 && a.1 != b.1), "both inputs replaced");
}
#[test]
@@ -218,6 +466,38 @@ fn mcp_stdio_handshake_and_tool_call() {
let _ = child.wait();
}
/// `--format` mapping two inputs to one name fails the second instead of overwriting the first (#420).
#[test]
fn batch_fails_inputs_that_share_an_output_name() {
let d = tmp("batch-collide");
let input = d.join("in");
std::fs::create_dir_all(&input).unwrap();
write_png(&input.join("a.png"), 8, 4, 3);
// Same stem in other formats; `A.tif` differs only in case.
let img = photocraft_codecs::Image::from_u8(8, 4, photocraft_codecs::ChannelLayout::Rgb, vec![90; 96]).unwrap();
for (name, format) in [("a.bmp", photocraft_codecs::Format::Bmp), ("A.tif", photocraft_codecs::Format::Tiff)] {
std::fs::write(input.join(name), photocraft_codecs::encode(&img, format, &Default::default()).unwrap()).unwrap();
}
let actions = d.join("actions.json");
std::fs::write(&actions, r#"[{"command":"image.adjustments.invert"}]"#).unwrap();
let out_dir = d.join("out");
let o = bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir).args(["--format", "png"]).output().unwrap();
let (out, err) = (String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));
assert!(
!o.status.success(),
"a lost result must fail the run
{out}
{err}"
);
assert!(out.contains("1 succeeded, 2 failed"), "{out}");
assert_eq!(err.matches("not written").count(), 2, "{err}");
assert_eq!(std::fs::read_dir(&out_dir).unwrap().count(), 1);
// Keeping each file's format, every input gets its own output.
let out_dir = d.join("same");
let (out, _) = ok(bin().args(["batch", "--actions"]).arg(&actions).arg("--in").arg(&input).arg("--out").arg(&out_dir));
assert!(out.contains("3 succeeded, 0 failed"), "{out}");
}
#[test]
fn droplet_runs_an_action_on_files() {
let d = tmp("droplet");
@@ -237,3 +517,32 @@ fn droplet_runs_an_action_on_files() {
let o = bin().arg("droplet").arg(d.join("a.png")).arg(d.join("b.png")).output().unwrap();
assert!(!o.status.success(), "not a droplet");
}
#[test]
fn tiff_output_is_flat_unless_tiff_layers_is_given() {
let d = tmp("tiff-layers");
let layer_count = |path: &std::path::Path| -> usize {
let (info, _) = ok(bin().arg("info").arg(path));
let v: Value = serde_json::from_str(&info).unwrap();
v["layers"].as_array().unwrap().len()
};
for (name, flag, layers) in [("flat.tif", None, 1), ("layered.tif", Some("--tiff-layers"), 2)] {
let out = d.join(name);
let mut cmd = bin();
cmd.args(["run", "--new", r#"{"width":16,"height":16}"#]).args(["--cmd", "layer.new.layer", "--params", r#"{"name":"Ink"}"#]);
if let Some(flag) = flag {
cmd.arg(flag);
}
ok(cmd.arg("--out").arg(&out));
assert_eq!(layer_count(&out), layers, "{name}");
}
// convert honours the same flag.
let src = d.join("layered.tif");
let flat = d.join("converted.tif");
ok(bin().arg("convert").arg(&src).arg(&flat));
assert_eq!(layer_count(&flat), 1);
let kept = d.join("kept.tif");
ok(bin().arg("convert").arg(&src).arg(&kept).arg("--tiff-layers"));
assert_eq!(layer_count(&kept), 2);
std::fs::remove_dir_all(d).unwrap();
}
+146
View File
@@ -0,0 +1,146 @@
//! EXIF orientation end to end through the `photocraft-cli` binary (#285): a
//! phone-style JPEG (stored landscape, Orientation = 6) converts to an upright
//! portrait, an explicit rotation turns it back, and a `.pcraft` round trip
//! changes nothing.
use std::path::{Path, PathBuf};
use std::process::Command;
use photocraft_codecs::{ChannelLayout, DecodeOptions, EncodeOptions, Format, Image, SampleType, decode, decode_with, exif_orientation};
use serde_json::Value;
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_photocraft-cli"))
}
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pc-cli-orient-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn ok(cmd: &mut Command) -> String {
let o = cmd.output().unwrap();
let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned());
assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status);
out
}
/// EXIF block: Orientation = `o` (SHORT) and an inline Software tag, little-endian.
fn exif(o: u16) -> Vec<u8> {
let mut v = b"II*\0\x08\0\0\0\x02\0".to_vec();
v.extend_from_slice(&[0x12, 0x01, 3, 0, 1, 0, 0, 0]);
v.extend_from_slice(&o.to_le_bytes());
v.extend_from_slice(&[0, 0]);
v.extend_from_slice(&[0x31, 0x01, 2, 0, 4, 0, 0, 0]);
v.extend_from_slice(b"PC1\0");
v.extend_from_slice(&[0, 0, 0, 0]);
v
}
/// Upright 16×32 portrait: red top-left quarter, green top-right, blue bottom half.
fn upright_color(x: usize, y: usize) -> [u8; 3] {
match (x < 8, y < 16) {
(true, true) => [230, 20, 20],
(false, true) => [20, 230, 20],
_ => [20, 20, 230],
}
}
/// The portrait as a camera stores it for Orientation = 6: 32×16, with EXIF.
fn phone_jpeg() -> Vec<u8> {
let (w, h) = (16, 32);
let mut px = vec![0u8; w * h * 3];
for y in 0..h {
for x in 0..w {
// Upright (x, y) sits at stored (y, w - 1 - x) in a 32-wide image.
let (sx, sy) = (y, w - 1 - x);
px[(sy * h + sx) * 3..][..3].copy_from_slice(&upright_color(x, y));
}
}
let stored = Image::from_u8(h as u32, w as u32, ChannelLayout::Rgb, px).unwrap();
let jpeg =
photocraft_codecs::encode(&stored, Format::Jpeg, &EncodeOptions { jpeg_quality: 100, jpeg_chroma_subsampling: false, ..Default::default() }).unwrap();
let mut seg = b"Exif\0\0".to_vec();
seg.extend_from_slice(&exif(6));
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xFF, 0xE1]);
out.extend_from_slice(&((seg.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&seg);
out.extend_from_slice(&jpeg[2..]);
out
}
/// The file as stored (no orientation applied), so its own dimensions and tag can be checked.
fn stored(path: &Path) -> Image {
decode_with(&std::fs::read(path).unwrap(), &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap()
}
fn assert_portrait_pixels(img: &Image) {
let img = img.convert(ChannelLayout::Rgb, SampleType::U8);
for (x, y) in [(3, 4), (12, 4), (3, 28), (12, 28)] {
let i = (y * 16 + x) * 3;
let got = &img.data()[i..i + 3];
let want = upright_color(x, y);
assert!(got.iter().zip(want).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12), "({x},{y}): {got:?} vs {want:?}");
}
}
#[test]
fn orientation_6_jpeg_converts_upright_and_rotates_back() {
let d = tmp("e2e");
let jpg = d.join("IMG_0006.JPG");
std::fs::write(&jpg, phone_jpeg()).unwrap();
// convert → PNG: the stored pixels are already upright, and any EXIF says 1.
let png = d.join("upright.png");
ok(bin().arg("convert").arg(&jpg).arg(&png));
let raw = stored(&png);
assert_eq!(raw.dimensions(), (16, 32), "stored upright, not landscape");
assert_portrait_pixels(&raw);
if let Some(e) = raw.meta.exif.as_deref() {
assert_eq!(exif_orientation(e), 1, "written EXIF orientation");
}
assert_eq!(exif_orientation(&std::fs::read(&png).unwrap()), 1);
assert_eq!(decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (16, 32), "no second turn on reopen");
// An explicit Image › Image Rotation › 90° Clockwise makes it landscape again.
let turned = d.join("turned.png");
ok(bin().arg("run").arg(&jpg).args(["--cmd", "image.imageRotation.90cw", "--out"]).arg(&turned));
let t = stored(&turned);
assert_eq!(t.dimensions(), (32, 16));
if let Some(e) = t.meta.exif.as_deref() {
assert_eq!(exif_orientation(e), 1);
}
// The portrait's top-left (red) is now top-right; its bottom (blue) is on the left.
let t = t.convert(ChannelLayout::Rgb, SampleType::U8);
let at = |x: usize, y: usize| <[u8; 3]>::try_from(&t.data()[(y * 32 + x) * 3..][..3]).unwrap();
let near = |a: [u8; 3], b: [u8; 3]| a.iter().zip(b).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12);
assert!(near(at(28, 3), upright_color(3, 4)), "{:?}", at(28, 3));
assert!(near(at(28, 12), upright_color(12, 4)), "{:?}", at(28, 12));
assert!(near(at(4, 8), upright_color(8, 28)), "{:?}", at(4, 8));
// .pcraft save, reopen and save again: identical document and pixels.
let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft"));
ok(bin().arg("convert").arg(&jpg).arg(&a));
ok(bin().arg("convert").arg(&a).arg(&b));
// Everything but the file's own path must match.
let info = |p: &Path| {
let mut v = serde_json::from_str::<Value>(&ok(bin().arg("info").arg(p))).unwrap();
v.as_object_mut().unwrap().remove("file");
v
};
let (ia, ib) = (info(&a), info(&b));
assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32)));
assert_eq!(ia, ib);
let (pa, pb) = (d.join("a.png"), d.join("b.png"));
ok(bin().arg("convert").arg(&a).arg(&pa));
ok(bin().arg("convert").arg(&b).arg(&pb));
let (xa, xb) = (stored(&pa), stored(&pb));
assert_eq!(xa.dimensions(), (16, 32));
assert_eq!(xa.to_rgba8(), xb.to_rgba8());
assert_eq!(xa.meta.exif, xb.meta.exif);
assert_eq!(xa.to_rgba8(), stored(&png).to_rgba8(), "the .pcraft round trip matches the direct conversion");
std::fs::remove_dir_all(d).unwrap();
}
+5
View File
@@ -6,6 +6,11 @@ edition.workspace = true
license.workspace = true
publish = false
[features]
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
# it; official builds (CI, packaging/, release) build with `--features heif`.
heif = ["photocraft-codecs/heif"]
[lints]
workspace = true
+15 -4
View File
@@ -4,16 +4,27 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>PhotoCraft</title>
<link data-trunk rel="rust" data-bin="photocraft-web" data-wasm-opt="z" data-weak-refs />
<link data-trunk rel="rust" data-bin="photocraft-web" data-cargo-profile="wasm-release" data-cargo-features="heif" data-wasm-opt="z" data-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; align-items: center; justify-content: center;
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
#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">Loading PhotoCraft&hellip;</div>
<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>
+15
View File
@@ -28,3 +28,18 @@ fn main() {
fn main() {
eprintln!("photocraft-web only runs in the browser: build it with `trunk build --release` in apps/photocraft-web");
}
#[cfg(test)]
mod tests {
/// A WebAssembly module that never downloads (or doesn't match the page) runs no Rust, so the
/// page itself replaces the endless "Loading" with what may have gone wrong and a way to
/// retry, without a hand-written script.
#[test]
fn loading_page_offers_a_reload_when_startup_stalls() {
let html = include_str!("../index.html");
let (_, hint) = html.split_once("<p id=\"photocraft_stalled\">").expect("stalled-startup hint in the loading element");
assert!(hint.contains("failed to") && hint.contains("<a href=\"\">Reload</a>"), "{hint}");
assert!(html.contains("animation: photocraft-stalled"), "the hint appears after a delay");
assert!(!html.contains("<script"), "trunk injects the generated loader; the page has no script of its own");
}
}
+14 -4
View File
@@ -14,8 +14,8 @@ type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
];
const CANVAS_ID: &str = "photocraft_canvas";
@@ -69,7 +69,7 @@ pub fn start() {
});
}
/// Pen pressure, tilt and twist from Pointer Events (eframe forwards none of them for pens) into
/// Pen pressure, tilt, twist and the eraser button from Pointer Events (eframe forwards none of them for pens) into
/// the app's stylus feed. The sample is kept through `pointerup` so the stroke's last points keep
/// their pressure; hovering, a mouse, or leaving the canvas clears it.
fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::stylus::StylusFeed) {
@@ -83,7 +83,15 @@ fn listen_pen(target: &web_sys::HtmlCanvasElement, feed: photocraft_ui_egui::sty
return;
}
let pen = e.pointer_type() == "pen" && e.buttons() != 0 && ty != "pointercancel" && ty != "pointerleave";
feed.set(pen.then(|| PenSample { pressure: e.pressure(), tilt_x: e.tilt_x() as f32, tilt_y: e.tilt_y() as f32, rotation: e.twist() as f32 }));
// W3C Pointer Events: `buttons` bit 5 (32) is the pen's eraser.
let eraser = e.buttons() & 32 != 0;
feed.set(pen.then(|| PenSample {
pressure: e.pressure(),
tilt_x: e.tilt_x() as f32,
tilt_y: e.tilt_y() as f32,
rotation: e.twist() as f32,
eraser,
}));
});
if target.add_event_listener_with_callback(kind, cb.as_ref().unchecked_ref()).is_ok() {
cb.forget();
@@ -136,6 +144,8 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
opts.tiff_layers = settings.tiff_layers;
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
pick_open: Some(Box::new(move || {
+26 -2
View File
@@ -7,6 +7,11 @@ default-run = "photocraft"
description = "Photocraft: an open-source, native image editor"
build = "build.rs"
[features]
# Open HEIC/HEIF photos (the optional photocraft-heif crate). Not default so distributors choose
# it; official builds (CI, packaging/, release) build with `--features heif`.
heif = ["photocraft-codecs/heif"]
[lints]
workspace = true
@@ -22,21 +27,40 @@ photocraft-io = { workspace = true }
photocraft-format = { workspace = true }
photocraft-automation = { workspace = true }
photocraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
# Pen tablet pressure/tilt on macOS and X11 (the workspace's one isolated unsafe helper crate).
photocraft-tablet = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
arboard = "3"
arboard = { version = "3.6", features = ["wayland-data-control"] }
open = "5"
# Reads the saved window layout (eframe persistence) to drop values that would abort startup.
ron = "0.12"
[target.'cfg(target_os = "macos")'.dependencies]
# Finder double-click / Open With / Dock drops arrive as Apple events; this small audited crate
# registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a
# safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
# Safe AppKit calls only: turn off AppKit's title bar dragging (mac_window.rs) and find where the
# pointer is when files are dropped (cursor.rs; winit 0.30 drops carry no position). Already in
# Cargo.lock (winit, photocraft-tablet); pinned exactly to the same versions.
objc2 = "=0.6.4"
objc2-foundation = { version = "=0.3.2", default-features = false, features = ["std", "NSArray", "NSEnumerator", "NSGeometry"] }
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSApplicationScripting", "NSEvent", "NSResponder", "NSView", "NSWindow"] }
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
[target.'cfg(target_os = "linux")'.dependencies]
x11rb = { version = "=0.13.2", default-features = false }
# Drop position on Windows (`cursor.rs`): safe Win32 wrappers (GetCursorPos), no dependencies of
# its own. Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "windows")'.dependencies]
winsafe = { version = "=0.0.29", default-features = false, features = ["user"] }
[build-dependencies]
# Icon + VERSIONINFO for photocraft.exe; build.rs only uses it when targeting Windows.
+234
View File
@@ -0,0 +1,234 @@
//! Where the desktop app keeps its per-user data: preferences, brush presets, crash-recovery
//! autosaves and the GPU startup marker all live under the one directory [`config_dir`] returns.
//!
//! In order:
//! 1. `PHOTOCRAFT_CONFIG_DIR`, when set (tests, agents, custom setups).
//! 2. **Portable mode** (#228): a marker file ([`PORTABLE_MARKERS`]) beside the executable puts
//! everything in `<exe dir>/PhotoCraftData`, so a portable copy on a USB stick leaves nothing in
//! `%APPDATA%`. The Windows portable zip ships with `portable.txt`; the MSI doesn't. If that
//! 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`.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Files beside the executable that switch on portable mode (either one; contents are ignored).
pub const PORTABLE_MARKERS: [&str; 2] = ["portable.txt", "PhotoCraft.portable"];
/// The data folder created beside the executable in portable mode.
pub const PORTABLE_DATA_DIR: &str = "PhotoCraftData";
/// How [`DataDir::dir`] was chosen.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
/// `PHOTOCRAFT_CONFIG_DIR`.
Override,
/// `<exe dir>/PhotoCraftData`.
Portable,
/// The platform's per-user config directory.
Platform,
}
/// The resolved data directory.
#[derive(Clone, Debug)]
pub struct DataDir {
/// `None` only when no platform directory is known either (no `HOME`/`APPDATA`).
pub dir: Option<PathBuf>,
pub mode: Mode,
/// Set when a portable marker was found but its data folder isn't writable.
pub warning: Option<String>,
}
/// The data directory for this process, resolved once (the portable check probes the disk).
pub fn current() -> &'static DataDir {
static DIR: OnceLock<DataDir> = OnceLock::new();
DIR.get_or_init(|| {
let exe = std::env::current_exe().ok();
let d = resolve(|k| std::env::var_os(k), exe.as_deref().and_then(Path::parent));
if let Some(w) = &d.warning {
log::warn!("{w}");
eprintln!("photocraft: {w}");
}
log::info!("settings directory ({:?}): {:?}", d.mode, d.dir);
d
})
}
/// The per-user settings directory (see the module docs).
pub fn config_dir() -> Option<PathBuf> {
current().dir.clone()
}
/// 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()) {
return DataDir { dir: Some(PathBuf::from(d)), mode: Mode::Override, warning: None };
}
let platform = platform_dir(&env);
let Some((exe_dir, marker)) = exe_dir.and_then(|d| portable_marker(d).map(|m| (d, m))) else {
return DataDir { dir: platform, mode: Mode::Platform, warning: None };
};
let data = exe_dir.join(PORTABLE_DATA_DIR);
match ensure_writable(&data) {
Ok(()) => DataDir { dir: Some(data), mode: Mode::Portable, warning: None },
Err(e) => {
let fallback = platform.as_ref().map_or_else(|| "nowhere (no user folder found)".to_string(), |p| p.display().to_string());
let warning =
format!("portable mode ({} found) but {} isn't writable ({e}); settings are kept in {fallback} instead", marker.display(), data.display());
DataDir { dir: platform, mode: Mode::Platform, warning: Some(warning) }
}
}
}
/// The platform convention, from `env` (so tests can fake `HOME`/`APPDATA`/`XDG_CONFIG_HOME`).
pub fn platform_dir(env: &impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
let home = env("HOME").filter(|h| !h.is_empty()).map(PathBuf::from);
if cfg!(target_os = "macos") {
return home.map(|h| h.join("Library/Application Support/Photocraft"));
}
if cfg!(windows) {
return env("APPDATA").filter(|a| !a.is_empty()).map(|a| PathBuf::from(a).join("Photocraft"));
}
env("XDG_CONFIG_HOME").filter(|x| !x.is_empty()).map(PathBuf::from).or_else(|| home.map(|h| h.join(".config"))).map(|c| c.join("photocraft"))
}
/// The first portable marker file in `exe_dir`, if any.
pub fn portable_marker(exe_dir: &Path) -> Option<PathBuf> {
PORTABLE_MARKERS.iter().map(|m| exe_dir.join(m)).find(|p| p.is_file())
}
/// Create `dir` and prove a file can be written in it (a folder can exist yet be read-only).
fn ensure_writable(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let probe = dir.join(format!(".write-test-{}", std::process::id()));
std::fs::write(&probe, b"")?;
// A leftover probe is harmless; don't fail portable mode over it.
let _ = std::fs::remove_file(&probe);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
fn temp(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("photocraft-app-dirs-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn env_of(pairs: &[(&str, &Path)]) -> impl Fn(&str) -> Option<OsString> + use<> {
let m: HashMap<String, OsString> = pairs.iter().map(|(k, v)| (k.to_string(), v.as_os_str().to_owned())).collect();
move |k| m.get(k).cloned()
}
/// A fake user folder that every platform's convention resolves under.
fn fake_home(root: &Path) -> impl Fn(&str) -> Option<OsString> + use<> {
let home = root.join("home");
let appdata = root.join("appdata");
env_of(&[("HOME", &home), ("APPDATA", &appdata)])
}
#[test]
fn no_marker_uses_the_platform_dir() {
let root = temp("platform");
let exe = root.join("bin");
std::fs::create_dir_all(&exe).unwrap();
let env = fake_home(&root);
let d = resolve(&env, Some(&exe));
assert_eq!(d.mode, Mode::Platform);
assert!(d.warning.is_none());
let dir = d.dir.unwrap();
assert_eq!(Some(dir.clone()), platform_dir(&env));
assert!(!dir.starts_with(&exe));
assert!(!exe.join(PORTABLE_DATA_DIR).exists(), "no portable folder without a marker");
// The convention per platform.
if cfg!(target_os = "macos") {
assert_eq!(dir, root.join("home/Library/Application Support/Photocraft"));
} else if cfg!(windows) {
assert_eq!(dir, root.join("appdata").join("Photocraft"));
} else {
assert_eq!(dir, root.join("home/.config/photocraft"));
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn either_marker_keeps_everything_beside_the_exe() {
for marker in PORTABLE_MARKERS {
let root = temp("portable");
let exe = root.join("PhotoCraft");
std::fs::create_dir_all(&exe).unwrap();
std::fs::write(exe.join(marker), b"").unwrap();
let d = resolve(fake_home(&root), Some(&exe));
assert_eq!(d.mode, Mode::Portable, "{marker}");
assert!(d.warning.is_none());
let dir = d.dir.unwrap();
assert_eq!(dir, exe.join(PORTABLE_DATA_DIR));
assert!(dir.is_dir(), "the data folder is created");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "the write probe is removed");
// Every derived path sits under the exe dir.
for sub in ["preferences.json", "Presets", "Recovery", crate::gpu_startup::MARKER_FILE] {
assert!(dir.join(sub).starts_with(&exe));
}
assert!(!root.join("home").exists() && !root.join("appdata").exists(), "nothing written to the user folder");
let _ = std::fs::remove_dir_all(&root);
}
}
#[test]
fn a_marker_folder_is_not_a_marker() {
let root = temp("dir-marker");
let exe = root.join("bin");
std::fs::create_dir_all(exe.join("portable.txt")).unwrap();
assert_eq!(resolve(fake_home(&root), Some(&exe)).mode, Mode::Platform);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn unwritable_portable_folder_falls_back_with_a_warning() {
let root = temp("unwritable");
let exe = root.join("bin");
std::fs::create_dir_all(&exe).unwrap();
std::fs::write(exe.join("portable.txt"), b"").unwrap();
// A file where the data folder should go: it can't be created, on every platform.
std::fs::write(exe.join(PORTABLE_DATA_DIR), b"in the way").unwrap();
let env = fake_home(&root);
let d = resolve(&env, Some(&exe));
assert_eq!(d.mode, Mode::Platform);
assert_eq!(d.dir, platform_dir(&env));
let w = d.warning.unwrap();
assert!(w.contains("portable") && w.contains("isn't writable"), "{w}");
// No user folder at all: still no panic, just no directory.
let d = resolve(|_| None, Some(&exe));
assert!(d.dir.is_none() && d.warning.is_some());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn override_wins_over_portable() {
let root = temp("override");
let exe = root.join("bin");
std::fs::create_dir_all(&exe).unwrap();
std::fs::write(exe.join("portable.txt"), b"").unwrap();
let custom = root.join("custom");
let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &custom)]), Some(&exe));
assert_eq!((d.mode, d.dir), (Mode::Override, Some(custom)));
assert!(!exe.join(PORTABLE_DATA_DIR).exists());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn missing_exe_dir_or_env_never_panics() {
let d = resolve(|_| None, None);
assert_eq!((d.mode, d.dir), (Mode::Platform, None));
let empty = PathBuf::new();
let d = resolve(env_of(&[("PHOTOCRAFT_CONFIG_DIR", &empty), ("HOME", &empty), ("APPDATA", &empty)]), None);
assert_eq!(d.dir, None, "empty variables are ignored");
}
}
+122
View File
@@ -0,0 +1,122 @@
//! The app icon, everywhere Windows, macOS and Linux show one (#248).
//!
//! - **Executable:** `build.rs` embeds `assets/app-icon/photocraft.ico` (16–256 px) as icon
//! resource 1, which Explorer, Alt+Tab and an unpinned taskbar button fall back to.
//! - **Window:** [`window_icon`] sets the title-bar and taskbar icon at runtime (`WM_SETICON` on
//! Windows, `_NET_WM_ICON` on X11, the Dock when running unbundled on macOS).
//! - **Start Menu shortcut (MSI):** the taskbar matches a running window to the shortcut that
//! launches its executable and shows that shortcut's icon. Windows Installer caches an advertised
//! shortcut's icon under its `<Icon Id>`, so that id must carry the target's `.exe` extension
//! (ICE50); an extensionless id showed a blank page on the taskbar. The tests below check it.
//!
//! The process keeps Windows' implicit AppUserModelID (derived from the executable path), which
//! is what makes that matching work for both the MSI shortcut and the portable exe. An explicit
//! ID would need a Win32 call (`unsafe`, which the workspace forbids) and would then also have to
//! be stamped on the shortcut, or the taskbar would stop matching the two.
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
pub fn window_icon() -> egui::IconData {
match eframe::icon_data::from_png_bytes(PNG) {
Ok(icon) => icon,
Err(e) => {
log::warn!("app icon: {e}");
egui::IconData::default()
}
}
}
#[cfg(target_os = "macos")]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
#[cfg(not(target_os = "macos"))]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
#[cfg(test)]
mod tests {
use super::*;
const ICO: &[u8] = include_bytes!("../../../assets/app-icon/photocraft.ico");
const WXS: &str = include_str!("../../../packaging/windows/photocraft.wxs");
fn u16_at(b: &[u8], i: usize) -> usize {
u16::from_le_bytes([b[i], b[i + 1]]) as usize
}
fn u32_at(b: &[u8], i: usize) -> usize {
u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize
}
#[test]
fn window_icon_decodes() {
let icon = window_icon();
assert!(icon.width >= 256 && icon.width == icon.height, "{}x{}", icon.width, icon.height);
assert_eq!(icon.rgba.len(), icon.width as usize * icon.height as usize * 4);
assert!(icon.rgba.chunks(4).any(|p| p[3] > 0), "the icon isn't blank");
}
/// The `.ico` build.rs embeds: every size Windows asks for, each a valid PNG or BMP image.
#[test]
fn exe_icon_has_every_shell_size() {
assert_eq!((u16_at(ICO, 0), u16_at(ICO, 2)), (0, 1), "ICONDIR header, type 1 (icon)");
let n = u16_at(ICO, 4);
let mut sizes = Vec::new();
for i in 0..n {
let e = 6 + i * 16;
let w = if ICO[e] == 0 { 256 } else { ICO[e] as usize };
let h = if ICO[e + 1] == 0 { 256 } else { ICO[e + 1] as usize };
assert_eq!(w, h);
let (len, off) = (u32_at(ICO, e + 8), u32_at(ICO, e + 12));
let data = ICO.get(off..off + len).expect("image data inside the file");
let png = data.starts_with(b"\x89PNG\r\n\x1a\n");
let bmp = data.len() >= 40 && u32_at(data, 0) == 40;
assert!(png || bmp, "{w}px entry is neither PNG nor BMP");
if png {
// IHDR width/height match the directory entry.
let pw = u32::from_be_bytes([data[16], data[17], data[18], data[19]]) as usize;
assert_eq!(pw, w, "{w}px entry holds a {pw}px PNG");
}
sizes.push(w);
}
for want in [16, 24, 32, 48, 64, 256] {
assert!(sizes.contains(&want), "photocraft.ico lacks {want}px (has {sizes:?})");
}
}
/// The value of `attr="…"` inside `element`.
fn attr<'a>(element: &'a str, attr: &str) -> Option<&'a str> {
let key = format!(" {attr}=\"");
let start = element.find(&key)? + key.len();
element[start..].split('"').next()
}
/// Each `<tag …>` element's text up to its closing `>`.
fn elements<'a>(xml: &'a str, tag: &str) -> Vec<&'a str> {
let open = format!("<{tag} ");
xml.match_indices(&open).map(|(i, _)| &xml[i..i + xml[i..].find('>').unwrap()]).collect()
}
/// ICE50 (#248): advertised shortcut icons are cached under their `<Icon Id>`; without the
/// target's extension the shell shows a blank page on the Start Menu and the taskbar.
#[test]
fn msi_shortcut_icons_keep_the_exe_extension() {
let icons: Vec<&str> = elements(WXS, "Icon").iter().filter_map(|e| attr(e, "Id")).collect();
assert!(!icons.is_empty());
for id in &icons {
assert!(id.ends_with(".exe") || id.ends_with(".ico"), "<Icon Id=\"{id}\"> needs an .exe or .ico extension");
}
let shortcuts = elements(WXS, "Shortcut");
assert!(!shortcuts.is_empty());
for s in shortcuts {
let icon = attr(s, "Icon").expect("shortcuts name their icon");
assert!(icons.contains(&icon), "shortcut icon {icon} isn't an <Icon>");
if attr(s, "Advertise") == Some("yes") {
// Every shortcut in the MSI targets photocraft.exe.
assert!(icon.ends_with(".exe"), "advertised shortcut icon {icon} must end in .exe like its target (ICE50)");
}
}
let arp = WXS.split("Id=\"ARPPRODUCTICON\" Value=\"").nth(1).and_then(|r| r.split('"').next()).expect("ARPPRODUCTICON");
assert!(icons.contains(&arp), "ARPPRODUCTICON {arp} isn't an <Icon>");
// The Start Menu shortcut must not set its own AppUserModelID: the process keeps the
// implicit one, and a mismatch would split the taskbar button from the shortcut.
assert!(!WXS.contains("System.AppUserModel.ID"));
}
}
+126
View File
@@ -0,0 +1,126 @@
//! Where the pointer is, read from the OS, so a file drop can tell the canvas (place as a layer)
//! from the tab strip (open at that tab position) and the panels (open as a document), and the tab
//! strip can show where a dragged file would go. winit 0.30's file drops carry no position, and
//! during an OS drag the window gets no pointer events, so egui's last pointer is stale.
//!
//! - **macOS**: `NSEvent.mouseLocation`, converted into the content view of our window under it.
//! - **X11**: `QueryPointer` on our window (a second, pure-Rust X connection).
//! - **Windows**: `GetCursorPos`, relative to the window's client area.
//! - **Wayland**: winit 0.30 delivers no file drops there, so there's nothing to place.
//!
//! No `unsafe`: every OS call goes through a safe binding.
//!
//! [`service`] builds the pointer-position service for the app's window, `None` where it can't
//! be read.
pub use platform::service;
#[cfg(target_os = "macos")]
mod platform {
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSEvent, NSWindow};
use photocraft_ui_egui::CursorPosFn;
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
Some(Box::new(|ctx: &egui::Context| in_window_points().map(|(x, y)| egui::pos2(x as f32, y as f32) / ctx.zoom_factor())))
}
/// The pointer in points from the top-left of the content view of our frontmost window under
/// it (`None` when it's over none of ours, or off the main thread). Only our own windows are
/// searched: a drop that reached us landed on one of them.
fn in_window_points() -> Option<(f64, f64)> {
let mtm = MainThreadMarker::new()?;
let at = NSEvent::mouseLocation();
let contains = |w: &NSWindow| {
let f = w.frame();
(f.origin.x..f.origin.x + f.size.width).contains(&at.x) && (f.origin.y..f.origin.y + f.size.height).contains(&at.y)
};
let window = NSApplication::sharedApplication(mtm).orderedWindows().iter().find(|w| w.isVisible() && contains(w))?;
let view = window.contentView()?;
let p = view.convertPoint_fromView(window.convertPointFromScreen(at), None);
let y = if view.isFlipped() { p.y } else { view.bounds().size.height - p.y };
Some((p.x, y))
}
}
#[cfg(target_os = "linux")]
mod platform {
use eframe::wgpu::rwh::{HasWindowHandle, RawWindowHandle};
use photocraft_ui_egui::CursorPosFn;
use x11rb::protocol::xproto::ConnectionExt;
use x11rb::rust_connection::RustConnection;
pub fn service(cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
let window = match cc.window_handle().ok()?.as_raw() {
RawWindowHandle::Xlib(h) => u32::try_from(h.window).ok()?,
RawWindowHandle::Xcb(h) => h.window.get(),
_ => return None,
};
// Opened on first use and kept: the pointer is read every frame while files hover.
let mut conn: Option<RustConnection> = None;
Some(Box::new(move |ctx: &egui::Context| match pointer_in(&mut conn, window) {
Ok(p) => p.map(|(x, y)| egui::pos2(f32::from(x), f32::from(y)) / ctx.pixels_per_point()),
Err(e) => {
log::warn!("couldn't read the pointer position: {e}");
conn = None;
None
}
}))
}
/// The pointer in pixels relative to `window` (`None` when it's on another screen).
fn pointer_in(conn: &mut Option<RustConnection>, window: u32) -> Result<Option<(i16, i16)>, String> {
let conn = match conn {
Some(c) => c,
None => conn.insert(RustConnection::connect(None).map_err(|e| e.to_string())?.0),
};
let reply = conn.query_pointer(window).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
Ok(reply.same_screen.then_some((reply.win_x, reply.win_y)))
}
}
#[cfg(target_os = "windows")]
mod platform {
use photocraft_ui_egui::CursorPosFn;
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
Some(Box::new(|ctx: &egui::Context| {
let pt = winsafe::GetCursorPos().map_err(|e| log::warn!("couldn't read the pointer position: {e}")).ok()?;
let inner = ctx.input(|i| i.viewport().inner_rect)?;
Some(super::client_points(egui::pos2(pt.x as f32, pt.y as f32), inner.min, ctx.pixels_per_point()))
}))
}
}
/// A screen position in physical pixels to points in the window whose client area starts at
/// `inner_min` (egui's `inner_rect.min`: the client origin in pixels over `ppp`). On Windows
/// `GetCursorPos` and the client origin are both physical pixels (winit makes the process
/// per-monitor DPI aware) and the window draws at one scale wherever it sits, so this holds across
/// monitors with different DPIs.
#[cfg(any(target_os = "windows", test))]
fn client_points(screen_px: egui::Pos2, inner_min: egui::Pos2, ppp: f32) -> egui::Pos2 {
((screen_px - inner_min * ppp) / ppp).to_pos2()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
mod platform {
use photocraft_ui_egui::CursorPosFn;
pub fn service(_cc: &eframe::CreationContext<'_>) -> Option<CursorPosFn> {
None
}
}
#[cfg(test)]
mod tests {
use egui::pos2;
#[test]
fn client_points_on_a_second_monitor_at_another_scale() {
// A 150% monitor left of a 100% primary: the window's client area starts at (-2400, 300) px.
let inner_min = pos2(-2400.0, 300.0) / 1.5;
assert_eq!(super::client_points(pos2(-2100.0, 450.0), inner_min, 1.5), pos2(200.0, 100.0));
// The same window on the 100% primary.
assert_eq!(super::client_points(pos2(500.0, 400.0), pos2(300.0, 300.0), 1.0), pos2(200.0, 100.0));
}
}
+662
View File
@@ -0,0 +1,662 @@
//! Crash-safe GPU startup (#4).
//!
//! A graphics driver that segfaults while the window's wgpu device is created (the Intel Vulkan
//! driver `igvk64.dll` on Windows) can't be caught in-process. So before the device exists the app
//! writes a *marker* (`gpu-starting.json` in the config directory: backend and, once chosen, the
//! adapter) and holds an OS file lock on it; it clears the marker once the first frames have
//! rendered. If the next launch finds a marker that no running process holds, the previous start
//! died inside the driver, and this one retries with the next safer backend:
//!
//! - Windows: Vulkan → DX12 → CPU
//! - Linux and other Unix: Vulkan → GL → CPU
//! - macOS: Metal → CPU
//!
//! The choice that worked is remembered in `performance.gpuBackend` (Preferences › Performance,
//! where it can be reset). With `auto`, Intel adapters on Windows use DX12. `WGPU_BACKEND` still
//! overrides everything, and `--safe-gpu` forces the CPU path for one launch.
//!
//! "CPU" composites on the CPU and draws the window with a software adapter where the platform
//! has one (WARP on Windows, llvmpipe over GL elsewhere).
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use eframe::{egui_wgpu, wgpu};
use photocraft_engine::prefs::{GpuBackend, RenderingMode};
use serde_json::{Value, json};
/// The marker's file name in the config directory.
pub const MARKER_FILE: &str = "gpu-starting.json";
/// PCI vendor id of Intel.
pub const INTEL: u32 = 0x8086;
/// Largest marker read (a marker is a few hundred bytes).
const MARKER_MAX: u64 = 64 << 10;
/// Platform families with different backend chains.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Os {
Windows,
Mac,
/// Linux, the BSDs and other Unix.
Other,
}
impl Os {
pub fn current() -> Self {
if cfg!(windows) {
Os::Windows
} else if cfg!(target_os = "macos") {
Os::Mac
} else {
Os::Other
}
}
}
/// What a start records before touching the driver.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Marker {
/// The plan's backend (`auto`, `dx12`, …), or `env:<value>` under `WGPU_BACKEND`.
pub backend: String,
/// The chosen adapter's name, backend and driver (empty until the adapter is chosen).
pub adapter: String,
pub adapter_backend: String,
pub driver: String,
pub version: String,
}
impl Marker {
pub fn to_json(&self) -> String {
json!({
"backend": self.backend,
"adapter": self.adapter,
"adapterBackend": self.adapter_backend,
"driver": self.driver,
"version": self.version,
})
.to_string()
}
/// Parse a marker leniently: anything unreadable is a marker of an unknown backend.
pub fn parse(text: &str) -> Self {
let v: Value = serde_json::from_str(text).unwrap_or(Value::Null);
let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").chars().take(256).collect::<String>();
Marker { backend: s("backend"), adapter: s("adapter"), adapter_backend: s("adapterBackend"), driver: s("driver"), version: s("version") }
}
/// The backend the crashed start was using: the adapter's when it got that far with `auto`,
/// else the plan's. `None` for a start under `WGPU_BACKEND` (the user's own override).
pub fn tried(&self) -> Option<GpuBackend> {
let planned = if self.backend.is_empty() { Some(GpuBackend::Auto) } else { GpuBackend::parse(&self.backend) }?;
match (planned, GpuBackend::parse(&self.adapter_backend)) {
(GpuBackend::Auto, Some(actual)) => Some(actual),
_ => Some(planned),
}
}
}
/// The backend this launch uses and why.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Plan {
pub backend: GpuBackend,
/// `WGPU_BACKEND`, which picks the backends itself.
pub env: Option<String>,
/// Why this isn't simply the preference.
pub reason: Option<String>,
/// Store `backend` in the preferences once the start succeeded (a crash fallback).
pub remember: bool,
}
/// A backend the platform has (others fall back to `auto`).
pub fn sanitize(pref: GpuBackend, os: Os) -> GpuBackend {
use GpuBackend::*;
match (os, pref) {
(Os::Windows, Metal) | (Os::Mac, Vulkan | Dx12 | Gl) | (Os::Other, Dx12 | Metal) => Auto,
(_, b) => b,
}
}
/// The next safer backend after a start on `tried` crashed.
pub fn next_safer(tried: GpuBackend, os: Os) -> GpuBackend {
use GpuBackend::*;
match (os, tried) {
(Os::Windows, Auto | Vulkan | Gl | Metal) => Dx12,
(Os::Windows, Dx12 | Cpu) => Cpu,
(Os::Mac, _) => Cpu,
(Os::Other, Auto | Vulkan | Dx12 | Metal) => Gl,
(Os::Other, Gl | Cpu) => Cpu,
}
}
/// Decide this launch's backend from the preference, a marker left by a start that crashed,
/// `WGPU_BACKEND` and `--safe-gpu`.
pub fn plan(pref: GpuBackend, crashed: Option<&Marker>, env: Option<&str>, safe_gpu: bool, os: Os) -> Plan {
if let Some(v) = env.map(str::trim).filter(|v| !v.is_empty()) {
let backend = if safe_gpu { GpuBackend::Cpu } else { GpuBackend::Auto };
return Plan { backend, env: Some(v.to_string()), reason: Some(format!("WGPU_BACKEND={v} overrides the backend")), remember: false };
}
if safe_gpu {
return Plan { backend: GpuBackend::Cpu, env: None, reason: Some("--safe-gpu: CPU renderer for this launch".into()), remember: false };
}
let pref = sanitize(pref, os);
if let Some((m, tried)) = crashed.and_then(|m| Some((m, m.tried()?))) {
let next = next_safer(tried, os);
let on = if m.adapter.is_empty() { String::new() } else { format!(" ({})", m.adapter) };
return Plan {
backend: next,
env: None,
reason: Some(format!("the previous start didn't finish on {}{on}; using {}", tried.name(), next.name())),
remember: next != pref,
};
}
Plan { backend: pref, env: None, reason: None, remember: false }
}
/// Resolve the rendering policy before the window exists. Explicit GPU/Automatic selections
/// reset a legacy CPU backend to automatic; CPU keeps software-preferred window presentation.
pub fn plan_with_mode(pref: GpuBackend, mode: RenderingMode, crashed: Option<&Marker>, env: Option<&str>, safe_gpu: bool, os: Os) -> Plan {
let backend = match mode {
RenderingMode::Cpu => GpuBackend::Cpu,
RenderingMode::Auto | RenderingMode::Gpu if pref == GpuBackend::Cpu => GpuBackend::Auto,
_ => pref,
};
let env = if safe_gpu || mode == RenderingMode::Cpu { None } else { env };
let crashed = if mode == RenderingMode::Cpu { None } else { crashed };
plan(backend, crashed, env, safe_gpu, os)
}
/// Instance backends for `plan` (`None`: egui's default, which honours `WGPU_BACKEND`).
pub fn backends(plan: &Plan, os: Os) -> Option<wgpu::Backends> {
if plan.env.is_some() {
return None;
}
Some(match plan.backend {
GpuBackend::Auto => return None,
GpuBackend::Vulkan => wgpu::Backends::VULKAN,
GpuBackend::Dx12 => wgpu::Backends::DX12,
GpuBackend::Metal => wgpu::Backends::METAL,
GpuBackend::Gl => wgpu::Backends::GL,
GpuBackend::Cpu => match os {
Os::Windows => wgpu::Backends::DX12,
Os::Mac => wgpu::Backends::METAL,
Os::Other => wgpu::Backends::GL,
},
})
}
/// The DX12 shader compiler (#712). wgpu's default loads `dxcompiler.dll` by name, and the
/// Windows DLL search falls through to the current directory and `PATH`, so another program's DXC
/// build (a browser's, an SDK's) got loaded and failed device creation or crashed the first shader
/// compile. Use a `dxcompiler.dll` shipped beside the executable, by its full path; otherwise FXC
/// (`d3dcompiler_47.dll`, part of Windows). `WGPU_DX12_COMPILER` still picks one explicitly.
pub fn dx12_compiler(env: Option<&str>, exe_dir: Option<&Path>) -> wgpu::Dx12Compiler {
if let Some(c) = env.and_then(|v| v.trim().parse().ok()) {
return c;
}
let beside_exe = exe_dir.map(|d| d.join("dxcompiler.dll")).filter(|p| p.is_file());
match beside_exe.as_deref().and_then(Path::to_str) {
Some(p) => wgpu::Dx12Compiler::DynamicDxc { dxc_path: p.to_string() },
None => wgpu::Dx12Compiler::Fxc,
}
}
/// What adapter selection needs to know about an adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Candidate {
pub vendor: u32,
pub device_type: wgpu::DeviceType,
pub backend: wgpu::Backend,
/// Can present to the window.
pub surface_ok: bool,
}
/// 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.
fn rank(a: &Candidate, backend: GpuBackend, os: Os) -> (u8, u8) {
let ty = match a.device_type {
wgpu::DeviceType::DiscreteGpu => 0,
wgpu::DeviceType::IntegratedGpu => 1,
wgpu::DeviceType::VirtualGpu => 2,
wgpu::DeviceType::Other => 3,
wgpu::DeviceType::Cpu => 4,
};
let ty = match (backend, a.device_type) {
(GpuBackend::Cpu, wgpu::DeviceType::Cpu) => 0,
(GpuBackend::Cpu, _) => ty + 1,
_ => ty,
};
let be = match a.backend {
wgpu::Backend::Dx12 if os == Os::Windows && a.vendor == INTEL => 0,
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 1,
wgpu::Backend::Dx12 => 2,
wgpu::Backend::Gl => 3,
wgpu::Backend::BrowserWebGpu | wgpu::Backend::Noop => 4,
};
(ty, be)
}
/// The adapter to use among `adapters` (`None` when there is none).
pub fn pick(adapters: &[Candidate], backend: GpuBackend, os: Os) -> Option<usize> {
adapters.iter().enumerate().filter(|(_, a)| a.surface_ok).min_by_key(|(i, a)| (rank(a, backend, os), *i)).map(|(i, _)| i)
}
/// Whether picking `chosen` applied the Intel-on-Windows DX12 default (a Vulkan adapter of the
/// same vendor was available).
pub fn intel_dx12_applied(adapters: &[Candidate], chosen: usize, os: Os) -> bool {
let Some(c) = adapters.get(chosen) else { return false };
os == Os::Windows
&& c.vendor == INTEL
&& c.backend == wgpu::Backend::Dx12
&& adapters.iter().any(|a| a.vendor == INTEL && a.backend == wgpu::Backend::Vulkan)
}
/// Configure eframe's wgpu setup for `plan`: instance backends, the DX12 shader compiler, adapter
/// selection (Intel on Windows, software adapters for `cpu`) and the marker update once the
/// adapter is chosen. `note` receives a remark for System Info (e.g. the Intel default).
pub fn configure(setup: &mut egui_wgpu::WgpuSetup, plan: &Plan, os: Os, sentinel: SharedSentinel, note: Arc<Mutex<Option<String>>>) {
if let egui_wgpu::WgpuSetup::CreateNew(create) = setup {
if let Some(b) = backends(plan, os) {
create.instance_descriptor.backends = b;
}
if os == Os::Windows {
let exe = std::env::current_exe().ok();
let env = std::env::var("WGPU_DX12_COMPILER").ok();
create.instance_descriptor.backend_options.dx12.shader_compiler = dx12_compiler(env.as_deref(), exe.as_deref().and_then(Path::parent));
}
let select = plan.env.is_none() && (plan.backend == GpuBackend::Cpu || (plan.backend == GpuBackend::Auto && os == Os::Windows));
if select {
let backend = plan.backend;
create.native_adapter_selector = Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| {
let cands: Vec<Candidate> = adapters
.iter()
.map(|a| {
let i = a.get_info();
Candidate {
vendor: i.vendor,
device_type: i.device_type,
backend: i.backend,
surface_ok: surface.is_none_or(|s| a.is_surface_supported(s)),
}
})
.collect();
let i = pick(&cands, backend, os).ok_or_else(|| "no graphics adapter found".to_string())?;
if backend == GpuBackend::Cpu && cands.get(i).is_some_and(|c| c.device_type != wgpu::DeviceType::Cpu) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("CPU image rendering; no software graphics adapter is available, so the window uses hardware graphics".into());
}
if backend == GpuBackend::Auto && intel_dx12_applied(&cands, i, os) {
*note.lock().unwrap_or_else(PoisonError::into_inner) =
Some("Intel graphics on Windows: using DirectX 12 (the Intel Vulkan driver is known to crash)".into());
}
adapters.get(i).cloned().ok_or_else(|| "no graphics adapter found".to_string())
}));
}
}
photocraft_ui_egui::gpu_canvas::use_adapter_limits_with(setup, move |adapter| {
let info = adapter.get_info();
let mut guard = sentinel.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(s) = guard.as_mut() {
let mut m = s.marker.clone();
m.adapter = info.name.clone();
m.adapter_backend = photocraft_ui_egui::gpu_canvas::backend_name(info.backend).to_string();
m.driver = [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join(" ");
if let Err(e) = s.write(m) {
log::warn!("GPU startup marker: {e}");
}
}
});
}
/// What a previous start left behind.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Previous {
/// Nothing: it finished starting (or never ran).
Clean,
/// Its marker, unlocked: it died before rendering its first frames.
Crashed(Marker),
/// Another instance is starting right now (it holds the lock).
Busy,
}
impl Previous {
pub fn crashed(&self) -> Option<&Marker> {
match self {
Previous::Crashed(m) => Some(m),
_ => None,
}
}
}
/// The locked marker file of this start.
#[derive(Debug)]
pub struct Sentinel {
path: PathBuf,
file: Option<File>,
marker: Marker,
}
/// The sentinel, shared with eframe's adapter hook and the app's started hook.
pub type SharedSentinel = Arc<Mutex<Option<Sentinel>>>;
impl Sentinel {
/// Open (creating) the marker in `dir` and lock it. Returns what a previous start left and,
/// unless another instance holds the lock or the directory isn't writable, this start's
/// sentinel (not yet written: see [`Sentinel::write`]).
pub fn begin(dir: &Path) -> (Previous, Option<Sentinel>) {
let path = dir.join(MARKER_FILE);
if let Err(e) = std::fs::create_dir_all(dir) {
log::warn!("GPU startup marker: {e}");
return (Previous::Clean, None);
}
let mut file = match OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path) {
Ok(f) => f,
Err(e) => {
log::warn!("GPU startup marker {}: {e}", path.display());
return (Previous::Clean, None);
}
};
match file.try_lock() {
Ok(()) => {}
Err(std::fs::TryLockError::WouldBlock) => return (Previous::Busy, None),
Err(std::fs::TryLockError::Error(e)) => {
log::warn!("GPU startup marker lock: {e}");
return (Previous::Clean, None);
}
}
let mut text = String::new();
let _ = (&mut file).take(MARKER_MAX).read_to_string(&mut text);
let previous = if text.trim().is_empty() { Previous::Clean } else { Previous::Crashed(Marker::parse(&text)) };
(previous, Some(Sentinel { path, file: Some(file), marker: Marker::default() }))
}
/// Replace the marker's contents with `m`.
pub fn write(&mut self, m: Marker) -> std::io::Result<()> {
let f = self.file.as_mut().ok_or_else(|| std::io::Error::other("marker closed"))?;
f.set_len(0)?;
f.seek(SeekFrom::Start(0))?;
f.write_all(m.to_json().as_bytes())?;
f.flush()?;
self.marker = m;
Ok(())
}
/// The start succeeded: clear the marker and release the lock.
pub fn finish(mut self) {
if let Some(f) = self.file.take() {
// Empty means "no crash" even if removing fails (another instance has it open).
let _ = f.set_len(0);
drop(f);
}
let _ = std::fs::remove_file(&self.path);
}
}
/// 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);
let perf = v.get("performance");
let backend = perf.and_then(|p| p.get("gpuBackend")).and_then(Value::as_str).and_then(GpuBackend::parse).unwrap_or_default();
let use_gpu = perf.and_then(|p| p.get("useGpu")).and_then(Value::as_bool).unwrap_or(true);
let explicit = perf.and_then(|p| p.get("renderingMode")).and_then(Value::as_str).and_then(RenderingMode::parse);
let mode = explicit.unwrap_or(if !use_gpu || backend == GpuBackend::Cpu { RenderingMode::Cpu } else { RenderingMode::Auto });
(backend, mode)
}
#[cfg(test)]
mod tests {
use super::*;
use GpuBackend::*;
fn crashed(backend: &str, adapter_backend: &str) -> Marker {
Marker { backend: backend.into(), adapter: "Intel(R) UHD Graphics".into(), adapter_backend: adapter_backend.into(), ..Default::default() }
}
#[test]
fn cpu_policy_ignores_hardware_backend_override() {
let p = plan_with_mode(Vulkan, RenderingMode::Cpu, None, Some("vulkan"), false, Os::Windows);
assert_eq!(p.backend, Cpu);
assert!(p.env.is_none());
assert_eq!(plan_with_mode(Vulkan, RenderingMode::Cpu, Some(&crashed("vulkan", "vulkan")), None, false, Os::Windows).backend, Cpu);
let p = plan_with_mode(Auto, RenderingMode::Gpu, None, Some("vulkan"), true, Os::Windows);
assert_eq!(p.backend, Cpu);
assert!(p.env.is_none());
}
#[test]
fn no_marker_uses_the_preference() {
for os in [Os::Windows, Os::Mac, Os::Other] {
let p = plan(Auto, None, None, false, os);
assert_eq!(p, Plan { backend: Auto, env: None, reason: None, remember: false });
}
assert_eq!(plan(Dx12, None, None, false, Os::Windows).backend, Dx12);
assert_eq!(plan(Gl, None, None, false, Os::Other).backend, Gl);
// A backend the platform lacks is `auto`.
assert_eq!(plan(Metal, None, None, false, Os::Windows).backend, Auto);
assert_eq!(plan(Dx12, None, None, false, Os::Mac).backend, Auto);
assert_eq!(plan(Dx12, None, None, false, Os::Other).backend, Auto);
}
#[test]
fn a_crash_moves_down_each_chain_and_is_remembered() {
// Windows: Vulkan → DX12 → CPU.
let p = plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Windows);
assert_eq!((p.backend, p.remember), (Dx12, true));
assert!(p.reason.as_deref().unwrap_or("").contains("vulkan (Intel(R) UHD Graphics)"), "{p:?}");
assert_eq!(plan(Dx12, Some(&crashed("dx12", "dx12")), None, false, Os::Windows).backend, Cpu);
// Crashed before the adapter was chosen (enumeration): from `auto`.
assert_eq!(plan(Auto, Some(&crashed("auto", "")), None, false, Os::Windows).backend, Dx12);
// `auto` that had already picked DX12 (the Intel default) goes to the CPU.
assert_eq!(plan(Auto, Some(&crashed("auto", "dx12")), None, false, Os::Windows).backend, Cpu);
// Linux: Vulkan → GL → CPU.
assert_eq!(plan(Auto, Some(&crashed("auto", "vulkan")), None, false, Os::Other).backend, Gl);
assert_eq!(plan(Gl, Some(&crashed("gl", "gl")), None, false, Os::Other).backend, Cpu);
// macOS: Metal → CPU.
assert_eq!(plan(Auto, Some(&crashed("auto", "metal")), None, false, Os::Mac).backend, Cpu);
// The CPU path stays there, and isn't "remembered" again.
let p = plan(Cpu, Some(&crashed("cpu", "dx12")), None, false, Os::Windows);
assert_eq!((p.backend, p.remember), (Cpu, false));
// An unreadable marker still counts as a crash.
assert_eq!(plan(Auto, Some(&Marker::parse("{garbage")), None, false, Os::Windows).backend, Dx12);
}
#[test]
fn env_override_beats_everything() {
let p = plan(Cpu, Some(&crashed("auto", "vulkan")), Some("vulkan"), false, Os::Windows);
assert_eq!(p.env.as_deref(), Some("vulkan"));
assert_eq!(p.backend, Auto);
assert!(!p.remember);
assert_eq!(backends(&p, Os::Windows), None, "egui reads WGPU_BACKEND itself");
// A crash under the override doesn't count against `auto` later.
let p = plan(Auto, Some(&Marker { backend: "env:vulkan".into(), ..Default::default() }), None, false, Os::Windows);
assert_eq!(p.backend, Auto);
// Empty means unset.
assert_eq!(plan(Auto, None, Some(" "), false, Os::Windows).env, None);
// --safe-gpu with the override: CPU canvas, the override's backends.
assert_eq!(plan(Auto, None, Some("dx12"), true, Os::Windows).backend, Cpu);
}
#[test]
fn safe_gpu_forces_the_cpu_for_one_launch() {
let p = plan(Vulkan, Some(&crashed("vulkan", "vulkan")), None, true, Os::Other);
assert_eq!((p.backend, p.remember), (Cpu, false));
assert_eq!(backends(&p, Os::Windows), Some(wgpu::Backends::DX12));
assert_eq!(backends(&p, Os::Other), Some(wgpu::Backends::GL));
assert_eq!(backends(&p, Os::Mac), Some(wgpu::Backends::METAL));
}
fn cand(vendor: u32, device_type: wgpu::DeviceType, backend: wgpu::Backend) -> Candidate {
Candidate { vendor, device_type, backend, surface_ok: true }
}
#[test]
fn intel_on_windows_defaults_to_dx12() {
use wgpu::{Backend as B, DeviceType as T};
let intel = [cand(INTEL, T::IntegratedGpu, B::Vulkan), cand(INTEL, T::IntegratedGpu, B::Dx12), cand(INTEL, T::IntegratedGpu, B::Gl)];
let i = pick(&intel, Auto, Os::Windows).unwrap();
assert_eq!(intel[i].backend, B::Dx12);
assert!(intel_dx12_applied(&intel, i, Os::Windows));
// Elsewhere (and for other vendors) Vulkan stays first.
assert_eq!(intel[pick(&intel, Auto, Os::Other).unwrap()].backend, B::Vulkan);
let nv = [cand(0x10de, T::DiscreteGpu, B::Dx12), cand(0x10de, T::DiscreteGpu, B::Vulkan)];
let i = pick(&nv, Auto, Os::Windows).unwrap();
assert_eq!(nv[i].backend, B::Vulkan);
assert!(!intel_dx12_applied(&nv, i, Os::Windows));
// Intel iGPU + discrete GPU: the discrete one wins (high performance), on Vulkan.
let hybrid = [intel[0], intel[1], nv[1]];
assert_eq!(pick(&hybrid, Auto, Os::Windows), Some(2));
// Adapters that can't present lose to ones that can.
let mut only = [cand(0x10de, T::DiscreteGpu, B::Vulkan), intel[1]];
only[0].surface_ok = false;
assert_eq!(pick(&only, Auto, Os::Windows), Some(1));
assert_eq!(pick(&[], Auto, Os::Windows), None);
}
#[test]
fn cpu_prefers_software_adapters() {
use wgpu::{Backend as B, DeviceType as T};
let a = [cand(INTEL, T::IntegratedGpu, B::Dx12), cand(0x1414, T::Cpu, B::Dx12)];
assert_eq!(pick(&a, Cpu, Os::Windows), Some(1));
// No software adapter: still something.
assert_eq!(pick(&a[..1], Cpu, Os::Windows), Some(0));
}
#[test]
fn adapters_must_present_even_when_none_support_the_surface() {
let mut a = cand(INTEL, wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12);
a.surface_ok = false;
assert_eq!(pick(&[a], Auto, Os::Windows), None);
assert_eq!(pick(&[a], Cpu, Os::Windows), None);
}
#[test]
fn explicit_policy_overrides_legacy_cpu_backend() {
assert_eq!(plan_with_mode(Cpu, RenderingMode::Gpu, None, None, false, Os::Mac).backend, Auto);
assert_eq!(plan_with_mode(Metal, RenderingMode::Cpu, None, None, false, Os::Mac).backend, Cpu);
assert_eq!(plan_with_mode(Auto, RenderingMode::Gpu, None, None, true, Os::Mac).backend, Cpu);
}
#[test]
fn marker_round_trip_and_lenient_parse() {
let m = Marker {
backend: "auto".into(),
adapter: "A \"quoted\" GPU".into(),
adapter_backend: "vulkan".into(),
driver: "1.2".into(),
version: "0.1".into(),
};
assert_eq!(Marker::parse(&m.to_json()), m);
assert_eq!(Marker::parse(""), Marker::default());
assert_eq!(Marker::parse("[1,2]").tried(), Some(Auto));
assert_eq!(Marker::parse(r#"{"backend": 7}"#).tried(), Some(Auto));
assert_eq!(Marker::parse(r#"{"backend": "env:dx12"}"#).tried(), None);
let long = format!(r#"{{"adapter": "{}"}}"#, "x".repeat(10_000));
assert_eq!(Marker::parse(&long).adapter.len(), 256);
}
fn temp_dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("photocraft-gpu-startup-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
}
#[test]
fn sentinel_detects_a_crash_and_clears_on_success() {
let dir = temp_dir("cycle");
// First launch: nothing left behind.
let (prev, s) = Sentinel::begin(&dir);
assert_eq!(prev, Previous::Clean);
let mut s = s.expect("sentinel");
s.write(Marker { backend: "auto".into(), ..Default::default() }).unwrap();
s.write(Marker { backend: "auto".into(), adapter: "GPU".into(), adapter_backend: "vulkan".into(), ..Default::default() }).unwrap();
// While it is starting, a second instance sees it busy, not crashed.
let (busy, none) = Sentinel::begin(&dir);
assert_eq!(busy, Previous::Busy);
assert!(none.is_none());
// The process dies in the driver: the lock goes, the marker stays.
drop(s);
// Another test in this binary spawns a process: between its fork and exec the child briefly
// shares this file's flock, so `begin` can see the lock as busy. Retry for a moment.
let (mut prev, mut s) = Sentinel::begin(&dir);
for _ in 0..50 {
if prev != Previous::Busy {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
(prev, s) = Sentinel::begin(&dir);
}
let m = prev.crashed().cloned().expect("crash detected");
assert_eq!((m.adapter.as_str(), m.tried()), ("GPU", Some(Vulkan)));
// This start succeeds: the next one finds nothing.
let mut s = s.expect("sentinel");
s.write(Marker { backend: "dx12".into(), ..Default::default() }).unwrap();
s.finish();
assert!(!dir.join(MARKER_FILE).exists());
let (prev, _s) = Sentinel::begin(&dir);
assert_eq!(prev, Previous::Clean);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unwritable_config_dir_is_not_fatal() {
let dir = temp_dir("file");
std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
std::fs::write(&dir, b"not a directory").unwrap();
let (prev, s) = Sentinel::begin(&dir.join("sub"));
assert_eq!(prev, Previous::Clean);
assert!(s.is_none());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn prefs_are_read_leniently() {
let dir = temp_dir("prefs");
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("preferences.json");
assert_eq!(read_rendering_prefs(None), (Auto, RenderingMode::Auto));
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
std::fs::write(&p, r#"{"performance": {"gpuBackend": "dx12", "useGpu": false}, "interface": {}}"#).unwrap();
assert_eq!(read_rendering_prefs(Some(&p)), (Dx12, RenderingMode::Cpu));
std::fs::write(&p, r#"{"performance": {"gpuBackend": "quantum"}}"#).unwrap();
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
std::fs::write(&p, r#"{"performance": {"useGpu": false, "renderingMode": "gpu"}}"#).unwrap();
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Gpu));
std::fs::write(&p, "{not json").unwrap();
assert_eq!(read_rendering_prefs(Some(&p)), (Auto, RenderingMode::Auto));
let _ = std::fs::remove_dir_all(&dir);
}
/// #712: never a `dxcompiler.dll` found through the DLL search path (current directory, PATH).
#[test]
fn dx12_compiler_is_fxc_or_dxc_beside_the_exe() {
use wgpu::Dx12Compiler as C;
let dir = temp_dir("dxc");
std::fs::create_dir_all(&dir).unwrap();
assert!(matches!(dx12_compiler(None, None), C::Fxc));
assert!(matches!(dx12_compiler(None, Some(&dir)), C::Fxc));
std::fs::write(dir.join("dxcompiler.dll"), b"MZ").unwrap();
let expected = dir.join("dxcompiler.dll");
assert!(matches!(dx12_compiler(None, Some(&dir)), C::DynamicDxc { dxc_path } if Path::new(&dxc_path) == expected));
// An explicit WGPU_DX12_COMPILER wins; an unknown value is ignored.
assert!(matches!(dx12_compiler(Some(" FXC "), Some(&dir)), C::Fxc));
assert!(matches!(dx12_compiler(Some("dxc"), None), C::DynamicDxc { dxc_path } if dxc_path == "dxcompiler.dll"));
assert!(matches!(dx12_compiler(Some("quantum"), None), C::Fxc));
let _ = std::fs::remove_dir_all(&dir);
// The app's setup replaces wgpu's default (`Auto`: DXC by name from the search path).
let mut setup = egui_wgpu::WgpuSetup::without_display_handle();
let plan = plan(Auto, None, None, false, Os::Windows);
configure(&mut setup, &plan, Os::Windows, Arc::new(Mutex::new(None)), Arc::default());
let egui_wgpu::WgpuSetup::CreateNew(create) = &setup else { panic!("a new instance") };
if std::env::var_os("WGPU_DX12_COMPILER").is_none() {
let safe = match &create.instance_descriptor.backend_options.dx12.shader_compiler {
C::Fxc => true,
C::DynamicDxc { dxc_path } => Path::new(dxc_path).is_absolute(),
C::StaticDxc | C::Auto => false,
};
assert!(safe, "{:?}", create.instance_descriptor.backend_options.dx12.shader_compiler);
}
}
}
+413
View File
@@ -0,0 +1,413 @@
//! Linux start-up check for the shared libraries winit and wgpu load at runtime (issue #201).
//!
//! The windowing (X11/Wayland, xkbcommon) and GPU (Vulkan, EGL) libraries aren't ELF `NEEDED`
//! entries: winit and wgpu `dlopen` them when the window opens, and some of those crates panic
//! when a library is missing (`xkbcommon-dl`: "libxkbcommon-x11.so could not be loaded"). So,
//! before eframe starts, we look for the libraries the current session type needs and, if one is
//! missing, print the package to install and exit non-zero instead of crashing (AGENTS.md, Never
//! crash).
//!
//! No `dlopen` here (that needs `unsafe`): we read the dynamic linker's cache (`ldconfig -p`) and
//! look in `LD_LIBRARY_PATH` and the usual library directories. Only the cache is conclusive: if
//! it can't be read (NixOS, some containers) and a library isn't found in the directories, we
//! warn and start anyway. `PHOTOCRAFT_SKIP_LIB_CHECK=1` skips the check.
//!
//! The parsing and decision logic is pure and tested on every platform; only [`preflight`]
//! touches the system, and it is only called on Linux.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::path::{Path, PathBuf};
/// The display server winit will connect to, chosen the way winit 0.30 does: Wayland when
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set (non-empty), otherwise X11 when `DISPLAY` is.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisplaySession {
Wayland,
X11,
/// Neither is set: winit reports that itself (an error, not a panic).
None,
}
pub fn session_from_env(var: impl Fn(&str) -> Option<String>) -> DisplaySession {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
DisplaySession::Wayland
} else if set("DISPLAY") {
DisplaySession::X11
} else {
DisplaySession::None
}
}
/// A runtime-loaded library and the package that provides it on each distro family.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Lib {
pub soname: &'static str,
pub debian: &'static str,
pub fedora: &'static str,
pub arch: &'static str,
pub suse: &'static str,
}
const fn lib(soname: &'static str, debian: &'static str, fedora: &'static str, arch: &'static str, suse: &'static str) -> Lib {
Lib { soname, debian, fedora, arch, suse }
}
const XKBCOMMON: Lib = lib("libxkbcommon.so.0", "libxkbcommon0", "libxkbcommon", "libxkbcommon", "libxkbcommon0");
const XKBCOMMON_X11: Lib = lib("libxkbcommon-x11.so.0", "libxkbcommon-x11-0", "libxkbcommon-x11", "libxkbcommon-x11", "libxkbcommon-x11-0");
const X11: Lib = lib("libX11.so.6", "libx11-6", "libX11", "libx11", "libX11-6");
const X11_XCB: Lib = lib("libX11-xcb.so.1", "libx11-xcb1", "libX11-xcb", "libx11", "libX11-xcb1");
const XCB: Lib = lib("libxcb.so.1", "libxcb1", "libxcb", "libxcb", "libxcb1");
const XCURSOR: Lib = lib("libXcursor.so.1", "libxcursor1", "libXcursor", "libxcursor", "libXcursor1");
const XI: Lib = lib("libXi.so.6", "libxi6", "libXi", "libxi", "libXi6");
const WAYLAND_CLIENT: Lib = lib("libwayland-client.so.0", "libwayland-client0", "libwayland-client", "wayland", "libwayland-client0");
const VULKAN: Lib = lib("libvulkan.so.1", "libvulkan1", "vulkan-loader", "vulkan-icd-loader", "libvulkan1");
const EGL: Lib = lib("libEGL.so.1", "libegl1", "libglvnd-egl", "libglvnd", "libEGL1");
/// One requirement: at least one of these libraries must be present.
pub type AnyOf = &'static [Lib];
/// What winit 0.30 and wgpu `dlopen` for a session. X11: x11-dl opens Xlib, Xcursor, Xlib-xcb
/// and XInput2, x11rb opens libxcb, and xkbcommon-dl opens xkbcommon and xkbcommon-x11 (RandR
/// goes through x11rb's protocol code, so libXrandr isn't needed). Wayland: wayland-sys opens
/// libwayland-client (wayland-cursor is pure Rust) plus xkbcommon. wgpu needs Vulkan or EGL.
pub fn requirements(session: DisplaySession) -> Vec<AnyOf> {
const GPU: AnyOf = &[VULKAN, EGL];
match session {
DisplaySession::Wayland => vec![&[WAYLAND_CLIENT], &[XKBCOMMON], GPU],
DisplaySession::X11 => vec![&[X11], &[X11_XCB], &[XCB], &[XCURSOR], &[XI], &[XKBCOMMON], &[XKBCOMMON_X11], GPU],
DisplaySession::None => Vec::new(),
}
}
/// The `ldconfig -p` architecture tag for this build, when it's one we can tell apart from a
/// 32-bit library of the same name (`libc6,x86-64` vs plain `libc6` for i386).
fn ldconfig_arch_tag() -> Option<&'static str> {
if cfg!(target_arch = "x86_64") {
Some("x86-64")
} else if cfg!(target_arch = "aarch64") {
Some("AArch64")
} else {
None
}
}
/// Does `ldconfig -p` output list `soname` for our architecture? Lines look like
/// `\tlibxkbcommon-x11.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon-x11.so.0`.
pub fn ldconfig_has(output: &str, soname: &str, arch_tag: Option<&str>) -> bool {
output.lines().any(|line| {
let line = line.trim_start();
let Some((name, rest)) = line.split_once(' ') else { return false };
if name != soname {
return false;
}
match arch_tag {
None => true,
Some(tag) => {
let flags = rest.split_once('(').and_then(|(_, r)| r.split_once(')')).map_or("", |(f, _)| f);
flags.split(',').any(|f| f.trim() == tag)
}
}
})
}
/// A real `ldconfig -p` listing starts with "N libs found in cache".
fn ldconfig_output_is_valid(output: &str) -> bool {
output.lines().next().is_some_and(|l| l.contains("libs found in cache"))
}
/// Where we look for libraries, and what we found there.
pub struct Probe {
/// `ldconfig -p` output, when it ran and looked like a real cache listing.
pub ldconfig: Option<String>,
/// Directories to check for the soname (`LD_LIBRARY_PATH` first).
pub dirs: Vec<PathBuf>,
}
impl Probe {
fn has(&self, soname: &str, exists: &impl Fn(&Path) -> bool) -> bool {
self.ldconfig.as_deref().is_some_and(|out| ldconfig_has(out, soname, ldconfig_arch_tag())) || self.dirs.iter().any(|d| exists(&d.join(soname)))
}
}
/// The outcome of the check.
#[derive(Debug, PartialEq, Eq)]
pub enum Verdict {
Ok,
/// Requirements with none of their alternatives found. `conclusive` is true when the linker
/// cache was read, so the libraries really are absent (and winit would fail or panic).
Missing {
missing: Vec<AnyOf>,
conclusive: bool,
},
}
pub fn check(session: DisplaySession, probe: &Probe, exists: impl Fn(&Path) -> bool) -> Verdict {
let missing: Vec<AnyOf> = requirements(session).into_iter().filter(|any| !any.iter().any(|l| probe.has(l.soname, &exists))).collect();
if missing.is_empty() { Verdict::Ok } else { Verdict::Missing { missing, conclusive: probe.ldconfig.is_some() } }
}
/// Distro family, from `/etc/os-release` `ID` and `ID_LIKE`, to name the right packages.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Distro {
Debian,
Fedora,
Arch,
Suse,
Unknown,
}
pub fn distro_from_os_release(text: &str) -> Distro {
let mut ids = Vec::new();
for line in text.lines() {
let Some((key, value)) = line.split_once('=') else { continue };
if key.trim() == "ID" || key.trim() == "ID_LIKE" {
ids.extend(value.trim().trim_matches(|c| c == '"' || c == '\'').split_whitespace().map(str::to_ascii_lowercase));
}
}
let any = |names: &[&str]| ids.iter().any(|id| names.contains(&id.as_str()));
if any(&["debian", "ubuntu"]) {
Distro::Debian
} else if any(&["fedora", "rhel", "centos"]) {
Distro::Fedora
} else if any(&["arch"]) {
Distro::Arch
} else if any(&["suse", "opensuse"]) || ids.iter().any(|id| id.starts_with("opensuse")) {
Distro::Suse
} else {
Distro::Unknown
}
}
fn package(lib: &Lib, distro: Distro) -> &'static str {
match distro {
Distro::Debian | Distro::Unknown => lib.debian,
Distro::Fedora => lib.fedora,
Distro::Arch => lib.arch,
Distro::Suse => lib.suse,
}
}
fn install_command(packages: &[&str], distro: Distro) -> String {
let list = packages.join(" ");
match distro {
Distro::Debian => format!("sudo apt install {list}"),
Distro::Fedora => format!("sudo dnf install {list}"),
Distro::Arch => format!("sudo pacman -S {list}"),
Distro::Suse => format!("sudo zypper install {list}"),
Distro::Unknown => format!("Debian/Ubuntu: sudo apt install {list}"),
}
}
/// The message for missing libraries: what's missing, and the command that installs it.
pub fn missing_message(session: DisplaySession, missing: &[AnyOf], distro: Distro, conclusive: bool) -> String {
let session_name = match session {
DisplaySession::Wayland => "Wayland",
DisplaySession::X11 => "X11",
DisplaySession::None => "display",
};
let names: Vec<String> = missing.iter().map(|any| any.iter().map(|l| l.soname).collect::<Vec<_>>().join(" or ")).collect();
// For an either/or requirement, suggest its first alternative.
let mut packages: Vec<&str> = Vec::new();
for p in missing.iter().filter_map(|any| any.first()).map(|l| package(l, distro)) {
if !packages.contains(&p) {
packages.push(p);
}
}
let mut msg = if conclusive {
format!("photocraft: cannot start: the {session_name} session needs libraries that are not installed: {}.\n", names.join(", "))
} else {
format!("photocraft: warning: could not find these libraries the {session_name} session needs: {}. Starting anyway.\n", names.join(", "))
};
msg.push_str(&format!("Install them with:\n {}\n", install_command(&packages, distro)));
if distro == Distro::Unknown {
let fedora: Vec<&str> = missing.iter().filter_map(|any| any.first()).map(|l| l.fedora).collect();
msg.push_str(&format!(" Fedora: sudo dnf install {}\n", fedora.join(" ")));
}
if conclusive {
msg.push_str("(Set PHOTOCRAFT_SKIP_LIB_CHECK=1 to start anyway.)\n");
}
msg
}
/// Library directories to search: `LD_LIBRARY_PATH`, then the usual system and Flatpak paths.
fn search_dirs(ld_library_path: Option<&str>) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = ld_library_path.unwrap_or("").split(':').filter(|d| !d.is_empty()).map(PathBuf::from).collect();
let triplet = if cfg!(target_arch = "aarch64") { "aarch64-linux-gnu" } else { "x86_64-linux-gnu" };
for d in ["/app/lib", "/usr/local/lib", "/usr/lib64", "/lib64", "/usr/lib", "/lib"] {
dirs.push(PathBuf::from(d));
}
dirs.push(Path::new("/usr/lib").join(triplet));
dirs.push(Path::new("/lib").join(triplet));
dirs
}
/// `ldconfig -p`, from the usual locations (`/sbin` isn't on a normal user's PATH on Debian).
fn read_ldconfig() -> Option<String> {
for cmd in ["/sbin/ldconfig", "/usr/sbin/ldconfig", "ldconfig"] {
if let Ok(out) = std::process::Command::new(cmd).arg("-p").output()
&& out.status.success()
{
let text = String::from_utf8_lossy(&out.stdout).into_owned();
if ldconfig_output_is_valid(&text) {
return Some(text);
}
}
}
None
}
/// Run the check against this system. `Err` carries the message to print before exiting with a
/// non-zero status; a warning is printed here and the app starts.
pub fn preflight() -> Result<(), String> {
if std::env::var_os("PHOTOCRAFT_SKIP_LIB_CHECK").is_some_and(|v| !v.is_empty() && v != "0") {
return Ok(());
}
let session = session_from_env(|k| std::env::var(k).ok());
if session == DisplaySession::None {
return Ok(());
}
let probe = Probe { ldconfig: read_ldconfig(), dirs: search_dirs(std::env::var("LD_LIBRARY_PATH").ok().as_deref()) };
match check(session, &probe, |p| p.exists()) {
Verdict::Ok => Ok(()),
Verdict::Missing { missing, conclusive } => {
let distro = std::fs::read_to_string("/etc/os-release").map(|t| distro_from_os_release(&t)).unwrap_or(Distro::Unknown);
let msg = missing_message(session, &missing, distro, conclusive);
if conclusive {
Err(msg)
} else {
eprint!("{msg}");
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
move |k| map.get(k).cloned()
}
#[test]
fn session_follows_winit_order() {
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")])), DisplaySession::Wayland);
assert_eq!(session_from_env(env(&[("WAYLAND_SOCKET", "3")])), DisplaySession::Wayland);
assert_eq!(session_from_env(env(&[("WAYLAND_DISPLAY", ""), ("DISPLAY", ":99")])), DisplaySession::X11);
assert_eq!(session_from_env(env(&[("DISPLAY", "")])), DisplaySession::None);
assert_eq!(session_from_env(env(&[])), DisplaySession::None);
}
const CACHE: &str = "1234 libs found in cache `/etc/ld.so.cache'\n\
\tlibxkbcommon.so.0 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxkbcommon.so.0\n\
\tlibxkbcommon-x11.so.0 (libc6) => /lib/i386-linux-gnu/libxkbcommon-x11.so.0\n\
\tlibX11.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11.so.6\n\
\tlibX11-xcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libX11-xcb.so.1\n\
\tlibxcb.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libxcb.so.1\n\
\tlibXcursor.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXcursor.so.1\n\
\tlibXi.so.6 (libc6,x86-64) => /lib/x86_64-linux-gnu/libXi.so.6\n\
\tlibEGL.so.1 (libc6,x86-64) => /lib/x86_64-linux-gnu/libEGL.so.1\n\
\tlibvulkan.so.1 (libc6,AArch64) => /lib/aarch64-linux-gnu/libvulkan.so.1\n";
#[test]
fn ldconfig_parsing_matches_name_and_architecture() {
assert!(ldconfig_output_is_valid(CACHE));
assert!(!ldconfig_output_is_valid("ldconfig: command not found"));
assert!(ldconfig_has(CACHE, "libxkbcommon.so.0", Some("x86-64")));
// Only the 32-bit build is installed: doesn't count for a 64-bit app.
assert!(!ldconfig_has(CACHE, "libxkbcommon-x11.so.0", Some("x86-64")));
assert!(ldconfig_has(CACHE, "libxkbcommon-x11.so.0", None));
assert!(ldconfig_has(CACHE, "libvulkan.so.1", Some("AArch64")));
assert!(!ldconfig_has(CACHE, "libvulkan.so.1", Some("x86-64")));
// Prefix of another name, or a missing name, is not a match.
assert!(!ldconfig_has(CACHE, "libX11.so", None));
assert!(!ldconfig_has(CACHE, "libwayland-client.so.0", None));
assert!(!ldconfig_has("", "libX11.so.6", None));
assert!(!ldconfig_has("garbage\n\t(\n)) =>", "libX11.so.6", Some("x86-64")));
}
fn cache_for_this_arch(names: &[&str]) -> String {
let tag = ldconfig_arch_tag().map(|t| format!("libc6,{t}")).unwrap_or_else(|| "libc6".into());
let mut s = format!("{} libs found in cache `/etc/ld.so.cache'\n", names.len());
for n in names {
s.push_str(&format!("\t{n} ({tag}) => /usr/lib/{n}\n"));
}
s
}
const ALL_X11: &[&str] =
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0", "libEGL.so.1"];
#[test]
fn x11_without_xkbcommon_x11_is_reported() {
// Issue #201: Ubuntu server + Xvfb with only libxkbcommon0 installed.
let names: Vec<&str> = ALL_X11.iter().copied().filter(|n| *n != "libxkbcommon-x11.so.0").collect();
let probe = Probe { ldconfig: Some(cache_for_this_arch(&names)), dirs: vec![] };
let v = check(DisplaySession::X11, &probe, |_| false);
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing, got {v:?}") };
assert!(conclusive);
assert_eq!(missing.len(), 1);
assert_eq!(missing[0][0].soname, "libxkbcommon-x11.so.0");
let msg = missing_message(DisplaySession::X11, &missing, Distro::Debian, conclusive);
assert!(msg.contains("libxkbcommon-x11.so.0"), "{msg}");
assert!(msg.contains("sudo apt install libxkbcommon-x11-0"), "{msg}");
assert!(msg.contains("PHOTOCRAFT_SKIP_LIB_CHECK"), "{msg}");
}
#[test]
fn complete_systems_pass() {
let probe = Probe { ldconfig: Some(cache_for_this_arch(ALL_X11)), dirs: vec![] };
assert_eq!(check(DisplaySession::X11, &probe, |_| false), Verdict::Ok);
let wayland = cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0", "libvulkan.so.1"]);
let probe = Probe { ldconfig: Some(wayland), dirs: vec![] };
assert_eq!(check(DisplaySession::Wayland, &probe, |_| false), Verdict::Ok);
// Wayland doesn't need the X11 libraries; no session needs nothing.
assert_eq!(check(DisplaySession::None, &Probe { ldconfig: None, dirs: vec![] }, |_| false), Verdict::Ok);
}
#[test]
fn gpu_needs_vulkan_or_egl() {
let probe = Probe { ldconfig: Some(cache_for_this_arch(&["libwayland-client.so.0", "libxkbcommon.so.0"])), dirs: vec![] };
let Verdict::Missing { missing, .. } = check(DisplaySession::Wayland, &probe, |_| false) else { panic!("expected missing") };
assert_eq!(missing.len(), 1);
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Fedora, true);
assert!(msg.contains("libvulkan.so.1 or libEGL.so.1"), "{msg}");
assert!(msg.contains("sudo dnf install vulkan-loader"), "{msg}");
}
#[test]
fn library_dirs_count_and_unread_cache_only_warns() {
let dirs = search_dirs(Some("/opt/x/lib::/nix/store/abc/lib"));
assert_eq!(dirs[0], PathBuf::from("/opt/x/lib"));
assert_eq!(dirs[1], PathBuf::from("/nix/store/abc/lib"));
// Found by file in a directory (no ldconfig, as on NixOS).
let probe = Probe { ldconfig: None, dirs: vec![PathBuf::from("/nix/lib")] };
let present = ["/nix/lib/libwayland-client.so.0", "/nix/lib/libxkbcommon.so.0", "/nix/lib/libEGL.so.1"];
assert_eq!(check(DisplaySession::Wayland, &probe, |p| present.iter().any(|q| Path::new(q) == p)), Verdict::Ok);
// Not found and no cache to confirm it: inconclusive, so a warning rather than an exit.
let v = check(DisplaySession::Wayland, &probe, |_| false);
let Verdict::Missing { missing, conclusive } = v else { panic!("expected missing") };
assert!(!conclusive);
let msg = missing_message(DisplaySession::Wayland, &missing, Distro::Unknown, conclusive);
assert!(msg.contains("Starting anyway"), "{msg}");
assert!(msg.contains("apt install libwayland-client0 libxkbcommon0 libvulkan1"), "{msg}");
assert!(msg.contains("dnf install libwayland-client libxkbcommon vulkan-loader"), "{msg}");
}
#[test]
fn distro_detection() {
assert_eq!(distro_from_os_release("NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n"), Distro::Debian);
assert_eq!(distro_from_os_release("ID=linuxmint\nID_LIKE=\"ubuntu debian\"\n"), Distro::Debian);
assert_eq!(distro_from_os_release("ID=fedora\n"), Distro::Fedora);
assert_eq!(distro_from_os_release("ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n"), Distro::Fedora);
assert_eq!(distro_from_os_release("ID=endeavouros\nID_LIKE=arch\n"), Distro::Arch);
assert_eq!(distro_from_os_release("ID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n"), Distro::Suse);
assert_eq!(distro_from_os_release("ID=nixos\n"), Distro::Unknown);
assert_eq!(distro_from_os_release(""), Distro::Unknown);
assert_eq!(distro_from_os_release("=\n==\nID\n"), Distro::Unknown);
}
}
+20
View File
@@ -0,0 +1,20 @@
//! macOS title bar dragging. The window draws under a transparent title bar (`main.rs`), so AppKit
//! would otherwise drag the window from anywhere in that strip, menus included: pressing a menu
//! title and dragging down to an item moved the window instead. Turning AppKit's own dragging off
//! leaves only the app's drag region (the free gap between the menus and the controls,
//! `panels::title_bar`), which starts a drag explicitly (`ViewportCommand::StartDrag`, AppKit's
//! `performWindowDragWithEvent:`, which works on an unmovable window). All safe objc2 calls.
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
/// Stop AppKit dragging the app's windows by their title bar strip.
pub fn disable_native_title_drag() {
let Some(mtm) = MainThreadMarker::new() else {
log::warn!("title bar: not on the main thread; AppKit window dragging left on");
return;
};
for window in NSApplication::sharedApplication(mtm).windows().iter() {
window.setMovable(false);
}
}
+337 -36
View File
@@ -2,7 +2,11 @@
//!
//! Usage: `photocraft [--control <port>] [--control-token <64-hex> |
//! --control-token-file <path>] [--automation-read-root <dir>]
//! [--automation-write-root <dir>] [files…]`
//! [--automation-write-root <dir>] [--safe-gpu] [files…]`
//!
//! `--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
//! crashes inside the driver also falls back by itself next time (see `gpu_startup`).
//!
//! `--control <port>` (or `PHOTOCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server.
//! The first line must authenticate; subsequent request lines get reply lines.
@@ -13,12 +17,25 @@
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod app_dirs;
mod app_icon;
#[cfg(target_os = "macos")]
mod apple_events;
mod control_server;
mod crash_guard;
mod cursor;
mod gpu_startup;
#[cfg(target_os = "macos")]
mod mac_window;
// Pure logic is tested on every platform; only Linux runs the check.
#[cfg(any(target_os = "linux", test))]
mod linux_libs;
mod monitor_profile;
mod services;
// 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;
mod ui_state;
use photocraft_engine::Session;
use photocraft_ui_egui::PhotocraftApp;
@@ -26,32 +43,103 @@ use photocraft_ui_egui::PhotocraftApp;
/// Matches the `.desktop` file and hicolor icon name, so Wayland docks pick up the icon.
const APP_ID: &str = "ai.storyteller.photocraft";
/// Window, taskbar and (when running unbundled) Dock icon. macOS gets the padded 1024 px render
/// on Apple's icon grid; elsewhere the tighter 256 px hicolor render reads better at small sizes.
fn app_icon() -> egui::IconData {
#[cfg(target_os = "macos")]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/photocraft-1024.png");
#[cfg(not(target_os = "macos"))]
const PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.photocraft.png");
eframe::icon_data::from_png_bytes(PNG).unwrap_or_default()
/// The main window: 1440 × 900 (shrunk to fit the monitor, and maximized on the first frame
/// when it still doesn't fit, `work_area::fit_window`), centred on the main monitor. Without
/// `centered`, Windows cascades each new window from the top-left corner, so it opened at a
/// different offset every launch (#419). Wayland compositors place windows themselves.
fn native_options() -> eframe::NativeOptions {
eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_icon(app_icon::window_icon())
.with_app_id(APP_ID)
.with_title("PhotoCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([760.0, 480.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
centered: true,
// eframe saves native window geometry and egui panel/window sizes on exit.
// Keep that state beside preferences, including config overrides and portable mode.
persistence_path: services::config_dir().map(|dir| dir.join("ui.ron")),
..Default::default()
}
}
/// Parse a `--control` / `PHOTOCRAFT_CONTROL_PORT` value. An unparseable port is an error that
/// names the value (issue #701): silently running with no control server leaves a launcher or
/// agent unable to tell a typo from a successful grant.
fn parse_control_port(value: &str, source: &str) -> Result<u16, String> {
value.trim().parse().map_err(|_| format!("{source}: `{value}` is not a valid port (expected a number from 0 to 65535)"))
}
/// Process exit status for the collected `--control` / `PHOTOCRAFT_CONTROL_PORT` errors: `None`
/// when there are none, else 2 (a command-line usage error), never 0.
fn control_args_exit_code(errors: &[String]) -> Option<i32> {
if errors.is_empty() { None } else { Some(2) }
}
#[cfg(test)]
mod control_port_tests {
use super::{control_args_exit_code, parse_control_port};
#[test]
fn control_port_errors_exit_non_zero() {
assert_eq!(control_args_exit_code(&[]), None);
let errors = vec![parse_control_port("nope", "--control").unwrap_err()];
assert_eq!(control_args_exit_code(&errors), Some(2));
}
#[test]
fn control_port_accepts_valid_numbers() {
assert_eq!(parse_control_port("50494", "--control"), Ok(50494));
assert_eq!(parse_control_port(" 8080 ", "--control"), Ok(8080));
assert_eq!(parse_control_port("0", "PHOTOCRAFT_CONTROL_PORT"), Ok(0));
}
#[test]
fn control_port_rejects_unparseable_values_naming_them() {
// Out of range, a following flag eaten as the value, and an empty value all fail,
// naming the offending value in the message.
let err = parse_control_port("78787", "--control").unwrap_err();
assert!(err.contains("78787"), "{err}");
assert!(parse_control_port("--safe-gpu", "--control").is_err());
assert!(parse_control_port("", "--control").is_err());
}
}
fn main() -> eframe::Result {
crash_guard::install_hook();
let mut control_port: Option<u16> = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut control_port: Option<u16> = None;
let mut control_arg_errors: Vec<String> = Vec::new();
if let Some(value) = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().filter(|v| !v.trim().is_empty()) {
match parse_control_port(&value, "PHOTOCRAFT_CONTROL_PORT") {
Ok(port) => control_port = Some(port),
Err(error) => control_arg_errors.push(error),
}
}
let mut control_token = None;
let mut control_token_file = None;
let mut automation_read_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_READ_ROOT").map(std::path::PathBuf::from);
let mut automation_write_root = std::env::var_os("PHOTOCRAFT_AUTOMATION_WRITE_ROOT").map(std::path::PathBuf::from);
let mut files = Vec::new();
let mut safe_gpu = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
"--control" => match args.next() {
Some(value) => match parse_control_port(&value, "--control") {
Ok(port) => control_port = Some(port),
Err(error) => control_arg_errors.push(error),
},
None => control_arg_errors.push("--control: missing port value (expected `--control <port>`)".to_string()),
},
"--control-token" => control_token = args.next(),
"--control-token-file" => control_token_file = args.next().map(std::path::PathBuf::from),
"--automation-read-root" => automation_read_root = args.next().map(std::path::PathBuf::from),
"--automation-write-root" => automation_write_root = args.next().map(std::path::PathBuf::from),
"--safe-gpu" => safe_gpu = true,
"--version" => {
println!("photocraft {}", photocraft_engine::build_info::long_version());
return Ok(());
@@ -62,6 +150,24 @@ fn main() -> eframe::Result {
}
}
// A malformed control port must not silently drop the control server (issue #701): name the
// bad value and fail the launch, like `photocraft-cli serve --port` does for the same typo.
// Exit status 2 is the usual command-line usage error, so a launcher sees the failure.
if let Some(code) = control_args_exit_code(&control_arg_errors) {
for error in &control_arg_errors {
eprintln!("photocraft: {error}");
}
std::process::exit(code);
}
// winit and wgpu dlopen the windowing and GPU libraries, and some of those crates panic when
// one is missing (issue #201). Name the package to install and exit instead.
#[cfg(target_os = "linux")]
if let Err(message) = linux_libs::preflight() {
eprint!("{message}");
std::process::exit(1);
}
let control = if let Some(port) = control_port {
let (supplied, token_file) = photocraft_automation::security::token_inputs(control_token, control_token_file);
let token = match photocraft_automation::security::server_token(supplied.as_deref(), token_file.as_deref()) {
@@ -96,44 +202,150 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
// Read the main display's ICC profile while the window opens (colour-managed canvas).
// Pen tablet samples on macOS (AppKit event monitor, before winit sees each event) and X11
// (started once eframe says which display server it is on). The monitor lives until the event
// loop returns.
let stylus_feed = photocraft_ui_egui::stylus::StylusFeed::default();
#[cfg(target_os = "macos")]
let _tablet = tablet::install_macos(&stylus_feed);
// Read the displays' ICC profiles while the window opens (colour-managed canvas; `None`
// where the platform has no reader).
let monitor = monitor_profile::detect_async();
// Brush presets load in the background; the app attaches them when they arrive.
let presets = services::presets_dir().map(photocraft_engine::preset_store::open_dir_async);
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_icon(app_icon())
.with_app_id(APP_ID)
.with_title("PhotoCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([760.0, 480.0])
.with_drag_and_drop(true)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false),
..Default::default()
let mut options = native_options();
// eframe restores the saved window layout before our code runs; drop values that would crash it.
ui_state::sanitize(options.persistence_path.as_deref());
// Crash-safe GPU startup (#4): pick the backend (a marker left by a start that died in the
// driver moves to a safer one), and lock this start's marker until the first frames render.
let t_sentinel = std::time::Instant::now();
let os = gpu_startup::Os::current();
let (pref, mode) = gpu_startup::read_rendering_prefs(services::prefs_file().as_deref());
let (previous, sentinel) = match services::config_dir() {
Some(dir) => gpu_startup::Sentinel::begin(&dir),
None => (gpu_startup::Previous::Clean, None),
};
let env_backend = std::env::var("WGPU_BACKEND").ok();
let plan = gpu_startup::plan_with_mode(pref, mode, previous.crashed(), env_backend.as_deref(), safe_gpu, os);
if let Some(m) = previous.crashed() {
log::warn!("the previous start didn't finish (GPU backend {}, adapter {:?}); {}", m.backend, m.adapter, plan.reason.as_deref().unwrap_or(""));
}
let sentinel: gpu_startup::SharedSentinel = std::sync::Arc::new(std::sync::Mutex::new(sentinel));
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_mut() {
let backend = match &plan.env {
Some(v) => format!("env:{v}"),
None => plan.backend.name().to_string(),
};
let marker = gpu_startup::Marker { backend, version: photocraft_engine::build_info::long_version().to_string(), ..Default::default() };
if let Err(e) = s.write(marker) {
log::warn!("GPU startup marker: {e}");
}
}
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.
photocraft_ui_egui::gpu_canvas::use_adapter_limits(&mut options.wgpu_options.wgpu_setup);
eframe::run_native(
"Photocraft",
gpu_startup::configure(&mut options.wgpu_options.wgpu_setup, &plan, os, sentinel.clone(), gpu_note.clone());
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;
let app_created = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let created_in_callback = app_created.clone();
let started_sentinel = sentinel.clone();
let result = eframe::run_native(
"PhotoCraft",
options,
Box::new(move |cc| {
created_in_callback.store(true, std::sync::atomic::Ordering::Relaxed);
let automation = control.as_ref().map(|(_, _, workspace)| workspace.clone());
let mut services = services::native(automation);
services.preset_store = presets;
#[cfg(target_os = "linux")]
let display = tablet::DisplayKind::of(cc);
#[cfg(target_os = "linux")]
{
services.is_wayland = display == Some(tablet::DisplayKind::Wayland);
}
let mut app = PhotocraftApp::new(Session::new(), services);
app.integrated_titlebar = cfg!(target_os = "macos");
if let Ok(Some(icc)) = monitor.recv_timeout(std::time::Duration::from_secs(2)) {
app.session.color.monitor_profile = Some(std::sync::Arc::new(icc));
// Only the title bar's free gap drags the window, never the menus (mac_window.rs).
#[cfg(target_os = "macos")]
mac_window::disable_native_title_drag();
// Long commands and file opens run as background jobs with progress and Cancel (#210).
app.background_jobs = std::env::var_os("PHOTOCRAFT_INLINE_JOBS").is_none();
// Displays and their profiles (#569): wait briefly so the first frames already use
// the right profile; a slower reading is applied when it arrives, and the shell reads
// again when the displays may have changed.
if let Some(rx) = monitor {
app.services.read_displays = Some(std::sync::Arc::new(monitor_profile::detect_async));
match rx.recv_timeout(std::time::Duration::from_secs(2)) {
Ok(r) => photocraft_ui_egui::monitor_status::apply(&mut app, r),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => photocraft_ui_egui::monitor_status::pending(&mut app, rx),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
photocraft_ui_egui::monitor_status::apply(&mut app, Err("the display profile reader stopped without an answer".into()));
}
}
}
// Preferences › Performance › Use Graphics Processor.
if let Some(rs) = cc.wgpu_render_state.clone()
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
&& app.session.prefs().performance.use_gpu
{
app.set_wgpu(rs);
// Preferences › Performance › Use Graphics Processor (and the GPU backend: `cpu`
// composites on the CPU).
let info = &mut app.perf.gpu_info;
info.preference = pref.name().to_string();
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}")),
(reason, note) => reason.or(note),
};
info.canvas = "cpu".into();
if let Some(rs) = cc.wgpu_render_state.clone() {
app.perf.gpu_info.set_adapter(&rs.adapter.get_info());
let software_window = rs.adapter.get_info().device_type == eframe::wgpu::DeviceType::Cpu;
if software_window && mode != photocraft_engine::prefs::RenderingMode::Cpu {
app.perf.gpu_info.fallback = Some("No compatible hardware graphics adapter; using software graphics.".into());
}
if !software_window
&& plan.backend != photocraft_engine::prefs::GpuBackend::Cpu
&& std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none()
&& app.session.prefs().performance.effective_rendering_mode() != photocraft_engine::prefs::RenderingMode::Cpu
{
app.set_wgpu(rs);
} else {
// The window still draws with wgpu: record its errors instead of panicking.
let _ = photocraft_ui_egui::gpu_canvas::DeviceHealth::watch(&rs.device);
}
}
let fallback_reason = std::env::var("PHOTOCRAFT_GPU_STARTUP_FAILURE").ok().or_else(|| {
(app.perf.gpu_info.canvas == "cpu" && mode != photocraft_engine::prefs::RenderingMode::Cpu)
.then(|| app.perf.gpu_info.fallback.clone())
.flatten()
});
if let Some(reason) = fallback_reason {
photocraft_ui_egui::gpu_status::queue_fallback_notice(&mut app, &reason);
}
app.perf.span("gpuSentinel", sentinel_ms);
// Once the first frames rendered: clear the marker, and keep a crash fallback.
let remember_cpu =
std::env::var_os("PHOTOCRAFT_GPU_STARTUP_FAILURE").is_some() || (plan.remember && plan.backend == photocraft_engine::prefs::GpuBackend::Cpu);
let remember = plan.remember.then_some(plan.backend);
app.on_started(move |app| {
if let Some(s) = started_sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
s.finish();
}
if remember_cpu
&& let Err(error) = app.run(
"prefs.set",
serde_json::json!({"values": {
"performance.renderingMode": "cpu", "performance.useGpu": false
}}),
)
{
log::warn!("couldn't remember CPU recovery: {error}");
}
if let Some(b) = remember
&& app.session.prefs().performance.gpu_backend != b
&& let Err(e) = app.run("prefs.set", serde_json::json!({"path": "performance.gpuBackend", "value": b.name()}))
{
log::warn!("couldn't remember GPU backend {}: {e}", b.name());
}
});
if let Some((port, token, _)) = control {
let rx = control_server::start(port, token, cc.egui_ctx.clone());
app = app.with_control(rx);
@@ -142,9 +354,98 @@ fn main() -> eframe::Result {
{
app.services.os_events = Some(apple_events.connect(&cc.egui_ctx));
}
// Where file drags and drops are (winit 0.30 doesn't say).
app.services.cursor_pos = cursor::service(cc);
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor installed above
// and the X11 reader write into this feed.
app.stylus.feed = stylus_feed;
#[cfg(target_os = "linux")]
tablet::spawn_x11(&app.stylus.feed, display);
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
// Portable marker found but its data folder isn't writable (#228): say where settings went.
if let Some(w) = &app_dirs::current().warning {
photocraft_ui_egui::notices::post(&mut app, "Portable mode is off", vec![w.clone()], false, None);
}
Ok(Box::new(app))
}),
)
);
// Closed before the first frames rendered: not a driver crash. (A start that failed to
// create its device keeps the marker, so the next one tries a safer backend.)
if result.is_ok()
&& let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()
{
s.finish();
}
// Retry in a fresh process: winit event loops cannot be recreated reliably in-process.
// Only renderer initialization failures qualify; never restart after editing has begun.
if retry_cpu && !app_created.load(std::sync::atomic::Ordering::Relaxed) && matches!(&result, Err(eframe::Error::Wgpu(_))) {
let reason = result.as_ref().err().map(ToString::to_string).unwrap_or_default();
if let Some(s) = sentinel.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
s.finish();
}
if let Ok(exe) = std::env::current_exe() {
let launched = std::process::Command::new(exe)
.args(std::env::args_os().skip(1))
.arg("--safe-gpu")
.env_remove("WGPU_BACKEND")
.env("PHOTOCRAFT_GPU_STARTUP_FAILURE", &reason)
.spawn();
match launched {
Ok(_) => return Ok(()),
Err(error) => log::error!("could not start CPU compatibility mode: {error}"),
}
}
}
result
}
#[cfg(test)]
mod tests {
#[test]
fn window_and_panel_geometry_survive_a_restart() {
let options = super::native_options();
assert!(options.persist_window);
assert_eq!(options.persistence_path, super::services::config_dir().map(|dir| dir.join("ui.ron")));
#[derive(Default)]
struct Storage(std::collections::BTreeMap<String, String>);
impl eframe::Storage for Storage {
fn get_string(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
fn set_string(&mut self, key: &str, value: String) {
self.0.insert(key.into(), value);
}
fn remove_string(&mut self, key: &str) {
self.0.remove(key);
}
fn flush(&mut self) {}
}
let ctx = egui::Context::default();
let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))), ..Default::default() };
let mut output = ctx.run_ui(input(), |ui| {
egui::Panel::right("dock").exact_size(410.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
});
output.textures_delta.clear();
let mut storage = Storage::default();
ctx.memory(|memory| eframe::set_value(&mut storage, "egui", memory));
let restored = egui::Context::default();
restored.memory_mut(|memory| *memory = eframe::get_value(&storage, "egui").unwrap());
let mut output = restored.run_ui(input(), |ui| {
egui::Panel::right("dock").default_size(290.0).show(ui, |ui| ui.set_min_width(ui.available_width()));
});
output.textures_delta.clear();
let panel = egui::containers::panel::PanelState::load(&restored, egui::Id::new("dock")).unwrap();
assert_eq!(panel.size().x, 410.0);
}
#[test]
fn the_window_opens_centred_at_its_default_size() {
let o = super::native_options();
assert!(o.centered, "#419: centred, not cascaded from the top-left corner");
assert_eq!(o.viewport.inner_size, Some(egui::vec2(1440.0, 900.0)));
// eframe shrinks the start size to the monitor, so the centred position is on-screen.
assert_ne!(o.viewport.clamp_size_to_monitor_size, Some(false));
}
}
+193 -32
View File
@@ -1,45 +1,145 @@
//! The main display's ICC profile, for the colour-managed canvas (Edit › Color Settings ›
//! Monitor Profile = `auto`).
//! The displays and their ICC profiles, for the colour-managed canvas (Edit › Color Settings ›
//! Monitor Profile = `auto`; #569).
//!
//! macOS: `NSScreen.mainScreen.colorSpace.ICCProfileData`, a documented AppKit API, read through
//! `osascript` (AppleScriptObjC) so the app needs no `unsafe` FFI. The query runs on a background
//! thread at launch (about 0.4 s) and the profile is applied when it arrives. Other platforms
//! return `None` (sRGB, or the profile chosen in Color Settings).
//! macOS: every `NSScreen` with its `CGDirectDisplayID`, name, frame and
//! `colorSpace.ICCProfileData`, documented AppKit APIs read through `osascript` (AppKit via
//! AppleScriptObjC) so the app needs no `unsafe` FFI. A read takes about 0.4 s on a background
//! thread; the shell starts one at launch and again when the displays may have changed
//! (`photocraft_ui_egui::monitor_status`), and each window then uses the profile of the display
//! it is on. Other platforms report no displays (sRGB, or the profile chosen in Color Settings).
use std::sync::mpsc::Receiver;
/// Starts reading the main display's profile in the background.
pub fn detect_async() -> Receiver<Option<Vec<u8>>> {
use photocraft_engine::display_color::Display;
/// What the platform reader returns: the displays, or why there are none.
pub type Detection = Result<Vec<Display>, String>;
/// Starts reading the displays in the background; `None` when the platform has no reader.
pub fn detect_async() -> Option<Receiver<Detection>> {
if !cfg!(target_os = "macos") {
return None;
}
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(detect());
});
rx
Some(rx)
}
/// One line per screen, primary (menu-bar) screen first: id, frame (x, y, width, height in
/// points, Cocoa's bottom-left origin), the profile as base64, the profile's name in System
/// Settings and the display's name, tab-separated. Numbers as integers (text conversion of
/// reals follows the user's locale, e.g. "0,0").
#[cfg(target_os = "macos")]
fn detect() -> Option<Vec<u8>> {
let out = std::process::Command::new("/usr/bin/osascript")
.args([
"-e",
"use framework \"AppKit\"",
"-e",
"return ((current application's NSScreen's mainScreen()'s colorSpace()'s ICCProfileData()'s base64EncodedStringWithOptions:0) as text)",
])
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
const SCRIPT: &str = r#"use framework "AppKit"
set out to ""
repeat with s in (current application's NSScreen's screens() as list)
set f to s's frame()
set n to ((s's deviceDescription()'s objectForKey:"NSScreenNumber") as integer)
set icc to ""
set cs to s's colorSpace()
set csName to ""
if cs is not missing value then
set csName to ((cs's localizedName()) as text)
set d to cs's ICCProfileData()
if d is not missing value then set icc to ((d's base64EncodedStringWithOptions:0) as text)
end if
set out to out & n & tab & ((item 1 of item 1 of f) as integer) & tab & ((item 2 of item 1 of f) as integer) & tab & ((item 1 of item 2 of f) as integer) & tab & ((item 2 of item 2 of f) as integer) & tab & icc & tab & csName & tab & ((s's localizedName()) as text) & linefeed
end repeat
return out"#;
#[cfg(target_os = "macos")]
fn detect() -> Detection {
let mut cmd = std::process::Command::new("/usr/bin/osascript");
cmd.args(["-e", SCRIPT]);
parse_reply(&run_helper(&mut cmd, HELPER_TIMEOUT)?)
}
/// How long a reading may take before the helper is stopped (it normally takes about 0.4 s).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
const HELPER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Run the helper and return its standard output; a helper that fails, or runs longer than
/// `timeout`, is an error (and is killed and reaped), so a hung reading can't block later ones.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn run_helper(cmd: &mut std::process::Command, timeout: std::time::Duration) -> Result<String, String> {
use std::io::Read;
use std::process::Stdio;
let mut child =
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| format!("couldn't run the display profile reader: {e}"))?;
// Drain both pipes while waiting: a reply larger than the pipe buffer would otherwise block
// the helper until the deadline.
let drain = |p: Option<Box<dyn Read + Send>>| {
std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut p) = p {
let _ = p.read_to_end(&mut buf);
}
buf
})
};
let out = drain(child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
let err = drain(child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
let deadline = std::time::Instant::now() + timeout;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(20)),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("reading the display profiles took longer than {} s and was stopped", timeout.as_secs()));
}
Err(e) => {
let _ = child.kill();
let _ = child.wait();
return Err(format!("waiting for the display profile reader failed: {e}"));
}
}
};
let out = out.join().unwrap_or_default();
let err = err.join().unwrap_or_default();
if !status.success() {
let err = String::from_utf8_lossy(&err);
let first = err.lines().next().unwrap_or("").trim();
return Err(format!("reading the display profiles failed ({status}){}", if first.is_empty() { String::new() } else { format!(": {first}") }));
}
let bytes = base64_decode(std::str::from_utf8(&out.stdout).ok()?.trim())?;
// An ICC profile starts with its size and carries `acsp` at offset 36.
(bytes.len() >= 132 && bytes.get(36..40) == Some(b"acsp")).then_some(bytes)
Ok(String::from_utf8_lossy(&out).into_owned())
}
#[cfg(not(target_os = "macos"))]
fn detect() -> Option<Vec<u8>> {
None
fn detect() -> Detection {
Err("this platform doesn't report display profiles".into())
}
/// The helper's reply ([`SCRIPT`]) → displays, frames flipped to a top-left origin with y down
/// (the coordinates window positions use). Lines that don't parse are skipped.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn parse_reply(out: &str) -> Detection {
let mut displays = Vec::new();
let mut primary_height = None;
for line in out.lines().filter(|l| !l.trim().is_empty()) {
let f: Vec<&str> = line.splitn(8, '\t').collect();
let [id, x, y, w, h, b64, profile_name, name] = f.as_slice() else { continue };
let num = |s: &str| s.trim().parse::<f64>().ok().filter(|v| v.is_finite());
let (Some(id), Some(x), Some(y), Some(w), Some(h)) = (id.trim().parse::<u32>().ok(), num(x), num(y), num(w), num(h)) else { continue };
// Cocoa frames are relative to the primary screen's bottom-left corner, y up.
let top = *primary_height.get_or_insert(h) - (y + h);
let name = name.trim();
let name = if name.is_empty() { format!("Display {id}") } else { name.to_string() };
let b64 = b64.trim();
// An ICC profile starts with its size and carries `acsp` at offset 36; anything else is
// treated as no profile (the engine then says the display has none).
let icc = (!b64.is_empty()).then(|| base64_decode(b64)).flatten().filter(|b| b.len() >= 132 && b.get(36..40) == Some(b"acsp"));
let profile_name = Some(profile_name.trim().to_string()).filter(|n| !n.is_empty());
displays.push(Display { id, name, frame: [x, top, w, h], profile_name, icc: icc.map(std::sync::Arc::new) });
}
if displays.is_empty() {
let first = out.lines().next().unwrap_or("").trim();
return Err(if first.is_empty() { "the display reader returned nothing".into() } else { format!("couldn't read the display list: {first}") });
}
Ok(displays)
}
/// Standard base64 (RFC 4648, with padding) → bytes; `None` on any invalid character.
@@ -87,12 +187,73 @@ mod tests {
assert!(base64_decode("a$==").is_none());
}
/// The detected profile (when there is one) parses as an RGB profile.
#[test]
fn detected_profile_parses() {
if let Some(bytes) = detect() {
let p = photocraft_engine::color_cmds::profile_from_bytes(&std::sync::Arc::new(bytes)).expect("parses");
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}", p.description);
fn helper_replies() {
// Only the ICC signature is checked here; parsing is the engine's job.
let mut icc = vec![0u8; 132];
icc[36..40].copy_from_slice(b"acsp");
let b64 = base64_encode(&icc);
// The #569 setup: built-in display primary, the external one right of it and higher up.
let reply = format!(
"1\t0\t0\t1728\t1117\t{b64}\tColor LCD\tBuilt-in Retina Display\n4\t1728\t92\t3008\t1692\t{b64}\tApple_Display_26-04-13.icc\tROG PG32UQX\n"
);
let d = parse_reply(&reply).unwrap();
assert_eq!(d.len(), 2);
assert_eq!((d[0].id, d[0].name.as_str(), d[0].frame), (1, "Built-in Retina Display", [0.0, 0.0, 1728.0, 1117.0]));
assert_eq!((d[1].id, d[1].frame), (4, [1728.0, -667.0, 3008.0, 1692.0]));
assert_eq!(d[1].profile_name.as_deref(), Some("Apple_Display_26-04-13.icc"));
assert_eq!(d[1].icc.as_deref(), Some(&icc));
// No profile, a bad profile, no name: kept, without a profile or with a fallback name.
let d = parse_reply("2\t0\t0\t800\t600\t\t\t\n3\t800\t0\t800\t600\taGVsbG8=\tX\tY\n").unwrap();
assert_eq!((d[0].name.as_str(), d[0].icc.is_none(), d[0].profile_name.is_none()), ("Display 2", true, true));
assert!(d[1].icc.is_none());
// Garbage lines are skipped; nothing usable is an error.
assert_eq!(parse_reply("junk\n5\t0\t0\t10\t10\t\t\tZ").unwrap().len(), 1);
assert!(parse_reply("").is_err());
assert!(parse_reply("execution error: -1728").unwrap_err().contains("-1728"));
}
// Drives /bin/sleep and /bin/sh; the helper itself only runs on macOS.
#[cfg(unix)]
#[test]
fn a_hung_helper_is_stopped_and_big_replies_dont_block() {
let t0 = std::time::Instant::now();
let e = run_helper(std::process::Command::new("/bin/sleep").arg("30"), std::time::Duration::from_millis(300)).unwrap_err();
assert!(e.contains("took longer than"), "{e}");
assert!(t0.elapsed() < std::time::Duration::from_secs(5), "killed at the deadline");
// 1 MB on stdout (far beyond a pipe buffer) arrives whole.
let big = run_helper(std::process::Command::new("/bin/sh").args(["-c", "head -c 1048576 /dev/zero | tr '\\0' a"]), std::time::Duration::from_secs(20))
.unwrap();
assert_eq!(big.len(), 1 << 20);
let e = run_helper(std::process::Command::new("/bin/sh").args(["-c", "echo boom >&2; exit 3"]), std::time::Duration::from_secs(5)).unwrap_err();
assert!(e.contains("boom"), "{e}");
assert!(run_helper(&mut std::process::Command::new("/nonexistent/helper"), std::time::Duration::from_secs(1)).is_err());
}
fn base64_encode(b: &[u8]) -> String {
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::new();
for c in b.chunks(3) {
let n = c.iter().enumerate().fold(0u32, |n, (i, v)| n | u32::from(*v) << (16 - 8 * i));
for i in 0..=c.len() {
s.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
}
}
while !s.len().is_multiple_of(4) {
s.push('=');
}
s
}
/// On a Mac, every display is read, and the profiles parse as RGB profiles.
#[test]
fn detected_displays_parse() {
if let Ok(displays) = detect() {
for d in displays {
let Some(icc) = d.icc else { continue };
let p = photocraft_engine::color_cmds::profile_from_bytes(&icc).expect("parses");
assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}: {}", d.name, p.description);
}
}
}
}
+295 -65
View File
@@ -3,11 +3,10 @@
use photocraft_codecs::{ChannelLayout, EncodeOptions, Image, SampleType as CS};
use photocraft_color::{ColorMode, SampleType};
use photocraft_doc::{Document, Layer, Size};
use photocraft_format::Autosaver;
use photocraft_format::RecoveryStore;
use photocraft_geom::Rect;
use photocraft_ui_egui::Services;
use photocraft_ui_egui::{Recovered, Services};
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
@@ -15,15 +14,23 @@ use std::sync::Arc;
/// Everything File › Open reads: PhotoCraft and Photoshop documents, flat images, and Photoshop
/// brushes (.abr) and gradients (.grd), which go to the preset libraries.
const OPEN_EXTS: &[&str] = &[
"pcraft", "psd", "psb", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam", "pfm",
"dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
"pcraft", "psd", "psb", "psdt", "png", "jpg", "jpeg", "tif", "tiff", "webp", "gif", "bmp", "tga", "ico", "qoi", "exr", "hdr", "pbm", "pgm", "ppm", "pam",
"pfm", "heic", "heif", "hif", "dng", "cr2", "cr3", "nef", "nrw", "arw", "pef", "orf", "rw2", "raf", "abr", "grd",
];
/// File › Save As formats: (filter name, extensions). The filter matching the suggested name's
/// extension comes first, so a .pcraft document saves as .pcraft by default and everything else
/// keeps defaulting to Photoshop.
const SAVE_FILTERS: &[(&str, &[&str])] =
&[("Photoshop", &["psd", "psb"]), ("PhotoCraft", &["pcraft"]), ("PNG", &["png"]), ("JPEG", &["jpg"]), ("TIFF", &["tif"]), ("OpenEXR", &["exr"])];
const SAVE_FILTERS: &[(&str, &[&str])] = &[
("Photoshop", &["psd", "psb"]),
("PhotoCraft", &["pcraft"]),
("PNG", &["png"]),
("JPEG", &["jpg"]),
("WebP", &["webp"]),
("TIFF", &["tif"]),
("Targa", &["tga"]),
("OpenEXR", &["exr"]),
];
/// [`SAVE_FILTERS`] with the one for `suggested`'s extension first.
fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])> {
@@ -36,24 +43,14 @@ fn save_filters(suggested: &str) -> Vec<(&'static str, &'static [&'static str])>
v
}
/// Per-user settings directory: `PHOTOCRAFT_CONFIG_DIR`, else the platform convention
/// (macOS `~/Library/Application Support/Photocraft`, Windows `%APPDATA%\Photocraft`, Linux
/// `$XDG_CONFIG_HOME/photocraft` or `~/.config/photocraft`).
/// 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`].
pub fn config_dir() -> Option<PathBuf> {
if let Some(d) = std::env::var_os("PHOTOCRAFT_CONFIG_DIR") {
return Some(PathBuf::from(d));
}
let home = std::env::var_os("HOME").map(PathBuf::from);
if cfg!(target_os = "macos") {
return home.map(|h| h.join("Library/Application Support/Photocraft"));
}
if cfg!(windows) {
return std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("Photocraft"));
}
std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".config"))).map(|c| c.join("photocraft"))
crate::app_dirs::config_dir()
}
fn prefs_file() -> Option<PathBuf> {
pub fn prefs_file() -> Option<PathBuf> {
config_dir().map(|d| d.join("preferences.json"))
}
@@ -67,19 +64,71 @@ fn recovery_dir() -> Option<PathBuf> {
config_dir().map(|d| d.join("Recovery"))
}
/// Write `bytes` atomically (temp file + rename) so a crash never leaves half a preferences file.
/// Write `bytes` crash-safely (temp file beside the target, fsync, rename, directory fsync; see
/// [`photocraft_format::atomic`]). Every document write (Save, Save As, Export, Save for Web) and
/// the preferences go through here, so a failed or interrupted save never destroys the old file.
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
photocraft_format::atomic_write(path, bytes).map_err(|e| e.to_string())
}
type SharedRecovery = Rc<RefCell<Option<RecoveryStore>>>;
/// Run `f` on the recovery store (`Err` without a config directory). The service closures never
/// call each other, so the store is never borrowed twice.
fn with_store<R>(store: &SharedRecovery, f: impl FnOnce(&mut RecoveryStore) -> R) -> Result<R, String> {
let mut slot = store.try_borrow_mut().map_err(|_| "crash recovery is busy".to_string())?;
Ok(f(slot.as_mut().ok_or("no config directory")?))
}
/// Crash recovery: background incremental .pcraft autosaves into `dir` (`None`: no config
/// directory, so autosaves fail and nothing is recovered). Recovered documents keep their entries
/// until a newer autosave replaces them or they're saved or closed (see [`RecoveryStore`]).
fn recovery_services(dir: Option<PathBuf>) -> Services {
let store: SharedRecovery = Rc::new(RefCell::new(dir.map(RecoveryStore::new)));
let (s1, s2, s3) = (store.clone(), store.clone(), store.clone());
Services {
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
with_store(&s1, |s| s.autosave(doc, revision, path.map(str::to_string)))
})),
discard_autosave: Some(Box::new(move |id: u64| {
let _ = with_store(&s2, |s| s.discard(id));
})),
recover: Some(Box::new(move || {
let found = with_store(&s3, |s| s.recover()).unwrap_or_default();
found.into_iter().map(|(e, doc)| Recovered { key: e.info.key, path: e.info.original_path, doc }).collect()
})),
adopt_autosave: Some(Box::new(move |id: u64, key: &str| {
let _ = with_store(&store, |s| s.adopt(id, key));
})),
..Default::default()
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
}
/// The pixels to paste when the clipboard holds copied files rather than an image (Copy in
/// Files, Finder or Explorer puts paths on the clipboard, #338): the first file that decodes,
/// upright, as RGBA8. Files that aren't images are skipped after reading only their header.
fn image_from_files(paths: &[PathBuf]) -> Option<(u32, u32, Vec<u8>)> {
use std::io::Read;
paths.iter().find_map(|path| {
// text/uri-list lines end in CRLF (RFC 2483, and GTK writes them so), but arboard splits
// on LF only, so a path copied in GNOME Files arrives with a trailing '\r'.
let path = path.to_str().map_or_else(|| path.clone(), |s| PathBuf::from(s.trim_end_matches('\r')));
let mut head = Vec::with_capacity(256);
std::fs::File::open(&path).ok()?.take(256).read_to_end(&mut head).ok()?;
// TGA has no magic number and is only guessed from the header, so trust the extension
// too before reading what may be a large non-image file.
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match photocraft_codecs::detect(&head)? {
photocraft_codecs::Format::Tga if photocraft_codecs::from_extension(ext) != Some(photocraft_codecs::Format::Tga) => return None,
_ => {}
}
let img = photocraft_codecs::decode(&std::fs::read(&path).ok()?).ok()?;
Some((img.width(), img.height(), img.to_rgba8()))
})
}
pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) -> Services {
let savers: Rc<RefCell<HashMap<u64, Autosaver>>> = Rc::default();
let savers2 = savers.clone();
let clip: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
let automation_read = automation.clone().map(|workspace| {
Box::new(move |path: &str| {
let bytes = workspace.read(path).map_err(|error| error.to_string())?;
@@ -90,6 +139,8 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
let automation_write = automation.clone().map(|workspace| {
Box::new(move |path: &str, bytes: &[u8]| workspace.write(path, bytes).map_err(|error| error.to_string())) as photocraft_ui_egui::AutomationWriteFn
});
let step: fn(&str, &serde_json::Value) -> photocraft_engine::Result<()> = photocraft_automation::workspace::authorize_desktop_engine_step;
let automation_authorize = automation.is_some().then_some(step);
let automation_command = automation.map(|_| {
Box::new(|id: &str, params: &serde_json::Value| {
photocraft_automation::workspace::authorize_desktop_engine_command(id, params).map_err(|error| error.to_string())
@@ -104,13 +155,23 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
opts.tiff_layers = settings.tiff_layers;
opts.xmp = if settings.xmp_all { photocraft_io::XmpEmbed::All } else { photocraft_io::XmpEmbed::None };
crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string()))
})),
pick_open: Some(Box::new(|| {
let path = rfd::FileDialog::new().add_filter("All Formats", OPEN_EXTS).add_filter("PhotoCraft", &["pcraft"]).pick_file()?;
let bytes = std::fs::read(&path).ok()?;
// A read failure goes back to the app, which reports it like any other open failure.
let bytes = photocraft_format::read_file(&path).map_err(|e| e.to_string());
Some((path.to_string_lossy().to_string(), bytes))
})),
pick_open_paths: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("All Formats", OPEN_EXTS)
.add_filter("PhotoCraft", &["pcraft"])
.pick_files()
.map(|paths| paths.into_iter().map(|path| path.to_string_lossy().into_owned()).collect())
})),
pick_save: Some(Box::new(|suggested: &str| {
let p = std::path::Path::new(suggested);
let mut d = rfd::FileDialog::new();
@@ -122,51 +183,47 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
}
Some(d.save_file()?.to_string_lossy().to_string())
})),
write: Some(Box::new(|path: &str, bytes: &[u8]| std::fs::write(path, bytes).map_err(|e| e.to_string()))),
write: Some(Box::new(|path: &str, bytes: &[u8]| write_atomic(Path::new(path), bytes))),
automation_read,
automation_write,
automation_command,
automation_authorize,
encode_png: Some(Box::new(|w, h, rgba| {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, rgba.to_vec()).map_err(|e| e.to_string())?;
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).map_err(|e| e.to_string())
})),
inbox: None,
open_url: Some(Box::new(|url: &str| open::that(url).map_err(|e| e.to_string()))),
clipboard_set_image: Some(Box::new(|w: u32, h: u32, px: &[u8]| {
let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
})),
clipboard_get_image: Some(Box::new(|| {
let img = arboard::Clipboard::new().ok()?.get_image().ok()?;
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
})),
clipboard_set_image: Some({
let clip = clip.clone();
Box::new(move |w: u32, h: u32, px: &[u8]| {
let mut slot = clip.try_borrow_mut().map_err(|_| "clipboard is busy".to_string())?;
let cb = match slot.as_mut() {
Some(c) => c,
None => slot.insert(arboard::Clipboard::new().map_err(|e| e.to_string())?),
};
cb.set_image(arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(px) }).map_err(|e| e.to_string())
})
}),
clipboard_get_image: Some({
let clip = clip.clone();
Box::new(move || {
let mut slot = clip.try_borrow_mut().ok()?;
let cb = match slot.as_mut() {
Some(c) => c,
None => slot.insert(arboard::Clipboard::new().ok()?),
};
// Copied files first: Finder also puts the file's icon on the clipboard as an
// image, which would otherwise paste instead of the file.
if let Some(img) = cb.get().file_list().ok().and_then(|paths| image_from_files(&paths)) {
return Some(img);
}
let img = cb.get_image().ok()?;
Some((img.width as u32, img.height as u32, img.bytes.into_owned()))
})
}),
load_prefs: Some(Box::new(|| std::fs::read_to_string(prefs_file()?).ok())),
save_prefs: Some(Box::new(|text: &str| write_atomic(&prefs_file().ok_or("no config directory")?, text.as_bytes()))),
// Crash recovery: background incremental .pcraft saves into the recovery directory.
autosave: Some(Box::new(move |doc: &Arc<Document>, revision: u64, path: Option<&str>| {
let dir = recovery_dir().ok_or("no config directory")?;
let mut map = savers.borrow_mut();
let saver = map.entry(doc.id.0).or_insert_with(|| Autosaver::new(&dir, &format!("doc-{}", doc.id.0)));
saver.request(doc.clone(), revision, path.map(str::to_string), Default::default());
Ok(())
})),
discard_autosave: Some(Box::new(move |id: u64| {
if let Some(s) = savers2.borrow_mut().remove(&id) {
let _ = s.discard();
}
})),
recover: Some(Box::new(|| {
let Some(dir) = recovery_dir() else { return Vec::new() };
let mut out = Vec::new();
for entry in photocraft_format::list_recovery(&dir) {
if let Ok(doc) = photocraft_format::recover(&entry) {
out.push((entry.info.original_path.clone(), doc));
}
// Recovered documents autosave again under their new ids.
let _ = photocraft_format::discard_recovery(&dir, &entry);
}
out
})),
append_text: Some(Box::new(|path: &str, text: &str| {
use std::io::Write;
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| e.to_string())?;
@@ -176,6 +233,8 @@ pub fn native(automation: Option<photocraft_automation::AuthorizedWorkspace>) ->
os_events: None,
// Set by main, which starts loading the store before the window opens.
preset_store: None,
is_wayland: false,
..recovery_services(recovery_dir())
}
}
@@ -240,3 +299,174 @@ pub fn export_flat(doc: &Document, path: &str) -> Result<Vec<u8>, String> {
};
photocraft_codecs::encode(&img, format, &EncodeOptions::default()).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_engine::Session;
use photocraft_format::list_recovery;
use photocraft_ui_egui::{PhotocraftApp, prefs_ui};
use serde_json::json;
/// "Export As" formats must lead with their own filter, or the save panel appends the first one's extension (`photo.webp.psd`).
#[test]
fn save_filters_lead_with_every_export_format() {
for ext in ["png", "jpg", "webp", "tif", "tga"] {
assert!(save_filters(&format!("photo.{ext}"))[0].1.contains(&ext), "{ext}");
}
}
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
/// One app launch with crash recovery in `dir` (dropping it is the crash: background writes
/// already queued finish, nothing else runs).
fn launch(dir: &Path) -> PhotocraftApp {
PhotocraftApp::new(Session::new(), recovery_services(Some(dir.to_path_buf())))
}
/// The first pixel of each open document, and whether it's unsaved.
fn open_docs(app: &PhotocraftApp) -> Vec<([f32; 4], bool)> {
app.session.documents().iter().map(|d| (photocraft_compose::flatten(&d.doc).px.first().copied().unwrap_or_default(), d.is_dirty())).collect()
}
fn index_of(app: &PhotocraftApp, color: [f32; 4]) -> usize {
open_docs(app).iter().position(|(c, _)| *c == color).unwrap()
}
fn new_doc(app: &mut PhotocraftApp, color: &str) {
app.run("file.new", json!({"width": 4, "height": 4})).unwrap();
app.run("edit.fill", json!({"color": color})).unwrap();
}
fn autosave(app: &mut PhotocraftApp, ctx: &egui::Context) {
prefs_ui::autosave_now(app);
prefs_ui::tick(app, ctx);
}
#[test]
fn recovered_documents_survive_a_second_crash_until_saved_or_closed() {
let dir = std::env::temp_dir().join(format!("photocraft-recovery-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let ctx = egui::Context::default();
// Launch 1: two unsaved documents are autosaved, then the app crashes.
{
let mut app = launch(&dir);
new_doc(&mut app, "#ff0000");
new_doc(&mut app, "#0000ff");
autosave(&mut app, &ctx);
}
assert_eq!(list_recovery(&dir).len(), 2);
// Launch 2 recovers both and crashes again before its first autosave (#444).
{
let mut app = launch(&dir);
let mut docs = open_docs(&app);
docs.sort_by(|a, b| a.0[0].total_cmp(&b.0[0]));
assert_eq!(docs, [(BLUE, true), (RED, true)], "recovered and still unsaved");
prefs_ui::tick(&mut app, &ctx);
}
assert_eq!(list_recovery(&dir).len(), 2, "recovering keeps the entries on disk");
// Launch 3 has both again; autosaving unchanged documents adds nothing, a new document
// (whose id may repeat one from an earlier launch) gets an entry of its own.
{
let mut app = launch(&dir);
assert_eq!(app.session.documents().len(), 2);
autosave(&mut app, &ctx);
new_doc(&mut app, "#00ff00");
autosave(&mut app, &ctx);
}
assert_eq!(list_recovery(&dir).len(), 3);
// Launch 4: closing and saving recovered documents removes their entries, no duplicates.
let mut app = launch(&dir);
assert_eq!(app.session.documents().len(), 3);
let red = index_of(&app, RED);
app.run("file.close", json!({"document": red})).unwrap();
// Saving marks the revision saved (the file write itself is the save service's job).
assert!(app.session.set_active(index_of(&app, BLUE)));
let st = app.session.active_mut().unwrap();
st.saved_revision = st.revision;
prefs_ui::tick(&mut app, &ctx);
let left = list_recovery(&dir);
assert_eq!(left.len(), 1);
assert_eq!(photocraft_compose::flatten(&photocraft_format::recover(&left[0]).unwrap()).px.first().copied(), Some(GREEN));
let green = index_of(&app, GREEN);
app.run("file.close", json!({"document": green})).unwrap();
prefs_ui::tick(&mut app, &ctx);
assert!(list_recovery(&dir).is_empty());
drop(app);
let _ = std::fs::remove_dir_all(&dir);
}
use std::sync::atomic::{AtomicUsize, Ordering};
fn temp(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("photocraft-services-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn red_png(w: u32, h: u32) -> Vec<u8> {
let img = Image::from_u8(w, h, ChannelLayout::Rgba, [255u8, 0, 0, 255].repeat((w * h) as usize)).unwrap();
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &EncodeOptions::default()).unwrap()
}
/// A 1×1 uncompressed 32-bit TGA (TGA has no magic number).
fn tga_1x1() -> Vec<u8> {
let mut b = vec![0u8, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 8];
b.extend_from_slice(&[0, 0, 255, 255]); // BGRA red
b
}
#[test]
fn copied_image_file_pastes_its_pixels() {
let dir = temp("png");
let png = dir.join("red.png");
std::fs::write(&png, red_png(3, 2)).unwrap();
let (w, h, px) = image_from_files(&[png]).unwrap();
assert_eq!((w, h), (3, 2));
assert_eq!(px, [255u8, 0, 0, 255].repeat(6));
}
#[test]
fn crlf_from_a_uri_list_is_ignored() {
// arboard splits text/uri-list on LF, so GNOME Files' CRLF list leaves a '\r' behind.
let dir = temp("crlf");
let png = dir.join("red image.png");
std::fs::write(&png, red_png(2, 2)).unwrap();
let with_cr = PathBuf::from(format!("{}\r", png.display()));
assert_eq!(image_from_files(&[with_cr]).map(|(w, h, _)| (w, h)), Some((2, 2)));
}
#[test]
fn non_images_are_skipped_for_the_first_image() {
let dir = temp("mixed");
let (txt, png) = (dir.join("notes.txt"), dir.join("red.png"));
std::fs::write(&txt, b"not an image").unwrap();
std::fs::write(&png, red_png(4, 1)).unwrap();
let missing = dir.join("gone.png");
assert_eq!(image_from_files(&[missing.clone(), txt.clone(), dir.clone(), png]).map(|(w, h, _)| (w, h)), Some((4, 1)));
assert!(image_from_files(&[missing, txt, dir]).is_none());
assert!(image_from_files(&[]).is_none());
}
#[test]
fn a_damaged_image_is_skipped_not_a_panic() {
let dir = temp("damaged");
let bad = dir.join("cut.png");
std::fs::write(&bad, &red_png(8, 8)[..20]).unwrap();
assert!(image_from_files(&[bad]).is_none());
}
#[test]
fn tga_is_trusted_only_with_its_extension() {
let dir = temp("tga");
let (tga, bin) = (dir.join("red.tga"), dir.join("red.bin"));
std::fs::write(&tga, tga_1x1()).unwrap();
std::fs::write(&bin, tga_1x1()).unwrap();
assert_eq!(image_from_files(&[tga]), Some((1, 1, vec![255, 0, 0, 255])));
assert!(image_from_files(&[bin]).is_none());
}
}
+201
View File
@@ -0,0 +1,201 @@
//! Pen tablet pressure, tilt, rotation and eraser end on macOS and Linux X11 (issue #79).
//!
//! winit 0.30 drops tablet data on both, so `photocraft-tablet` reads it beside winit (an AppKit
//! local event monitor; XInput2 raw events on a second X connection) and this module writes each
//! sample into the UI's [`StylusFeed`], where the canvas reads it exactly like the web runner's
//! Pointer Events and automation's simulated pen. Windows needs nothing here: winit forwards
//! `WM_POINTER` pressure as touch force. Wayland has no tablet input yet (see the crate docs).
use photocraft_tablet::Sample;
use photocraft_ui_egui::stylus::{PenSample, StylusFeed};
/// A tablet sample as the UI's pen sample.
pub fn pen_sample(s: Sample) -> PenSample {
PenSample { pressure: s.pressure, tilt_x: s.tilt_x, tilt_y: s.tilt_y, rotation: s.rotation, eraser: s.eraser }.sanitized()
}
/// Writes tablet samples into `feed` (`None` = a mouse).
pub fn sink(feed: StylusFeed) -> impl Fn(Option<Sample>) + Send + 'static {
move |s| feed.set(s.map(pen_sample))
}
/// Install the AppKit tablet monitor (main thread, before the event loop). Keep the result until
/// the event loop returns. On failure this logs why: pens then paint like a mouse.
#[cfg(target_os = "macos")]
pub fn install_macos(feed: &StylusFeed) -> Option<photocraft_tablet::macos::Monitor> {
photocraft_tablet::macos::Monitor::install(sink(feed.clone())).map_err(|e| log::warn!("{e}")).ok()
}
/// Start the XInput2 reader when eframe runs on X11. On 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.
#[cfg(target_os = "linux")]
pub fn spawn_x11(feed: &StylusFeed, display: Option<DisplayKind>) {
if display != Some(DisplayKind::X11) {
log::info!("tablet pressure: not available on this display server ({display:?}); Wayland is a follow-up to #79");
return;
}
if let Err(e) = photocraft_tablet::x11::spawn(None, sink(feed.clone())) {
log::warn!("{e}");
}
}
/// The windowing system eframe ended up on.
#[cfg(target_os = "linux")]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DisplayKind {
X11,
Wayland,
Other,
}
#[cfg(target_os = "linux")]
impl DisplayKind {
/// From the creation context's display handle.
pub fn of(cc: &eframe::CreationContext<'_>) -> Option<Self> {
use eframe::wgpu::rwh::{HasDisplayHandle, RawDisplayHandle};
Some(match cc.display_handle().ok()?.as_raw() {
RawDisplayHandle::Xlib(_) | RawDisplayHandle::Xcb(_) => DisplayKind::X11,
RawDisplayHandle::Wayland(_) => DisplayKind::Wayland,
_ => DisplayKind::Other,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_tablet::{Update, appkit, xi};
use photocraft_ui_egui::PhotocraftApp;
use photocraft_ui_egui::canvas::{ToolEvent, tool_event};
use photocraft_ui_egui::state::Tool;
use serde_json::{Value, json};
#[test]
fn samples_map_and_sanitize() {
let p = pen_sample(Sample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
assert_eq!(p, PenSample { pressure: 0.25, tilt_x: 30.0, tilt_y: -10.0, rotation: 90.0, eraser: true });
let p = pen_sample(Sample { pressure: f32::NAN, tilt_x: 500.0, tilt_y: f32::NEG_INFINITY, rotation: -45.0, eraser: false });
assert_eq!(p, PenSample { pressure: 1.0, tilt_x: 90.0, tilt_y: 0.0, rotation: 315.0, eraser: false });
let feed = StylusFeed::default();
let f = sink(feed.clone());
f(Some(Sample { pressure: 0.5, ..Default::default() }));
assert_eq!(feed.get().map(|p| p.pressure), Some(0.5));
f(None);
assert_eq!(feed.get(), None);
}
/// The platform path end to end, minus the OS: raw AppKit / XInput2 values → the crate's
/// mapping → this module's sink → the stylus feed → the canvas's tool events → `paint.stroke`.
/// A light-to-heavy pen stroke must paint thin and faint at the light end and wide and opaque
/// at the heavy end (Size and Opacity on Pen Pressure); a mouse paints full strength.
fn pressure_stroke(mut next: impl FnMut(f32) -> Option<Sample>) -> (f64, f64, f64, f64) {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let feed = app.stylus.feed.clone();
let sink = sink(feed);
app.session.execute("file.new", json!({"width": 240, "height": 80, "background": "white"})).unwrap();
app.session.execute("tools.setColors", json!({"foreground": [0.0, 0.0, 0.0, 1.0]})).unwrap();
let brush = json!({"size": 30, "hardness": 1.0, "spacing": 0.05, "pressureSize": true, "pressureOpacity": true, "smoothing": {"amount": 0}});
app.session.execute("tools.setBrush", json!({ "brush": brush })).unwrap();
app.ui.tool = Tool::Brush;
let m = egui::Modifiers::NONE;
let n = 20;
for i in 0..=n {
let t = i as f32 / n as f32;
sink(next(0.1 + 0.9 * t));
let (x, y) = (20.0 + 200.0 * f64::from(t), 40.0);
let pressure = app.stylus.pressure();
let ev = if i == 0 { ToolEvent::Down { x, y, pressure } } else { ToolEvent::Move { x, y, pressure } };
tool_event(&mut app, ev, m);
}
tool_event(&mut app, ToolEvent::Up { x: 220.0, y: 40.0 }, m);
sink(None);
// Darkness (1 - luma) along x = 50 (light end) and x = 200 (heavy end), and the painted
// half-width there.
let mut dark = |x: i32, y: i32| -> f64 {
let px: Value = app.session.execute("document.pixel", json!({"x": x, "y": y})).unwrap();
1.0 - px[0].as_f64().unwrap()
};
let (light, heavy) = (dark(50, 40), dark(200, 40));
let light_w = (0..40).take_while(|d| dark(50, 40 + d) > 0.02).count() as f64;
let heavy_w = (0..40).take_while(|d| dark(200, 40 + d) > 0.02).count() as f64;
(light, light_w, heavy, heavy_w)
}
#[test]
fn appkit_tablet_pressure_drives_stroke_width_and_opacity() {
let mut st = appkit::State::default();
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| {
let e =
appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::TABLET_POINT, pressure: p, ..Default::default() };
match st.handle(&e) {
Update::Set(s) => s,
Update::Keep => None,
}
});
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
assert!(heavy_dark > 0.9, "full pressure paints (nearly) opaque: {heavy_dark}");
// A mouse (non-tablet subtype) through the same path: full size and opacity throughout.
let mut st = appkit::State::default();
let (m_dark, m_w, _, _) = pressure_stroke(|p| {
let e = appkit::RawEvent { kind: appkit::event_type::LEFT_MOUSE_DRAGGED, subtype: appkit::subtype::MOUSE, pressure: p, ..Default::default() };
match st.handle(&e) {
Update::Set(s) => s,
Update::Keep => None,
}
});
assert!(m_dark > 0.9 && m_w >= heavy_w - 1.0, "mouse = pressure 1: {m_dark}, {m_w}");
}
#[test]
fn xinput_tablet_pressure_drives_stroke_width_and_opacity() {
let mut st = xi::State::default();
st.set_devices([xi::Device::new(9, "Wacom Intuos Pen stylus", [(2, "Abs Pressure".to_string(), 0.0, 65535.0)])]);
let (light_dark, light_w, heavy_dark, heavy_w) = pressure_stroke(|p| match st.handle(9, [(2, f64::from(p) * 65535.0)]) {
Update::Set(s) => s,
Update::Keep => None,
});
assert!(heavy_w > light_w * 2.0, "width responds: {light_w} → {heavy_w}");
assert!(heavy_dark > light_dark + 0.3, "opacity responds: {light_dark} → {heavy_dark}");
}
#[test]
fn use_tablet_pressure_off_paints_like_a_mouse() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.session.execute("prefs.set", json!({"path": "tools.useTabletPressure", "value": false})).unwrap();
// The canvas copies the preference each frame; do what it does.
app.stylus.use_pressure = app.session.prefs().tools.use_tablet_pressure;
sink(app.stylus.feed.clone())(Some(Sample { pressure: 0.2, tilt_x: 40.0, ..Default::default() }));
assert_eq!(app.stylus.pressure(), 1.0);
assert_eq!(app.stylus.sample(), None);
}
#[test]
fn eraser_end_switches_to_the_eraser_and_back() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let sink = sink(app.stylus.feed.clone());
let mut st = appkit::State::default();
let mut send = |e: appkit::RawEvent| {
if let Update::Set(s) = st.handle(&e) {
sink(s);
}
};
let prox = |device, entering| appkit::RawEvent { kind: appkit::event_type::TABLET_PROXIMITY, device, entering, ..Default::default() };
app.ui.tool = Tool::Brush;
send(prox(appkit::device::PEN, true));
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Brush, "the tip changes nothing");
send(prox(appkit::device::PEN, false));
send(prox(appkit::device::ERASER, true));
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Eraser);
// Mouse moves in between change nothing; the tip coming back restores the Brush.
send(appkit::RawEvent { kind: appkit::event_type::MOUSE_MOVED, ..Default::default() });
assert!(!photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
send(prox(appkit::device::PEN, true));
assert!(photocraft_ui_egui::stylus::Stylus::sync_eraser_tool(&mut app));
assert_eq!(app.ui.tool, Tool::Brush);
}
}
+133
View File
@@ -0,0 +1,133 @@
//! The saved window geometry and egui layout (`ui.ron`, written by eframe's persistence).
//!
//! eframe restores this state while it creates the window, before PhotoCraft's code runs, and
//! some values it does not check abort startup: a zoom factor of zero, NaN or 1e30 fails a
//! `dpi` assertion, and a NaN or huge window size fails surface creation. A truncated or
//! hand-edited file would then crash every launch. [`sanitize`] runs first and removes a file
//! holding such values (the window and panels open at their defaults; nothing else is lost).
//! A file that isn't RON at all is left alone: eframe already ignores it.
use std::path::Path;
/// Window positions, sizes and panel rects beyond this many points or pixels are corrupt.
const MAX_COORD: f64 = 1.0e6;
/// egui's zoom factor is the UI scale over the display scale (75% on a 2× display is 0.375,
/// 300% on a 1× display is 3).
const ZOOM: std::ops::RangeInclusive<f64> = 0.1..=10.0;
/// Remove `path` if it holds state that would crash startup. Never fails: errors are logged.
pub fn sanitize(path: Option<&Path>) {
let Some(path) = path else { return };
let Ok(text) = std::fs::read_to_string(path) else { return };
if let Err(why) = check(&text) {
log::warn!("ignoring saved window layout {}: {why}", path.display());
if let Err(e) = std::fs::remove_file(path) {
log::warn!("could not remove {}: {e}", path.display());
}
}
}
/// `Err` names the first value that would crash startup.
fn check(text: &str) -> Result<(), String> {
// eframe stores a map of key → RON string; a file that doesn't parse is ignored by eframe.
let Ok(kv) = ron::from_str::<std::collections::HashMap<String, String>>(text) else { return Ok(()) };
for (key, value) in &kv {
// eframe also skips an entry that doesn't parse.
let Ok(v) = ron::from_str::<ron::Value>(value) else { continue };
floats_sane(&v, 0).map_err(|e| format!("{key}: {e}"))?;
let zoom = (key == "egui").then(|| field(&v, "options").and_then(|o| field(o, "zoom_factor")).and_then(as_f64)).flatten();
if let Some(z) = zoom.filter(|z| !ZOOM.contains(z)) {
return Err(format!("egui: zoom factor {z}"));
}
}
Ok(())
}
/// Every float finite and within [`MAX_COORD`]; depth-limited (the file is untrusted).
fn floats_sane(v: &ron::Value, depth: usize) -> Result<(), String> {
if depth > 64 {
return Err("nested too deeply".into());
}
match v {
ron::Value::Number(_) => match as_f64(v) {
Some(f) if !f.is_finite() || f.abs() > MAX_COORD => Err(format!("value {f}")),
_ => Ok(()),
},
ron::Value::Option(Some(inner)) => floats_sane(inner, depth + 1),
ron::Value::Seq(items) => items.iter().try_for_each(|i| floats_sane(i, depth + 1)),
ron::Value::Map(m) => m.iter().try_for_each(|(k, v)| floats_sane(k, depth + 1).and_then(|()| floats_sane(v, depth + 1))),
_ => Ok(()),
}
}
/// A float (integers such as egui's hashed ids are not coordinates).
fn as_f64(v: &ron::Value) -> Option<f64> {
match v {
ron::Value::Number(ron::value::Number::F32(f)) => Some(f64::from(f.get())),
ron::Value::Number(ron::value::Number::F64(f)) => Some(f.get()),
_ => None,
}
}
fn field<'a>(v: &'a ron::Value, name: &str) -> Option<&'a ron::Value> {
match v {
ron::Value::Map(m) => m.get(&ron::Value::String(name.into())),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::check;
fn file(window: &str, zoom: &str) -> String {
let egui = format!("(options:(zoom_factor:{zoom},max_passes:2),data:([(18446744073709551615,(ron:\"(x:1.0)\"))]))");
format!("{{\"window\":{window:?},\"egui\":{egui:?}}}")
}
const WINDOW: &str = "(inner_position_pixels:Some((x:616.0,y:430.0)),fullscreen:false,inner_size_points:Some((x:1100.0,y:760.0)))";
#[test]
fn a_saved_layout_is_kept() {
assert_eq!(check(&file(WINDOW, "1.0")), Ok(()));
assert_eq!(check(&file(WINDOW, "0.375")), Ok(()));
}
#[test]
fn values_that_abort_startup_are_rejected() {
// Each of these aborted the app at launch before the check (dpi / wgpu assertions).
for zoom in ["0.0", "NaN", "1e30", "-1.0"] {
assert!(check(&file(WINDOW, zoom)).is_err(), "zoom {zoom}");
}
for size in ["(x:NaN,y:760.0)", "(x:1100.0,y:1e30)", "(x:inf,y:1.0)"] {
let w = WINDOW.replace("(x:1100.0,y:760.0)", size);
assert!(check(&file(&w, "1.0")).is_err(), "size {size}");
}
}
#[test]
fn unreadable_files_are_left_to_eframe() {
// eframe ignores files and entries it can't parse, so they can't crash it.
for text in ["", "not ron {{{", "{\"window\":\"(((\"}", "[1,2,3]"] {
assert_eq!(check(text), Ok(()), "{text:?}");
}
let deep = format!("{{\"egui\":{:?}}}", "[".repeat(500) + &"]".repeat(500));
let _ = check(&deep);
}
#[test]
fn sanitize_removes_only_a_bad_file() {
let dir = std::env::temp_dir().join(format!("photocraft-ui-state-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let good = dir.join("good.ron");
let bad = dir.join("bad.ron");
std::fs::write(&good, file(WINDOW, "1.0")).unwrap();
std::fs::write(&bad, file(WINDOW, "0.0")).unwrap();
super::sanitize(Some(&good));
super::sanitize(Some(&bad));
super::sanitize(Some(&dir.join("missing.ron")));
super::sanitize(None);
assert!(good.exists() && !bad.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}
+4 -1
View File
@@ -36,7 +36,10 @@ craftrules at `assets/app-icons/photocraft/source.png`. License: see `LICENSE.tx
- `hicolor/<size>/apps/ai.storyteller.photocraft.png` (16–512) and `hicolor/scalable/...svg`:
Linux icon theme.
The app also sets the window icon and Wayland app ID at runtime (`apps/photocraft/src/main.rs`).
The app also sets the window icon and Wayland app ID at runtime (`apps/photocraft/src/app_icon.rs`,
`main.rs`). On Windows the taskbar shows the icon of the Start Menu shortcut that launches the
exe, so the MSI's `<Icon Id>` keeps the `.exe` extension (ICE50); `app_icon.rs` documents the
details and tests the `.ico` sizes and the WiX icon references.
## Regenerate
+14
View File
@@ -0,0 +1,14 @@
<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="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 377 B

+3 -3
View File
@@ -13,7 +13,7 @@
| `mcp` | Start the MCP server on stdio, optionally bridged to the desktop app |
| `serve` | Start the JSON-lines headless server on stdio or loopback TCP |
Use `photocraft-cli --help` as the executable source of truth. A typical bounded command run is:
Use `photocraft-cli --help` (or `photocraft-cli <subcommand> --help`) as the executable source of truth. A typical bounded command run is:
```sh
photocraft-cli run input.psd \
@@ -21,6 +21,6 @@ photocraft-cli run input.psd \
--out output.png
```
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. Run untrusted action files only in an appropriately isolated account or environment.
Paths supplied to the current CLI are ordinary operating-system paths. The CLI does not restrict them to a workspace root. Batch output creates the requested output directory, and save operations may create parent directories. `batch` refuses an output directory that is its input directory (the results would replace the originals) unless `--in-place` is given. Run untrusted action files only in an appropriately isolated account or environment.
CLI parsing reports malformed flags and JSON as errors, but it is not a security policy layer. Engine commands remain responsible for validating their own parameters.
CLI parsing reports malformed flags and JSON as errors, and a flag the subcommand doesn't take (a typo such as `--fromat`) is a usage error (exit status 2) rather than being ignored. It is not a security policy layer. Engine commands remain responsible for validating their own parameters.
+1 -1
View File
@@ -11,7 +11,7 @@ Automation requests are untrusted input with potential effects beyond the active
| MCP capability scopes | **Partial:** filesystem read/write capabilities only; general tool scopes are absent |
| Allowed read/write roots | **Implemented:** separate launch-time roots, with absent authority failing closed |
| Symlink-safe capability filesystem | **Implemented:** relative operations use held directory capabilities and reject link escapes |
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit; no explicit JSON-depth policy |
| Request-byte and JSON-depth limits | **Partial:** 1 MiB request-line limit on TCP and headless JSON-lines stdio (MCP stdio input is not byte-capped); no explicit JSON-depth policy |
| Batch-step limit | **Implemented:** 256 steps for headless and MCP batches |
| Reply-size limit | **Implemented:** 8 MiB encoded JSON/MCP result; aggregate batch reply budget |
| Headless preview budgets | **Implemented:** 2048-pixel requested edge, 67,108,864 source pixels, 5 MiB encoded PNG |
+2
View File
@@ -22,6 +22,8 @@ Default `LoadOptions` apply these maximums:
| One decompressed blob | 1 GiB |
| Total decompressed tiles and blobs | 16 GiB |
Layer groups may nest at most 100 levels (`MAX_GROUP_DEPTH`). Saving a deeper document fails with an error instead of writing a bundle that can't be opened, and a manifest whose JSON nesting goes beyond what that depth needs is rejected before it is parsed, so a hostile file can't exhaust the stack.
The ZIP reader bounds decompression by the caller-supplied maximum, rejects encrypted entries and unsupported compression methods, validates offsets with checked arithmetic, and rejects CRC or declared-size mismatches. Corruption and property tests cover truncation, byte mutation, missing content, invalid manifests, excessive sizes, and random bytes.
These defaults are intentionally finite but can still be expensive on constrained systems. Future hardening should add context-sensitive budgets and explicit limits for entry count, document complexity, and cumulative work, then test directory bundles against symlink and replacement races.
+13 -2
View File
@@ -1,9 +1,20 @@
# Raster formats
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
`photocraft-codecs` provides format detection, capability declarations, decode limits, metadata handling, and encoding for flat images. The default build declares read/write support for PNG, JPEG, TIFF, WebP, GIF, BMP, TGA, ICO, Netpbm/PFM, QOI, OpenEXR, and Radiance HDR. HEIF/HEIC (iPhone and Mac photos) is read-only, decoded in pure Rust by `heic-rs` through the optional `photocraft-heif` crate. It is a build-time choice behind the `heif` cargo feature (`cargo build -p photocraft --features heif`): official builds include it; without it HEIC files are still recognised and opening one reports that HEIC support isn't included in that build. AVIF encoding is optional; AVIF decoding is not implemented in the current crate.
## Layered TIFF
A TIFF saved by Photoshop with its layers carries them in two private tags: 37724 (`ImageSourceData`, the same layer section a PSD has, preceded by the string `Adobe Photoshop Document Data Block`) and 34377 (the PSD image resources). `photocraft-codecs` passes both through as opaque bytes on `Metadata`; `photocraft-io` rebuilds a PSD model from them plus the TIFF's pixels and hands it to the PSD importer, so groups, masks, layer styles, type, shapes, smart objects and adjustment layers open from a TIFF exactly as from a PSD, and are written back the same way. In Intel-order (`II`) files Photoshop stores the whole block structure byte-swapped (reversed keys such as `MIB8` and `ryaL`, little-endian integers and doubles) while pixel samples stay big-endian; `photocraft-psd`'s `tiff` module converts between the two orders field by field, and drops, with a warning, any block whose layout it does not know rather than pass byte-swapped data to other readers. Limits: a TIFF holds one transparency sample, so extra alpha channels are not written; Lab documents are saved flat. Save As asks "Including layers will increase file size" (with "Discard Layers and Save a Copy") while *Ask Before Saving Layered TIFF Files* is on in Preferences › File Handling. Scripted and agent saves write a flat TIFF unless they ask for layers: `--tiff-layers` for `photocraft-cli convert`, `run` and `batch`, and `"tiffLayers": true` in the save, batch and export commands and MCP `doc_save`.
Capability details such as sample depth, channel layout, alpha, ICC, EXIF, XMP, DPI, and animation behavior are defined by `crates/codecs/src/format.rs`. Do not infer fidelity from the filename extension alone.
## Exporting documents
Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_export`, Layers to Files, Layer › Export As) flattens it the same way:
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead.
## Implemented decode limits
`DecodeOptions::default()` applies `Limits` before decoded pixel allocation:
@@ -19,4 +30,4 @@ Format adapters pass compatible allocation limits into PNG, TIFF, WebP, and `ima
`Limits::none()` exists for callers with a deliberate reason to relax policy. It remains bounded by representable memory sizes, but it removes the default resource policy and should not be used for untrusted input.
Animated containers are represented as a single decoded frame by the current codec API. Refer to [`crates/codecs/README.md`](https://github.com/storytold/photocraft/blob/main/crates/codecs/README.md) and source capability declarations for current behavior.
Animated containers and multi-page TIFFs are represented as a single decoded frame or page by the current codec API; opening one adds an import warning such as "only the first of 3 frames was imported". A JPEG cut off inside its image data opens with a warning that its data ends early (an error when it holds no image data at all). Refer to [`crates/codecs/README.md`](https://github.com/storytold/photocraft/blob/main/crates/codecs/README.md) and source capability declarations for current behavior.
+15 -2
View File
@@ -7,7 +7,10 @@ Automation is a privilege boundary because requests can cause filesystem access,
- Desktop control binds to `127.0.0.1` and uses one JSON request/reply per line.
- Headless TCP refuses a successfully bound non-loopback address.
- Every desktop-control and headless-TCP connection must authenticate with a 256-bit bearer token before method dispatch.
- Encoded request lines are limited to 1 MiB, active TCP connections to 16, and headless/MCP batches to 256 steps.
- Encoded request lines are limited to 1 MiB on desktop and headless TCP and on the headless
JSON-lines stdio server (`photocraft-cli serve`). MCP over stdio has no request-byte cap: the
MCP SDK reads its own lines, and only MCP tool results are size-checked. Active TCP connections
are limited to 16, and headless/MCP batches to 256 steps.
- JSON-lines replies and encoded MCP tool results are limited to 8 MiB; retained batch replies
have an aggregate budget and stop later steps when exhausted. The headless stdio JSON-lines
transport enforces the same request/reply ceilings as TCP.
@@ -16,22 +19,32 @@ Automation is a privilege boundary because requests can cause filesystem access,
PNGs are limited to 5 MiB before base64 encoding or file writes. Trusted-local CLI rendering
retains its existing behavior.
- The MCP bridge bounds incoming replies and does not retry an operation after an oversized
reply. Screenshot decoding has separate dimension, pixel, and allocation ceilings.
reply. Screenshots are decoded under separate dimension, pixel, and allocation ceilings before
downscaling; the 5 MiB PNG limit applies to the returned image.
- MCP normally uses stdio and can optionally bridge to loopback control TCP.
- The bridge and UI request paths use timeouts.
- Engine commands are expected to reject invalid parameters without panicking.
- Remote filesystem methods use separately granted read and write directory capabilities.
- A save without a `path` (MCP `doc_save`, `serve` `doc.save`, the control channel's `app.save`)
writes back only to the document's own PSD, PSB or `.pcraft` file in its own format; any other
save needs an explicit `path`, so automation never flattens or converts over the file it opened.
- Automation paths must be relative beneath the applicable root. Absolute paths, parent
traversal, alternate separators, Windows device names, malformed components, and link escapes
are rejected before file effects.
- Engine commands that still use ambient paths fail closed at the automation boundary. Synthetic
UI input reaches the same command policy, and automation-triggered file hooks do not run
user-configured script-event paths.
- `prefs.set` applies the engine's dotted-path normalization before checking for filesystem-bearing
preference sections, including paths with leading or repeated dots. It refuses whole-preferences
updates and those sections; individual safe preference keys remain available.
- Applying the Preferences dialog with `ui.dialog.apply` is deliberately refused over automation.
## Known limitations
- no per-client or per-tool capabilities;
- no general per-client or per-tool capability model beyond filesystem read/write authority;
- no automated bulk apply of the Preferences dialog; agents can change individual safe keys with
`prefs.set`;
- no explicit JSON-depth policy, aggregate document/session-memory accounting, compositor
scratch-space accounting, or command-duration/cancellation budget;
- one thread per accepted TCP connection;
+3 -3
View File
@@ -39,15 +39,15 @@ A compact file declares oversized dimensions, excessive channels, nested descrip
### Filesystem authority abuse
An automation client supplies an absolute path, traversal sequence, symlink, junction, or replaced path to read or overwrite data outside the intended project. Current automation uses caller-supplied OS paths and has no workspace policy. This scenario is a known limitation.
An automation client supplies an absolute path, traversal sequence, symlink, junction, or replaced path to read or overwrite data outside the intended project. Remote filesystem methods use separately configured directory capabilities, and engine commands that still require ambient paths fail closed at the automation boundary. This is not yet a general per-command capability model, and trusted desktop operations can still use ambient paths.
### Local control takeover
Another process on the same host connects to the loopback control server, discovers methods, drives UI input, executes commands, or opens/saves files. Loopback reduces network exposure but does not authenticate the client.
Another process on the same host connects to the loopback control server, discovers methods, drives UI input, executes commands, or opens/saves files. A 256-bit bearer token is required before method dispatch, but authentication does not grant per-client or per-tool capabilities.
### Resource exhaustion
A client opens many TCP connections, sends an extremely long JSON line, creates a huge batch, requests full-size rendering, or repeatedly invokes expensive commands. Current automation transports lack explicit request, batch, and connection budgets.
A client opens many TCP connections, sends an extremely long JSON line, creates a huge batch, requests full-size rendering, or repeatedly invokes expensive commands. Request-line, connection, batch, and preview limits mitigate some cases; explicit JSON-depth, document-memory, and command-duration budgets remain absent.
### Supply-chain compromise
+5 -2
View File
@@ -147,9 +147,12 @@ impl GalleryFilter {
out
}
/// Whether the filter reads the foreground / background colours.
/// Whether the filter reads the foreground / background colours. Derived
/// from the params notation, plus the filters whose pixel code reads the
/// colours without listing them (issue #709): Colored Pencil uses the
/// background as paper tint, Neon Glow mixes foreground over background.
pub fn uses_colours(self) -> bool {
self.params_doc().contains("\"foreground\"") || self.params_doc().contains("\"background\"")
matches!(self, Self::ColoredPencil | Self::NeonGlow) || self.params_doc().contains("\"foreground\"") || self.params_doc().contains("\"background\"")
}
}
+165 -6
View File
@@ -175,8 +175,13 @@ fn transpose(buf: &[f32], w: usize, h: usize, n: usize) -> Vec<f32> {
/// Gaussian blur by three box passes per axis over a premultiplied window.
fn gaussian_boxes(src: &Image, out: Rect, alpha: bool, sigma: f32) -> Vec<f32> {
box_passes(src, out, alpha, &boxes_for_gauss(sigma, 3))
}
/// Box blurs of the given (odd) widths, one after another along each axis, over a premultiplied
/// window; running sums make the cost independent of the widths.
fn box_passes(src: &Image, out: Rect, alpha: bool, boxes: &[usize]) -> Vec<f32> {
let n = src.ch;
let boxes = boxes_for_gauss(sigma, 3);
let margin: i32 = boxes.iter().map(|w| (*w as i32 - 1) / 2).sum::<i32>() + 1;
let win = Rect::new(out.x0 - margin, out.y0 - margin, out.x1 + margin, out.y1 + margin);
let (ww, wh) = (win.width() as usize, win.height() as usize);
@@ -197,11 +202,11 @@ fn gaussian_boxes(src: &Image, out: Rect, alpha: bool, sigma: f32) -> Vec<f32> {
#[cfg(target_arch = "wasm32")]
p.chunks_mut(ww * n).enumerate().for_each(|(y, row)| fill(y, row));
premultiply(&mut p, n, alpha);
for w in &boxes {
for w in boxes {
box_rows(&mut p, ww, n, (w - 1) / 2);
}
let mut t = transpose(&p, ww, wh, n);
for w in &boxes {
for w in boxes {
box_rows(&mut t, wh, n, (w - 1) / 2);
}
let p = transpose(&t, wh, ww, n);
@@ -217,8 +222,12 @@ fn gaussian_boxes(src: &Image, out: Rect, alpha: bool, sigma: f32) -> Vec<f32> {
pub(crate) fn boxed(src: &Image, out: Rect, ctx: &Ctx, radius: f32) -> Vec<f32> {
let r = radius.max(0.0).round() as usize;
let k = vec![1.0 / (2 * r + 1) as f32; 2 * r + 1];
conv_sep(src, out, &k, &k, ctx.alpha)
// Running sums cost the same at any radius; the direct kernel is quicker for the smallest.
if r <= 4 {
let k = vec![1.0 / (2 * r + 1) as f32; 2 * r + 1];
return conv_sep(src, out, &k, &k, ctx.alpha);
}
box_passes(src, out, ctx.alpha, &[2 * r + 1])
}
fn average_samples(src: &Image, out: Rect, ctx: &Ctx, mut offsets: impl FnMut(f32, f32, &mut Vec<(f32, f32)>)) -> Vec<f32> {
@@ -305,11 +314,102 @@ pub(crate) fn radial(src: &Image, out: Rect, ctx: &Ctx, amount: f32, method: Rad
})
}
pub(crate) fn surface(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32) -> Vec<f32> {
/// [`surface`] for 8-bit samples (every colour sample in the window is `k / 255`; `None`
/// otherwise): a 256-bin histogram of each channel over the window slides along the row, adding
/// and removing one column per step, so a pixel costs O(radius + levels) instead of O(radius^2).
/// Each level is weighted with the same expression as the direct sum.
fn surface_8bit(src: &Image, out: Rect, ctx: &Ctx, r: i32, t: f32, reach: usize) -> Option<Vec<f32>> {
let n = src.ch;
let cc = if ctx.alpha { n - 1 } else { n };
let win = out.inflate(r);
let (ww, wh) = (win.width() as usize, win.height() as usize);
// Level of every colour sample in the window; `SKIP` where the pixel is transparent (the
// direct sum skips those). Outside the source reads as 0, as `Image::get` does.
const SKIP: u16 = 256;
let mut lv = vec![0u16; ww * wh * cc];
for y in win.y0..win.y1 {
for x in win.x0..win.x1 {
let i = ((y - win.y0) as usize * ww + (x - win.x0) as usize) * cc;
let skip = ctx.alpha && src.get(x, y, n - 1) <= 0.0;
for c in 0..cc {
let v = src.get(x, y, c);
let k = (v * 255.0).round();
if !(0.0..=255.0).contains(&k) || (k / 255.0).to_bits() != v.to_bits() {
return None;
}
lv[i + c] = if skip { SKIP } else { k as u16 };
}
}
}
let (ow, oh) = (out.width() as usize, out.height() as usize);
let side = (2 * r + 1) as usize;
let mut res = vec![0.0f32; ow * oh * n];
let mut hist = [0u32; 257];
for oy in 0..oh {
for c in 0..cc {
hist.fill(0);
// Window of output column 0: window rows oy..oy + side, columns 0..side.
let col = |hist: &mut [u32; 257], wx: usize, add: bool| {
for wy in oy..oy + side {
let k = lv[(wy * ww + wx) * cc + c] as usize;
if add {
hist[k] += 1;
} else {
hist[k] -= 1;
}
}
};
for wx in 0..side {
col(&mut hist, wx, true);
}
for ox in 0..ow {
let p = src.px(out.x0 + ox as i32, out.y0 + oy as i32);
let v0 = p[c];
let k0 = (v0 * 255.0).round() as usize;
let (mut acc, mut wsum) = (0.0f64, 0.0f64);
for (k, &cnt) in hist.iter().enumerate().take(256.min(k0 + reach + 1)).skip(k0.saturating_sub(reach)) {
if cnt == 0 {
continue;
}
let v = k as f32 / 255.0;
let w = (1.0 - (v - v0).abs() / t).max(0.0);
acc += f64::from(v * w) * f64::from(cnt);
wsum += f64::from(w) * f64::from(cnt);
}
res[(oy * ow + ox) * n + c] = if wsum > 0.0 { (acc / wsum) as f32 } else { v0 };
if ox + 1 < ow {
col(&mut hist, ox, false);
col(&mut hist, ox + side, true);
}
}
}
if ctx.alpha {
for ox in 0..ow {
res[(oy * ow + ox) * n + n - 1] = src.px(out.x0 + ox as i32, out.y0 + oy as i32)[n - 1];
}
}
}
Some(res)
}
pub(crate) fn surface(src: &Image, out: Rect, ctx: &Ctx, radius: f32, threshold: f32) -> Vec<f32> {
let r = radius.max(0.0).round() as i32;
let t = (threshold.max(1.0) / 255.0) * 2.5;
// Levels within the threshold of the centre (the only ones with any weight), on each side.
let reach = ((t * 255.0).ceil() as usize).min(255);
let side = (2 * r + 1) as usize;
if side * side > 2 * reach + 1 + 4 * side
&& let Some(res) = surface_8bit(src, out, ctx, r, t, reach)
{
return res;
}
surface_direct(src, out, ctx, r, t)
}
/// [`surface`] by summing the whole (2r + 1)^2 window of every pixel.
fn surface_direct(src: &Image, out: Rect, ctx: &Ctx, r: i32, t: f32) -> Vec<f32> {
let n = src.ch;
let cc = if ctx.alpha { n - 1 } else { n };
let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n);
for y in out.y0..out.y1 {
for x in out.x0..out.x1 {
@@ -361,4 +461,63 @@ mod tests {
assert!(err < 0.02, "max error {err}");
assert_eq!(boxes_for_gauss(10.0, 3).len(), 3);
}
#[test]
fn box_passes_match_the_direct_box_kernel() {
// Noisy RGBA with varying alpha, so premultiplication and every channel are exercised.
for r in [5usize, 17, 60] {
let (w, h, m) = (40, 24, r as i32 + 2);
let src_rect = Rect::new(-m, -m, w + m, h + m);
let mut img = Image::new(src_rect, 4);
let mut s = 0x9e37_79b9u32;
for v in img.data.iter_mut() {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
*v = (s % 256) as f32 / 255.0;
}
let out = Rect::new(0, 0, w, h);
let k = vec![1.0 / (2 * r + 1) as f32; 2 * r + 1];
let direct = conv_sep(&img, out, &k, &k, true);
let ctx = Ctx { bounds: src_rect, mode: crate::ColorMode::Rgb, alpha: true };
let fast = boxed(&img, out, &ctx, r as f32);
let err = direct.iter().zip(&fast).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(err < 1e-5, "radius {r}: max error {err}");
}
}
#[test]
fn surface_blur_8bit_histogram_matches_the_direct_sum() {
// 8-bit levels with some fully transparent pixels, which the sum skips.
let src_rect = Rect::new(-14, -14, 40, 34);
let mut img = Image::new(src_rect, 4);
let mut s = 0x6c07_8965u32;
for px in img.data.as_chunks_mut::<4>().0 {
for v in px.iter_mut() {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
*v = (s % 256) as f32 / 255.0;
}
if s.is_multiple_of(7) {
px[3] = 0.0;
}
}
let ctx = Ctx { bounds: src_rect, mode: crate::ColorMode::Rgb, alpha: true };
// The output reaches the source edge, where samples read as transparent.
let out = Rect::new(-2, 0, 26, 20);
for (r, threshold) in [(1, 2.0), (4, 15.0), (12, 60.0), (12, 255.0)] {
let t = (threshold / 255.0) * 2.5;
let reach = ((t * 255.0f32).ceil() as usize).min(255);
let fast = surface_8bit(&img, out, &ctx, r, t, reach).expect("8-bit input");
let direct = surface_direct(&img, out, &ctx, r, t);
let err = direct.iter().zip(&fast).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(err < 1e-5, "r {r} threshold {threshold}: max error {err}");
}
// Not 8-bit: the histogram declines and the direct sum is used.
// (0, 0) is inside every window above.
let i = (14 * src_rect.width() as usize + 14) * 4;
img.data[i] = 0.5 / 255.0;
assert!(surface_8bit(&img, out, &ctx, 4, 0.1, 26).is_none());
}
}
+88
View File
@@ -29,6 +29,29 @@
use serde::{Deserialize, Serialize};
/// Most points a Camera Raw point curve accepts from commands and the control channel.
pub const MAX_CURVE_POINTS: usize = 16;
/// Checks a point curve from untrusted input: empty (linear) or 2..=16 finite points in 0..=255
/// with inputs increasing by at least one level. Deserialization stays lenient on purpose:
/// earlier editors saved curves this rejects, and [`curve_lut`] already sorts and de-duplicates
/// them, so stored Smart Filters keep rendering.
pub fn validate_curve(points: &[[f32; 2]]) -> Result<(), String> {
if points.len() == 1 {
return Err("a nonempty curve needs at least two points".into());
}
if points.len() > MAX_CURVE_POINTS {
return Err(format!("at most {MAX_CURVE_POINTS} curve points"));
}
if !points.iter().flatten().all(|v| v.is_finite() && (0.0..=255.0).contains(v)) {
return Err("curve coordinates must be finite and in 0..=255".into());
}
if points.windows(2).any(|w| w[1][0] - w[0][0] < 1.0) {
return Err("curve inputs must increase by at least one level".into());
}
Ok(())
}
use crate::photo_util::{hash01, linear_to_srgb, par_rows, srgb_to_linear};
/// One colour grading wheel.
@@ -437,6 +460,33 @@ pub fn band_weights(h: f32) -> [f32; 8] {
}
impl CameraRaw {
/// Rejects malformed point curves in new settings (see [`validate_curve`]).
pub fn validate(&self) -> Result<(), String> {
for (name, curve) in [
("pointCurve", &self.point_curve),
("pointCurveRed", &self.point_curve_red),
("pointCurveGreen", &self.point_curve_green),
("pointCurveBlue", &self.point_curve_blue),
] {
validate_curve(curve).map_err(|e| format!("{name}: {e}"))?;
}
Ok(())
}
/// [`validate`](Self::validate) for an edit of `stored` settings: a curve left as stored is
/// accepted, so editing another control of an older Smart Filter still works.
pub fn validate_changes(&self, stored: &CameraRaw) -> Result<(), String> {
for (name, curve, kept) in [
("pointCurve", &self.point_curve, &stored.point_curve),
("pointCurveRed", &self.point_curve_red, &stored.point_curve_red),
("pointCurveGreen", &self.point_curve_green, &stored.point_curve_green),
("pointCurveBlue", &self.point_curve_blue, &stored.point_curve_blue),
] {
if curve != kept {
validate_curve(curve).map_err(|e| format!("{name}: {e}"))?;
}
}
Ok(())
}
fn wb_neutral(&self) -> bool {
self.temperature == 0.0 && self.tint == 0.0 && self.exposure == 0.0
}
@@ -869,6 +919,44 @@ fn vignette(px: &mut [[f32; 4]], w: usize, h: usize, p: &CameraRaw) {
mod tests {
use super::*;
#[test]
fn new_curves_are_validated_but_saved_curves_still_load_and_render() {
for field in ["pointCurve", "pointCurveRed", "pointCurveGreen", "pointCurveBlue"] {
for points in [
serde_json::json!([[128, 128]]),
serde_json::json!([[255, 255], [0, 0]]),
serde_json::json!([[0, 0], [0, 255]]),
serde_json::json!([[0, -1], [255, 255]]),
serde_json::json!([[0, 0], [256, 255]]),
serde_json::json!([[0, 0], [1e30, 255]]),
serde_json::json!((0..17).map(|i| [i * 15, i * 15]).collect::<Vec<_>>()),
] {
let p: CameraRaw = serde_json::from_value(serde_json::json!({field: points})).unwrap();
assert!(p.validate().is_err(), "{field}: {points}");
}
for points in
[serde_json::json!([]), serde_json::json!([[0, 0], [255, 255]]), serde_json::json!((0..16).map(|i| [i * 17, i * 17]).collect::<Vec<_>>())]
{
let p: CameraRaw = serde_json::from_value(serde_json::json!({field: points})).unwrap();
assert!(p.validate().is_ok(), "{field}: {points}");
}
}
assert_eq!(serde_json::from_str::<CameraRaw>("{}").unwrap(), CameraRaw::default());
// An older editor could stack two points on one input and exceed 16 points; such a
// saved Smart Filter must still develop instead of silently disappearing.
let mut legacy: Vec<[f32; 2]> = (0..20).map(|i| [i as f32 * 12.0, i as f32 * 12.0]).collect();
legacy.insert(5, [60.0, 200.0]);
let p: CameraRaw = serde_json::from_value(serde_json::json!({"pointCurve": legacy})).unwrap();
assert!(p.validate().is_err());
let edited = CameraRaw { exposure: 1.0, ..p.clone() };
assert!(edited.validate_changes(&p).is_ok(), "an unchanged stored curve stays editable");
let broken = CameraRaw { point_curve: vec![[10.0, 10.0]], ..p.clone() };
assert!(broken.validate_changes(&p).is_err(), "a changed curve is validated");
let mut px = img(4, 4);
develop(&mut px, 4, 4, &p, false);
assert!(px.iter().flatten().all(|v| v.is_finite()));
}
fn img(w: usize, h: usize) -> Vec<[f32; 4]> {
(0..w * h)
.map(|i| {
+64 -9
View File
@@ -17,8 +17,8 @@
//!
//! Buffers are interleaved `w × h × ch` normalised floats (any model/depth); deterministic.
use crate::inpaint::{CompleteParams, complete};
use crate::poisson::membrane_fill;
use crate::inpaint::{CompleteParams, complete_with};
use crate::poisson::membrane_fill_with;
/// Options for [`fill`].
#[derive(Clone, Debug, PartialEq)]
@@ -161,11 +161,27 @@ fn box_blur(w: usize, h: usize, ch: usize, v: &[f32], r: usize) -> Vec<f32> {
/// Returns the full buffer (unchanged outside the hole). Falls back to the membrane fill when
/// nothing can be sampled.
pub fn fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], source: &[bool], opts: &FillOptions) -> Vec<f32> {
// Never cancelled; the fallback (the input unchanged) is unreachable.
fill_with(w, h, ch, img, hole, source, opts, &photocraft_raster::Interrupt::NONE).unwrap_or_else(|_| img.to_vec())
}
/// [`fill`] that can be cancelled (see [`crate::inpaint::complete_with`]) and reports progress.
#[allow(clippy::too_many_arguments)]
pub fn fill_with(
w: usize,
h: usize,
ch: usize,
img: &[f32],
hole: &[bool],
source: &[bool],
opts: &FillOptions,
ctl: &photocraft_raster::Interrupt,
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
assert_eq!(img.len(), w * h * ch);
assert_eq!(hole.len(), w * h);
assert_eq!(source.len(), w * h);
if !hole.iter().any(|h| *h) {
return img.to_vec();
return Ok(img.to_vec());
}
let params = CompleteParams { seed: opts.seed, ..Default::default() };
let gap = 2 * params.patch_radius + 2;
@@ -208,7 +224,11 @@ pub fn fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], source: &
}
oy += v.h + gap;
}
let filled = complete(mw, mh, ch, &mimg, &unknown, &params);
ctl.check()?;
// The completion is nearly all the work: it reports 0–90 %.
let cancel = || ctl.cancelled();
let progress = |f: f32| ctl.progress(f * 0.9);
let filled = complete_with(mw, mh, ch, &mimg, &unknown, &params, &photocraft_raster::Interrupt::new(&cancel, &progress))?;
let mut out = img.to_vec();
match filled {
Some(f) => {
@@ -221,16 +241,28 @@ pub fn fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], source: &
}
}
}
None => out = membrane_fill(w, h, ch, img, hole),
None => out = membrane_fill_with(w, h, ch, img, hole, ctl)?,
}
ctl.check()?;
if opts.color_adaptation > 0.0 {
adapt_colors(w, h, ch, img, hole, &mut out, opts.color_adaptation);
adapt_colors(w, h, ch, img, hole, &mut out, opts.color_adaptation, ctl)?;
}
out
ctl.progress(1.0);
Ok(out)
}
/// Pull the fill's low frequencies towards the membrane interpolation of the hole boundary.
fn adapt_colors(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], out: &mut [f32], k: f32) {
#[allow(clippy::too_many_arguments)]
fn adapt_colors(
w: usize,
h: usize,
ch: usize,
img: &[f32],
hole: &[bool],
out: &mut [f32],
k: f32,
ctl: &photocraft_raster::Interrupt,
) -> Result<(), photocraft_raster::Cancelled> {
let (mut x0, mut y0, mut x1, mut y1) = (w, h, 0, 0);
for y in 0..h {
for x in 0..w {
@@ -240,7 +272,9 @@ fn adapt_colors(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], out:
}
}
let r = ((x1 - x0).max(y1 - y0) / 6).clamp(1, 32);
let membrane = membrane_fill(w, h, ch, img, hole);
let membrane = membrane_fill_with(w, h, ch, img, hole, ctl)?;
ctl.progress(0.97);
ctl.check()?;
let low = box_blur(w, h, ch, out, r);
for (i, &hl) in hole.iter().enumerate() {
if hl {
@@ -250,6 +284,7 @@ fn adapt_colors(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], out:
}
}
}
Ok(())
}
#[cfg(test)]
@@ -263,6 +298,26 @@ mod tests {
(img, hole)
}
#[test]
fn cancelled_fill_stops_and_progress_reaches_one() {
use std::sync::atomic::{AtomicU32, Ordering};
let (w, h) = (60, 40);
let (img, hole) = stripes(w, h);
let source = vec![true; w * h];
let yes = || true;
let cancelled = photocraft_raster::Interrupt::cancel_only(&yes);
assert!(fill_with(w, h, 1, &img, &hole, &source, &FillOptions::default(), &cancelled).is_err());
let last = AtomicU32::new(0);
let no = || false;
let progress = |f: f32| {
assert!(f >= f32::from_bits(last.load(Ordering::Relaxed)), "progress went backwards");
last.store(f.to_bits(), Ordering::Relaxed);
};
let out = fill_with(w, h, 1, &img, &hole, &source, &FillOptions::default(), &photocraft_raster::Interrupt::new(&no, &progress)).unwrap();
assert_eq!(f32::from_bits(last.load(Ordering::Relaxed)), 1.0);
assert_eq!(out, fill(w, h, 1, &img, &hole, &source, &FillOptions::default()), "same result as the plain fill");
}
#[test]
fn fills_only_the_hole_with_known_texture() {
let (w, h) = (48, 32);
+11 -1
View File
@@ -172,7 +172,17 @@ pub(crate) fn gauss_box_radii(sigma: f32) -> [usize; 3] {
let wl = wl.max(1);
let wu = wl + 2;
let m = ((12.0 * sigma * sigma - n * (wl * wl) as f32 - 4.0 * n * wl as f32 - 3.0 * n) / (-4.0 * wl as f32 - 4.0)).round().clamp(0.0, n) as usize;
std::array::from_fn(|i| if i < m { (wl as usize - 1) / 2 } else { (wu as usize - 1) / 2 })
let mut radii = std::array::from_fn(|i| if i < m { (wl as usize - 1) / 2 } else { (wu as usize - 1) / 2 });
// Kovesi's rounded triple degenerates below sigma ~ 0.577: `wl` floors to 1 and every
// radius becomes 0, so the passes would be no-ops and callers with documented small
// sigmas (Camera Raw `sharpenRadius` 0.5, colour noise, HDR Toning `radius` 1) would
// silently change nothing (issue #706). One radius-1 pass is the smallest real blur;
// it only engages where the ideal widths are already at their floor, leaving larger
// sigmas exactly as the paper prescribes.
if radii.iter().all(|&r| r == 0) {
radii[0] = 1;
}
radii
}
/// Approximate Gaussian blur (three box passes) of an interleaved buffer.
+176
View File
@@ -0,0 +1,176 @@
//! Bounded RGB histograms of straight, normalized float RGBA samples.
//!
//! No colour conversion is performed: bins describe the supplied sample domain, not an
//! assumed sRGB/ICC display space. 256 bins are a display resolution, not an input bit depth.
pub const BINS: usize = 256;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RgbHistogram {
pub channels: [[u64; BINS]; 3],
pub samples: u64,
pub transparent: u64,
pub invalid: u64,
/// Finite samples outside 0..=1 are included in the edge bins and counted separately.
pub underflow: [u64; 3],
pub overflow: [u64; 3],
/// Exact endpoints, before display-bin rounding. A near-white value is not clipped.
pub shadows: [u64; 3],
pub highlights: [u64; 3],
}
impl Default for RgbHistogram {
fn default() -> Self {
Self { channels: [[0; BINS]; 3], samples: 0, transparent: 0, invalid: 0, underflow: [0; 3], overflow: [0; 3], shadows: [0; 3], highlights: [0; 3] }
}
}
impl RgbHistogram {
/// Each visible pixel counts once, including partially transparent pixels. Non-finite
/// pixels are rejected as a whole; transparent RGB cannot contribute phantom peaks.
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
let mut h = Self::default();
for pixel in pixels {
if !pixel[3].is_finite() {
h.invalid = h.invalid.saturating_add(1);
continue;
}
if pixel[3] <= 0.0 {
h.transparent = h.transparent.saturating_add(1);
continue;
}
if !pixel.iter().all(|v| v.is_finite()) {
h.invalid = h.invalid.saturating_add(1);
continue;
}
h.samples = h.samples.saturating_add(1);
let (low, high) = clipping(*pixel);
for (c, ((((bins, below), above), shadows), highlights)) in
h.channels.iter_mut().zip(&mut h.underflow).zip(&mut h.overflow).zip(&mut h.shadows).zip(&mut h.highlights).enumerate()
{
let Some(value) = pixel.get(c) else { continue };
if low & (1 << c) != 0 {
*shadows = shadows.saturating_add(1);
}
if high & (1 << c) != 0 {
*highlights = highlights.saturating_add(1);
}
if *value < 0.0 {
*below = below.saturating_add(1);
} else if *value > 1.0 {
*above = above.saturating_add(1);
}
// Match the preview encoder's nearest-bin rule, before it loses float precision.
let bin = (value.clamp(0.0, 1.0) * (BINS - 1) as f32 + 0.5) as usize;
if let Some(count) = bins.get_mut(bin) {
*count = count.saturating_add(1);
}
}
}
h
}
}
/// Clipped channels of one visible straight RGBA pixel as bit masks (bit 0=R, 1=G, 2=B):
/// `(shadows, highlights)` at exact endpoints ≤0 / ≥1, not rounded edge bins.
/// Transparent and non-finite pixels clip nothing.
pub fn clipping(p: [f32; 4]) -> (u8, u8) {
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
return (0, 0);
}
let mut shadows = 0;
let mut highlights = 0;
for (c, v) in p.iter().take(3).enumerate() {
if *v <= 0.0 {
shadows |= 1 << c;
}
if *v >= 1.0 {
highlights |= 1 << c;
}
}
(shadows, highlights)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_bins_and_channel_totals() {
let pixels: Vec<_> = (0..BINS).map(|i| [i as f32 / 255.0, (255 - i) as f32 / 255.0, 0.5, 0.25]).collect();
let h = RgbHistogram::from_rgba(&pixels);
assert_eq!(h.samples, 256);
assert!(h.channels[0].iter().all(|&v| v == 1));
assert!(h.channels[1].iter().all(|&v| v == 1));
assert_eq!(h.channels[2][128], 256);
for channel in h.channels {
assert_eq!(channel.iter().sum::<u64>(), h.samples);
}
}
#[test]
fn empty_transparent_invalid_and_hdr_are_explicit() {
assert_eq!(RgbHistogram::from_rgba(&[]), RgbHistogram::default());
let pixels = [[0.7, 0.9, 0.4, 0.0], [f32::NAN, 0.0, 0.0, 1.0], [0.0, f32::INFINITY, 0.0, 1.0], [0.0, 0.0, 0.0, f32::NAN], [-2.0, 4.0, 0.5, 1.0]];
let original = pixels;
let h = RgbHistogram::from_rgba(&pixels);
assert_eq!((h.samples, h.transparent, h.invalid), (1, 1, 3));
assert_eq!(h.underflow, [1, 0, 0]);
assert_eq!(h.overflow, [0, 1, 0]);
assert_eq!((h.channels[0][0], h.channels[1][255], h.channels[2][128]), (1, 1, 1));
assert_eq!(pixels[4], original[4]);
assert!(pixels[1][0].is_nan());
}
#[test]
fn clipping_indicators_use_exact_samples_not_edge_bin_rounding() {
let h = RgbHistogram::from_rgba(&[[0.0001, 0.9999, 0.5, 1.0], [-0.2, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0]]);
assert_eq!(h.shadows, [1, 0, 1]);
assert_eq!(h.highlights, [0, 1, 0]);
assert_eq!(h.channels[0][0], 2);
assert_eq!(h.channels[1][255], 2);
}
#[test]
fn rgb_and_gray_at_all_depths_use_the_same_float_contract() {
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
for sample in SampleType::ALL {
let mut surface = Surface::new(PixelFormat::new(mode, sample, true));
for (x, v) in [0.0, 0.25, 0.75, 1.0].into_iter().enumerate() {
let px = if mode == ColorMode::Rgb { vec![v, v, v, 1.0] } else { vec![v, 1.0] };
surface.write_pixel(x as i32, 0, &px);
}
let mut pixels = [[0.0; 4]; 4];
surface.read_rgba_into(Rect::new(0, 0, 4, 1), &mut pixels);
let h = RgbHistogram::from_rgba(&pixels);
assert_eq!(h.samples, 4, "{mode:?}/{sample:?}");
for c in &h.channels {
for bin in [0, 64, 191, 255] {
assert_eq!(c[bin], 1, "{mode:?}/{sample:?}, bin {bin}");
}
}
}
}
}
#[test]
fn exact_clipping_endpoints_not_quantized_neighbours() {
assert_eq!(clipping([0.0001, 0.9999, 0.5, 1.0]), (0, 0));
assert_eq!(clipping([-0.1, 1.0, 0.0, 1.0]), (5, 2));
assert_eq!(clipping([0.0, 1.0, 0.0, 0.0]), (0, 0));
assert_eq!(clipping([0.0, f32::INFINITY, 0.0, 1.0]), (0, 0));
}
#[test]
fn exposure_of_an_eight_bit_gradient_reproduces_the_quantization_comb() {
let mut pixels: Vec<_> = (0..256).map(|i| [i as f32 / 255.0, i as f32 / 255.0, i as f32 / 255.0, 1.0]).collect();
assert!(RgbHistogram::from_rgba(&pixels).channels[0].iter().all(|&v| v == 1));
crate::camera_raw::develop(&mut pixels, 256, 1, &crate::camera_raw::CameraRaw { exposure: 0.5, ..Default::default() }, true);
let h = RgbHistogram::from_rgba(&pixels);
assert!(h.channels[0][10..230].iter().filter(|&&v| v == 0).count() > 10);
assert_eq!(h.samples, 256);
assert!(h.overflow[0] > 0);
}
}
+46 -4
View File
@@ -164,6 +164,8 @@ struct Solver<'a> {
valid_list: Vec<(i32, i32)>,
/// Target centres (patch overlaps the hole), row-major.
targets: Vec<bool>,
/// Cancellation: checked per row band; a cancelled solve's output is discarded.
ctl: photocraft_raster::Interrupt<'a>,
}
impl Solver<'_> {
@@ -211,6 +213,9 @@ impl Solver<'_> {
let w = self.w;
let reverse = pass % 2 == 1;
let band_fn = |bi: usize, nn: &mut [(i32, i32)], co: &mut [f32]| {
if self.ctl.cancelled() {
return;
}
let mut rng = Rng::new(seed ^ (bi as u64).wrapping_mul(0x2545_F491_4F6C_DD1D) ^ ((pass as u64) << 40));
let y0 = bi * BAND;
let rows = nn.len() / w;
@@ -292,6 +297,9 @@ impl Solver<'_> {
let img = &*self.img;
let row = |y: usize| -> Vec<(usize, Vec<f32>)> {
let mut out = Vec::new();
if self.ctl.cancelled() {
return out;
}
for x in 0..w {
if !self.hole[y * w + x] {
continue;
@@ -349,9 +357,24 @@ impl Solver<'_> {
/// Content-aware completion of `hole` (see module docs). Returns `None` when there is no fully-known
/// patch to copy from (the caller can fall back to [`membrane_fill`]).
pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &CompleteParams) -> Option<Vec<f32>> {
// Never cancelled, so never `Err`.
complete_with(w, h, ch, img, hole, p, &photocraft_raster::Interrupt::NONE).unwrap_or(None)
}
/// [`complete`] that can be cancelled (checked per EM iteration, PatchMatch pass and row band)
/// and reports progress (by pyramid level). `Err(Cancelled)` when `ctl` was cancelled.
pub fn complete_with(
w: usize,
h: usize,
ch: usize,
img: &[f32],
hole: &[bool],
p: &CompleteParams,
ctl: &photocraft_raster::Interrupt,
) -> Result<Option<Vec<f32>>, photocraft_raster::Cancelled> {
assert_eq!(img.len(), w * h * ch);
assert_eq!(hole.len(), w * h);
let Some((bx0, by0, bx1, by1)) = hole_bbox(w, h, hole) else { return Some(img.to_vec()) };
let Some((bx0, by0, bx1, by1)) = hole_bbox(w, h, hole) else { return Ok(Some(img.to_vec())) };
let r = p.patch_radius.max(1);
let psz = 2 * r + 1;
// Pyramid: shrink until the hole is a few patches across.
@@ -359,6 +382,7 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
let mut ext = (bx1 - bx0).max(by1 - by0);
let mut exts = vec![ext];
while let Some(l) = levels.last() {
ctl.check()?;
if ext <= 2 * psz || l.w / 2 < 3 * psz || l.h / 2 < 3 * psz {
break;
}
@@ -370,7 +394,10 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
let nlev = levels.len();
let mut nnf: Vec<(i32, i32)> = Vec::new();
let mut prev: Option<(usize, usize, Vec<f32>)> = None;
let total_px: usize = levels.iter().map(|l| l.w * l.h).sum();
let mut done_px = 0usize;
for li in (0..nlev).rev() {
ctl.check()?;
let Level { w: lw, h: lh, img: mut limg, hole: lhole } = levels[li].clone();
// Initialise the hole: membrane at the coarsest level, upsampled result elsewhere.
match &prev {
@@ -393,6 +420,9 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
let mut valid_list = Vec::new();
let mut targets = vec![false; lw * lh];
for y in 0..lh as i32 {
if y % 32 == 0 {
ctl.check()?;
}
for x in 0..lw as i32 {
let cnt = window_count(&hs, lw, lh, x - ri, y - ri, x + ri + 1, y + ri + 1);
let inside = x >= ri && y >= ri && x + ri < lw as i32 && y + ri < lh as i32;
@@ -406,19 +436,22 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
}
if valid_list.is_empty() {
if li == 0 {
return None;
return Ok(None);
}
prev = Some((lw, lh, limg));
nnf.clear();
continue;
}
let mut solver = Solver { w: lw, h: lh, ch, r: ri, img: &mut limg, hole: &lhole, valid, valid_list, targets };
let mut solver = Solver { w: lw, h: lh, ch, r: ri, img: &mut limg, hole: &lhole, valid, valid_list, targets, ctl: *ctl };
// NNF init: upsampled from the coarser level where possible, random otherwise.
let mut rng = Rng::new(p.seed ^ (li as u64) << 20);
let pw_prev = prev.as_ref().map_or(0, |p| p.0);
let old = std::mem::take(&mut nnf);
let mut new_nnf = vec![(0i32, 0i32); lw * lh];
for y in 0..lh {
if y % 32 == 0 {
ctl.check()?;
}
for x in 0..lw {
if !solver.targets[y * lw + x] {
continue;
@@ -451,6 +484,9 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
for it in 0..em {
// Costs change as hole pixels are re-estimated: refresh before each PatchMatch.
for (i, c) in cost.iter_mut().enumerate() {
if i % (lw * 32).max(1) == 0 {
ctl.check()?;
}
if solver.targets[i] {
*c = solver.dist(((i % lw) as i32, (i / lw) as i32), nnf[i], f32::INFINITY);
}
@@ -464,13 +500,19 @@ pub fn complete(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool], p: &C
(4 * psz as i32).min(full_search)
};
for pass in 0..pm_iters {
ctl.check()?;
solver.patchmatch(&mut nnf, &mut cost, pass + it * pm_iters, p.seed.wrapping_add((li * 1000 + it) as u64), radius);
}
ctl.check()?;
solver.vote(&nnf, &cost);
ctl.check()?;
}
// Finer levels cost ~4× the previous one: weight progress by pixel count.
done_px += lw * lh;
ctl.progress(done_px as f32 / total_px.max(1) as f32);
prev = Some((lw, lh, limg));
}
prev.map(|(_, _, img)| img)
Ok(prev.map(|(_, _, img)| img))
}
/// Proximity match: the displacement `(dx, dy)` (within `max_radius`) whose surroundings best match the
+76 -2
View File
@@ -33,6 +33,7 @@ pub mod features;
mod fxutil;
mod gallery;
pub mod hdr;
pub mod histogram;
mod image;
pub mod inpaint;
pub mod lens;
@@ -51,6 +52,7 @@ pub mod poisson;
pub mod puppet;
pub mod pyramid;
pub mod quantize;
mod relight;
mod render;
pub mod render2;
pub mod resample;
@@ -59,6 +61,7 @@ pub mod scancrop;
pub mod seam;
pub mod segment;
pub mod selection;
mod selection_blur;
mod sharpen;
pub mod stack;
mod stylize;
@@ -67,6 +70,7 @@ pub mod tone;
pub mod transform;
pub mod trap;
pub mod vanishing;
pub mod vectorscope;
mod video;
pub mod warp;
pub mod wideangle;
@@ -408,6 +412,16 @@ pub enum FilterParams {
height: f32,
white_is_high: bool,
},
/// Photographic relight: angle −180…180°, elevation 0…90°, intensity/ambient 0…100,
/// warmth −100…100, softness 1…100. Intensity 0 is an identity copy.
Relight {
angle: f32,
elevation: f32,
intensity: f32,
ambient: f32,
warmth: f32,
softness: f32,
},
// ---- Noise ----
/// Strength 0–10, the rest 0–100 %.
@@ -584,6 +598,7 @@ impl FilterParams {
FilterParams::Fibers { .. } => "Fibers",
FilterParams::LensFlare { .. } => "Lens Flare",
FilterParams::LightingEffects { .. } => "Lighting Effects",
FilterParams::Relight { .. } => "Relight",
FilterParams::ReduceNoise { .. } => "Reduce Noise",
FilterParams::SmartBlur { .. } => "Smart Blur",
FilterParams::LensBlur { .. } => "Lens Blur",
@@ -625,6 +640,8 @@ fn halo_ext(p: &FilterParams) -> Halo {
// Generators and per-pixel renders only need the bounds geometry (always in the context).
FilterParams::Fibers { .. } | FilterParams::LensFlare { .. } => Halo::Radius(0),
FilterParams::LightingEffects { .. } => r(1.0),
FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0),
FilterParams::Relight { .. } => Halo::Radius(relight::HALO_RADIUS),
FilterParams::ReduceNoise { .. } => r(denoise::reach()),
FilterParams::SmartBlur { radius, .. } => r(*radius),
FilterParams::LensBlur { radius, .. } => r(*radius),
@@ -758,6 +775,12 @@ pub fn kernel(params: &FilterParams, src: &Image, out: Rect, ctx: &Ctx) -> Vec<f
white_is_high: *white_is_high,
},
),
FilterParams::Relight { angle, elevation, intensity, ambient, warmth, softness } => relight::relight(
src,
out,
ctx,
relight::Params { angle: *angle, elevation: *elevation, intensity: *intensity, ambient: *ambient, warmth: *warmth, softness: *softness },
),
FilterParams::ReduceNoise { strength, preserve_details, reduce_color_noise, sharpen_details, remove_jpeg_artifact } => denoise::reduce_noise(
src,
out,
@@ -860,6 +883,20 @@ pub fn apply_in(surface: &Surface, params: &FilterParams, area: Rect, bounds: Re
apply_tiled(surface, params, area.intersect(&extent), bounds, selection, auto_tile(params), Some(extent))
}
/// [`apply_in`] that can be cancelled (checked before each tile) and reports progress per tile
/// group. `None` when `ctl` was cancelled; the input surface is never modified.
pub fn apply_in_with(
surface: &Surface,
params: &FilterParams,
area: Rect,
bounds: Rect,
selection: Option<&Surface>,
extent: Rect,
ctl: &photocraft_raster::Interrupt,
) -> Option<Surface> {
apply_tiled_with(surface, params, area.intersect(&extent), bounds, selection, auto_tile(params), Some(extent), ctl)
}
/// Results do not depend on the tiling, so wide-halo filters use bigger tiles to keep the
/// re-read margin (and its cost) below ~2× the tile area.
fn auto_tile(params: &FilterParams) -> i32 {
@@ -879,9 +916,26 @@ pub fn apply_tiled(
tile: i32,
extent: Option<Rect>,
) -> Surface {
// Never cancelled, so always `Some`; the fallback (the input unchanged) is unreachable.
apply_tiled_with(surface, params, area, bounds, selection, tile, extent, &photocraft_raster::Interrupt::NONE).unwrap_or_else(|| surface.clone())
}
/// [`apply_tiled`] with cancellation (checked before each tile, so a cancel takes effect within
/// one tile's work) and progress (after each group of tiles). `None` when cancelled.
#[allow(clippy::too_many_arguments)]
pub fn apply_tiled_with(
surface: &Surface,
params: &FilterParams,
area: Rect,
bounds: Rect,
selection: Option<&Surface>,
tile: i32,
extent: Option<Rect>,
ctl: &photocraft_raster::Interrupt,
) -> Option<Surface> {
let mut out = surface.clone();
if area.is_empty() {
return out;
return Some(out);
}
let fmt = surface.format();
let ctx = Ctx { bounds, mode: fmt.mode, alpha: fmt.alpha };
@@ -897,7 +951,19 @@ pub fn apply_tiled(
}
y += tile;
}
// Tiles finished so far, for progress reported per tile (from any worker thread).
let finished = std::sync::atomic::AtomicUsize::new(0);
let total = tiles.len().max(1);
let run = |t: &Rect| -> (Rect, Vec<f32>) {
// Cancelled: skip the remaining tiles of the group (the result is discarded).
if ctl.cancelled() {
return (*t, Vec::new());
}
let tick = || {
let n = finished.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
// The last few percent are the writes after each group.
ctl.progress(0.98 * n as f32 / total as f32);
};
let owned;
let src = match (&shared, halo) {
(Some(s), _) => s,
@@ -930,6 +996,7 @@ pub fn apply_tiled(
}
}
}
tick();
(*t, data)
};
// Tiles are filtered in groups of about RESULT_BUDGET bytes (at least one per core) and each
@@ -942,6 +1009,9 @@ pub fn apply_tiled(
let per_tile = (tile.max(1) as usize).pow(2) * fmt.channels() * std::mem::size_of::<f32>();
let group = (RESULT_BUDGET / per_tile.max(1)).max(threads).max(1);
for chunk in tiles.chunks(group) {
if ctl.cancelled() {
return None;
}
#[cfg(not(target_arch = "wasm32"))]
let results: Vec<(Rect, Vec<f32>)> = {
use rayon::prelude::*;
@@ -949,12 +1019,16 @@ pub fn apply_tiled(
};
#[cfg(target_arch = "wasm32")]
let results: Vec<(Rect, Vec<f32>)> = chunk.iter().map(run).collect();
if ctl.cancelled() {
return None;
}
for (t, data) in results {
out.write_region(t, &data);
}
}
out.prune();
out
ctl.progress(1.0);
Some(out)
}
/// Bytes of float filter results held at once by [`apply_tiled`].
+144 -3
View File
@@ -38,6 +38,7 @@ pub enum LiquifyTool {
PushLeft,
Freeze,
Thaw,
LassoMask,
/// The Reconstruct… button: scales the whole (unfrozen) field toward zero by `amount` %.
ReconstructAll,
/// Mask Options › Mask All / None / Invert All (whole-field freeze edits).
@@ -47,7 +48,7 @@ pub enum LiquifyTool {
}
impl LiquifyTool {
pub const ALL: [LiquifyTool; 10] = [
pub const ALL: [LiquifyTool; 11] = [
LiquifyTool::ForwardWarp,
LiquifyTool::Reconstruct,
LiquifyTool::Smooth,
@@ -58,6 +59,7 @@ impl LiquifyTool {
LiquifyTool::PushLeft,
LiquifyTool::Freeze,
LiquifyTool::Thaw,
LiquifyTool::LassoMask,
];
pub fn label(self) -> &'static str {
@@ -72,6 +74,7 @@ impl LiquifyTool {
LiquifyTool::PushLeft => "Push Left",
LiquifyTool::Freeze => "Freeze Mask",
LiquifyTool::Thaw => "Thaw Mask",
LiquifyTool::LassoMask => "Freeze Lasso",
LiquifyTool::ReconstructAll => "Reconstruct All",
LiquifyTool::FreezeAll => "Mask All",
LiquifyTool::ThawAll => "Mask None",
@@ -86,7 +89,7 @@ impl LiquifyTool {
/// Tools that act while the brush is held still (each recorded point is a dab).
pub fn is_stationary(self) -> bool {
!matches!(self, LiquifyTool::ForwardWarp | LiquifyTool::PushLeft)
!matches!(self, LiquifyTool::ForwardWarp | LiquifyTool::PushLeft | LiquifyTool::LassoMask)
}
}
@@ -124,7 +127,8 @@ pub struct LiquifyStroke {
pub rate: f64,
#[serde(default)]
pub points: Vec<Vec<f64>>,
/// `reconstructAll` only: how much to restore, 0..100.
/// `reconstructAll` only: how much to restore, 0..100. `lassoMask`: 1.0/none freezes the
/// polygon, 0.0 thaws it.
#[serde(default)]
pub amount: Option<f64>,
}
@@ -216,6 +220,7 @@ impl LiquifyField {
/// Applies one stroke. Returns the document rectangle whose output changed.
pub fn apply_stroke(&mut self, s: &LiquifyStroke) -> Rect {
match s.tool {
LiquifyTool::LassoMask => return self.apply_lasso(s),
LiquifyTool::ReconstructAll => {
let k = (s.amount.unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32;
for (v, f) in self.d.iter_mut().zip(&self.freeze) {
@@ -242,6 +247,60 @@ impl LiquifyField {
dirty
}
/// Lasso: sets the freeze mask to `amount` (1.0 when none; 0.0 thaws) at every field node
/// inside the closed polygon in `s.points` (even-odd rule; non-finite points are skipped).
/// A scanline fill: each node row costs one pass over the edges, then only the nodes inside
/// are written. Returns the document rectangle the mask changed in.
fn apply_lasso(&mut self, s: &LiquifyStroke) -> Rect {
let poly: Vec<[f64; 2]> = s.points.iter().filter_map(|p| Some([*p.first()?, *p.get(1)?])).filter(|p| p[0].is_finite() && p[1].is_finite()).collect();
if poly.len() < 3 || self.w == 0 || self.h == 0 {
return Rect::EMPTY;
}
let (mut px0, mut py0, mut px1, mut py1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in &poly {
px0 = px0.min(p[0]);
py0 = py0.min(p[1]);
px1 = px1.max(p[0]);
py1 = py1.max(p[1]);
}
let (_, gy0) = self.to_grid(px0, py0);
let (_, gy1) = self.to_grid(px1, py1);
let last_row = self.h.saturating_sub(1) as f64;
let last_col = self.w.saturating_sub(1) as f64;
let (j0, j1) = (gy0.ceil().max(0.0), gy1.floor().min(last_row));
if j1 < j0 {
return Rect::EMPTY;
}
let v = if s.amount.unwrap_or(1.0) > 0.5 { 1.0f32 } else { 0.0 };
let mut xs: Vec<f64> = Vec::new();
for j in (j0 as usize)..=(j1 as usize) {
let y = self.node_pos(0, j)[1];
xs.clear();
let Some(mut prev) = poly.last().copied() else { return Rect::EMPTY };
for &a in &poly {
if (a[1] > y) != (prev[1] > y) {
xs.push(a[0] + (y - a[1]) * (prev[0] - a[0]) / (prev[1] - a[1]));
}
prev = a;
}
xs.sort_by(f64::total_cmp);
// Even-odd: nodes with xs[2k] <= x < xs[2k + 1] are inside.
for &[xa, xb] in xs.as_chunks::<2>().0 {
let (ga, _) = self.to_grid(xa, y);
let (gb, _) = self.to_grid(xb, y);
let (i0, i1) = (ga.ceil().max(0.0), (gb.ceil() - 1.0).min(last_col));
if i1 < i0 {
continue;
}
let row = j * self.w;
if let Some(cells) = self.freeze.get_mut(row + i0 as usize..=row + i1 as usize) {
cells.fill(v);
}
}
}
Rect::new(px0.floor() as i32, py0.floor() as i32, px1.ceil() as i32, py1.ceil() as i32).intersect(&self.bounds)
}
/// Starts a stroke at `p` (`[x, y, pressure]`): stationary tools dab once there. Interactive
/// callers use this plus [`Self::stroke_segment`] per new point, which is exactly what
/// [`Self::apply_stroke`] does with the whole point list.
@@ -276,6 +335,9 @@ impl LiquifyField {
/// One dab at `c` with brush motion `delta`. Returns the document rect it affected.
fn dab(&mut self, s: &LiquifyStroke, c: [f64; 2], delta: [f64; 2], point_pressure: f64) -> Rect {
if s.tool == LiquifyTool::LassoMask {
return Rect::EMPTY; // the lasso is polygon-only; never a brush dab
}
let r = (s.size / 2.0).max(0.5);
let strength = (s.pressure / 100.0).clamp(0.0, 1.0) * point_pressure;
let rate = (s.rate / 100.0).clamp(0.0, 1.0);
@@ -699,6 +761,85 @@ mod tests {
a.read_region(r).iter().zip(b.read_region(r)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
}
fn lasso(points: &[[f64; 2]], amount: Option<f64>) -> LiquifyStroke {
let mut s = LiquifyStroke::new(LiquifyTool::LassoMask, 1.0);
s.points = points.iter().map(|p| p.to_vec()).collect();
s.amount = amount;
s
}
/// Reference even-odd test (ray casting), to check the scanline fill against.
fn inside(p: [f64; 2], poly: &[[f64; 2]]) -> bool {
let mut inside = false;
let mut j = poly.len() - 1;
for (i, a) in poly.iter().enumerate() {
let b = poly[j];
if (a[1] > p[1]) != (b[1] > p[1]) && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] {
inside = !inside;
}
j = i;
}
inside
}
#[test]
fn lasso_freezes_exactly_the_nodes_inside_a_concave_polygon() {
// A concave "C" with a notch, at a non-integer cell size.
let poly = [[10.0, 8.0], [70.0, 8.0], [70.0, 20.0], [30.0, 22.5], [33.0, 40.0], [80.0, 41.0], [60.0, 58.0], [9.0, 55.0]];
for cell in [1.0, 2.0, 3.5] {
let mut f = LiquifyField::new(bounds(), cell);
let dirty = f.apply_stroke(&lasso(&poly, None));
assert_eq!(dirty, Rect::new(9, 8, 80, 58));
for j in 0..f.h {
for i in 0..f.w {
let want = if inside(f.node_pos(i, j), &poly) { 1.0 } else { 0.0 };
assert_eq!(f.freeze[j * f.w + i], want, "cell {cell}: node ({i}, {j}) at {:?}", f.node_pos(i, j));
}
}
assert!(f.freeze.contains(&1.0));
// Strokes replay to the same field (undo rebuilds from the list).
assert_eq!(LiquifyField::from_strokes(bounds(), cell, &[lasso(&poly, None)]), f);
}
}
#[test]
fn lasso_thaws_with_amount_zero() {
let mut f = LiquifyField::new(bounds(), 2.0);
f.apply_stroke(&LiquifyStroke::new(LiquifyTool::FreezeAll, 1.0));
assert!(f.freeze.iter().all(|&v| v == 1.0));
let square = [[20.0, 20.0], [40.0, 20.0], [40.0, 40.0], [20.0, 40.0]];
f.apply_stroke(&lasso(&square, Some(0.0)));
assert_eq!(f.freeze_at(30.0, 30.0), 0.0, "thawed inside");
assert_eq!(f.freeze_at(5.0, 5.0), 1.0, "still frozen outside");
}
#[test]
fn lasso_ignores_degenerate_and_hostile_polygons() {
let empty = LiquifyField::new(bounds(), 2.0);
let cases: [&[[f64; 2]]; 6] = [
&[],
&[[10.0, 10.0], [50.0, 50.0]],
&[[f64::NAN, 10.0], [50.0, f64::INFINITY], [20.0, 30.0]],
// Entirely outside the canvas, on every side.
&[[-50.0, -50.0], [-10.0, -50.0], [-10.0, -10.0]],
&[[200.0, 10.0], [300.0, 10.0], [250.0, 50.0]],
&[[10.0, 1e12], [50.0, 1e12], [30.0, 2e12]],
];
for poly in cases {
let mut f = empty.clone();
assert_eq!(f.apply_stroke(&lasso(poly, None)), Rect::EMPTY, "{poly:?}");
assert_eq!(f, empty, "{poly:?}");
}
// Huge coordinates around the canvas clamp to it instead of overflowing.
let mut f = empty.clone();
f.apply_stroke(&lasso(&[[-1e15, -1e15], [1e15, -1e15], [1e15, 1e15], [-1e15, 1e15]], None));
assert!(f.freeze.iter().all(|&v| v == 1.0));
// A NaN point among good ones is skipped.
let mut f = empty.clone();
f.apply_stroke(&lasso(&[[10.0, 10.0], [f64::NAN, 0.0], [50.0, 10.0], [50.0, 50.0], [10.0, 50.0]], None));
assert_eq!(f.freeze_at(30.0, 30.0), 1.0);
}
#[test]
fn no_strokes_is_identity_at_every_depth() {
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
+71 -22
View File
@@ -76,39 +76,41 @@ pub(crate) fn running_extreme(v: &[f32], k: usize, max: bool, out: &mut Vec<f32>
out.extend((0..=n - k).map(|i| pick(suf[i], pre[i + k - 1])));
}
/// Minimum (spreads dark/transparent areas) or Maximum over a square.
/// Minimum (spreads dark/transparent areas) or Maximum over a square. Separable (rows, then
/// columns), each pass a [`running_extreme`], so the cost per pixel does not grow with the radius.
pub(crate) fn min_max(src: &Image, out: Rect, radius: f32, max: bool) -> Vec<f32> {
let n = src.ch;
let r = radius.max(0.0).round() as i32;
if r == 0 {
return src.crop(out);
}
// Separable: rows then columns.
let k = (2 * r + 1) as usize;
// Rows: the extreme over x - r..=x + r, for every row the column pass needs.
let win = Rect::new(out.x0, out.y0 - r, out.x1, out.y1 + r);
let ww = win.width() as usize;
let mut tmp = vec![0.0f32; ww * win.height() as usize * n];
let (ww, wh) = (win.width() as usize, win.height() as usize);
let mut tmp = vec![0.0f32; ww * wh * n];
let (mut line, mut ext) = (Vec::new(), Vec::new());
for y in win.y0..win.y1 {
for x in win.x0..win.x1 {
for c in 0..n {
let mut m = if max { f32::MIN } else { f32::MAX };
for xx in x - r..=x + r {
let v = src.get(xx, y, c);
m = if max { m.max(v) } else { m.min(v) };
}
tmp[((y - win.y0) as usize * ww + (x - win.x0) as usize) * n + c] = m;
let o = (y - win.y0) as usize * ww * n;
for c in 0..n {
line.clear();
line.extend((win.x0 - r..win.x1 + r).map(|x| src.get(x, y, c)));
running_extreme(&line, k, max, &mut ext);
for (x, v) in ext.iter().enumerate() {
tmp[o + x * n + c] = *v;
}
}
}
let mut res = Vec::with_capacity(out.width() as usize * out.height() as usize * n);
for y in out.y0..out.y1 {
for x in out.x0..out.x1 {
for c in 0..n {
let mut m = if max { f32::MIN } else { f32::MAX };
for yy in y - r..=y + r {
let v = tmp[((yy - win.y0) as usize * ww + (x - win.x0) as usize) * n + c];
m = if max { m.max(v) } else { m.min(v) };
}
res.push(m);
// Columns: the extreme over y - r..=y + r of the row results.
let (ow, oh) = (out.width() as usize, out.height() as usize);
let mut res = vec![0.0f32; ow * oh * n];
for x in 0..ow {
for c in 0..n {
line.clear();
line.extend((0..wh).map(|y| tmp[(y * ww + x) * n + c]));
running_extreme(&line, k, max, &mut ext);
for (y, v) in ext.iter().enumerate() {
res[(y * ow + x) * n + c] = *v;
}
}
}
@@ -137,3 +139,50 @@ pub(crate) fn offset(src: &Image, out: Rect, ctx: &Ctx, dx: i32, dy: i32, undefi
}
res
}
#[cfg(test)]
mod tests {
use super::*;
/// The square window scanned directly, the reference for [`min_max`].
fn min_max_direct(src: &Image, out: Rect, r: i32, max: bool) -> Vec<f32> {
let mut res = Vec::new();
for y in out.y0..out.y1 {
for x in out.x0..out.x1 {
for c in 0..src.ch {
let mut m = if max { f32::MIN } else { f32::MAX };
for yy in y - r..=y + r {
for xx in x - r..=x + r {
let v = src.get(xx, yy, c);
m = if max { m.max(v) } else { m.min(v) };
}
}
res.push(m);
}
}
}
res
}
#[test]
fn square_min_max_matches_the_direct_window_exactly() {
let src_rect = Rect::new(-12, -9, 37, 30);
let mut img = Image::new(src_rect, 4);
let mut s = 0x2545_f491u32;
for v in img.data.iter_mut() {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
*v = (s % 1000) as f32 / 999.0;
}
// Includes windows wider than the output and output reaching past the source.
for out in [Rect::new(0, 0, 25, 21), Rect::new(-14, -3, 3, 40)] {
for r in [1, 2, 3, 7, 20] {
for max in [false, true] {
let fast = min_max(&img, out, r as f32, max);
assert_eq!(fast, min_max_direct(&img, out, r, max), "r {r} max {max} out {out:?}");
}
}
}
}
}
+1 -11
View File
@@ -111,16 +111,6 @@ fn composite_area(
}
/// Gradient tool over `area` (the selection bounds or canvas).
#[allow(clippy::too_many_arguments)]
/// Per-pixel dither noise in [0, 1) from a position hash (decorrelated, no visible pattern).
fn dither_noise(x: i32, y: i32) -> f32 {
let mut h = (x as u32).wrapping_mul(0x9E37_79B1) ^ (y as u32).wrapping_mul(0x85EB_CA77);
h ^= h >> 15;
h = h.wrapping_mul(0x2C1B_3C6D);
h ^= h >> 12;
f32::from((h & 0xFFFF) as u16) / 65535.0
}
#[allow(clippy::too_many_arguments)]
pub fn paint_gradient(
s: &mut Surface,
@@ -145,7 +135,7 @@ pub fn paint_gradient(
let mut c = sample_stops(stops, if reverse { 1.0 - t } else { t });
if dither {
// One quantisation step of monochromatic noise breaks 8-bit banding without speckle.
let n = (dither_noise(x, y) - 0.5) / 255.0;
let n = (photocraft_color::dither_noise(x, y) - 0.5) / 255.0;
for ch in c.iter_mut().take(3) {
*ch = (*ch + n).clamp(0.0, 1.0);
}
+34 -6
View File
@@ -31,10 +31,13 @@ const FINE_OMEGA: f32 = 1.7;
pub fn solve_membrane(w: usize, h: usize, unknown: &[bool], v: &mut [f32]) {
assert_eq!(unknown.len(), w * h);
assert_eq!(v.len(), w * h);
solve_rec(w, h, unknown, v, 0);
solve_rec(w, h, unknown, v, 0, &|| false);
}
fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize) {
fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize, stop: &(dyn Fn() -> bool + Sync)) {
if stop() {
return;
}
let n_unknown = unknown.iter().filter(|u| **u).count();
if n_unknown == 0 {
return;
@@ -64,7 +67,7 @@ fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize)
}
}
}
solve_rec(cw, chh, &cu, &mut cv, depth + 1);
solve_rec(cw, chh, &cu, &mut cv, depth + 1, stop);
// Prolong: bilinear sample of the coarse solution as the initial guess.
for y in 0..h {
let fy = ((y as f32 + 0.5) / 2.0 - 0.5).clamp(0.0, (chh - 1) as f32);
@@ -84,7 +87,7 @@ fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize)
v[y * w + x] = a + (b - a) * ty;
}
}
sor(w, h, unknown, v, FINE_ITERS, FINE_OMEGA);
sor(w, h, unknown, v, FINE_ITERS, FINE_OMEGA, stop);
} else {
let mean = known.iter().sum::<f32>() / known.len() as f32;
for (x, u) in v.iter_mut().zip(unknown) {
@@ -92,12 +95,12 @@ fn solve_rec(w: usize, h: usize, unknown: &[bool], v: &mut [f32], depth: usize)
*x = mean;
}
}
sor(w, h, unknown, v, 2000, 1.85);
sor(w, h, unknown, v, 2000, 1.85, stop);
}
}
/// SOR sweeps over the unknown pixels until the largest update drops below [`TOL`].
fn sor(w: usize, h: usize, unknown: &[bool], v: &mut [f32], max_iter: usize, omega: f32) {
fn sor(w: usize, h: usize, unknown: &[bool], v: &mut [f32], max_iter: usize, omega: f32, stop: &(dyn Fn() -> bool + Sync)) {
// Precompute the unknown pixels and which neighbours exist.
let cells: Vec<(u32, u8)> = (0..w * h)
.filter(|&i| unknown[i])
@@ -108,6 +111,9 @@ fn sor(w: usize, h: usize, unknown: &[bool], v: &mut [f32], max_iter: usize, ome
})
.collect();
for _ in 0..max_iter {
if stop() {
return;
}
let mut max_d = 0.0f32;
for &(i, m) in &cells {
let i = i as usize;
@@ -229,6 +235,28 @@ pub fn membrane_fill(w: usize, h: usize, ch: usize, img: &[f32], hole: &[bool])
})
}
/// [`membrane_fill`] that stops at the next SOR sweep once `ctl` is cancelled.
pub fn membrane_fill_with(
w: usize,
h: usize,
ch: usize,
img: &[f32],
hole: &[bool],
ctl: &photocraft_raster::Interrupt,
) -> Result<Vec<f32>, photocraft_raster::Cancelled> {
ctl.check()?;
let stop = || ctl.cancelled();
let out = per_channel(w, h, ch, |c| {
let mut v: Vec<f32> = (0..w * h).map(|i| if hole[i] { 0.0 } else { img[i * ch + c] }).collect();
if hole.len() == w * h && v.len() == w * h {
solve_rec(w, h, hole, &mut v, 0, &stop);
}
v
});
ctl.check()?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
+453
View File
@@ -0,0 +1,453 @@
//! Photographic relight: recover shading with a blur (Retinex) and replace it with a
//! Lambertian lamp whose direction comes from angle / elevation.
use photocraft_geom::Rect;
use crate::Ctx;
use crate::fxutil::{MAXC, rgba, set_rgba, xy};
use crate::image::Image;
/// Neighbourhood the kernel reads: `3σ + 1` at `σ = 32` (the typical cap for parallel tiles).
pub(crate) const HALO_RADIUS: i32 = 97;
/// Dialog units (degrees / 0–100), matching the command params.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Params {
pub angle: f32,
pub elevation: f32,
pub intensity: f32,
pub ambient: f32,
pub warmth: f32,
pub softness: f32,
}
/// Blur σ from softness and the reference bounds, clamped to `[2, 96]`.
pub(crate) fn sigma(softness: f32, bounds: Rect) -> f32 {
let side = bounds.width().min(bounds.height()) as f32;
let s = (softness.clamp(1.0, 100.0) / 100.0) * 0.08 * side.max(1.0);
s.clamp(2.0, 96.0)
}
/// Infinite-light direction: 0° from the right, 90° from above (image space, y down).
fn light_dir(angle: f32, elevation: f32) -> [f32; 3] {
let (s, co) = angle.to_radians().sin_cos();
let e = elevation.clamp(0.0, 90.0).to_radians();
norm3([co * e.cos(), -s * e.cos(), e.sin()])
}
fn norm3(v: [f32; 3]) -> [f32; 3] {
let m = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if !m.is_finite() || m < 1e-9 { [0.0, 0.0, 1.0] } else { [v[0] / m, v[1] / m, v[2] / m] }
}
fn light_color(warmth: f32) -> [f32; 3] {
let w = warmth.clamp(-100.0, 100.0) / 100.0;
if w >= 0.0 {
[1.0 + 0.15 * w, 1.0 - 0.05 * w, 1.0 - 0.25 * w]
} else {
let t = -w;
[1.0 - 0.20 * t, 1.0 - 0.10 * t, 1.0 + 0.15 * t]
}
}
fn rec709_y(c: [f32; 4]) -> f32 {
0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
fn finite_all(v: &[f32]) -> bool {
v.iter().all(|x| x.is_finite())
}
fn sample_y(buf: &[f32], w: usize, h: usize, rect: Rect, x: i32, y: i32) -> f32 {
if rect.is_empty() || w == 0 || h == 0 {
return 0.04;
}
let xx = x.clamp(rect.x0, rect.x1.saturating_sub(1));
let yy = y.clamp(rect.y0, rect.y1.saturating_sub(1));
let ox = (xx - rect.x0) as usize;
let oy = (yy - rect.y0) as usize;
if ox >= w || oy >= h {
return 0.04;
}
buf.get(oy.saturating_mul(w).saturating_add(ox)).copied().filter(|v| v.is_finite()).unwrap_or(0.04)
}
/// Separable Gaussian of a single-channel field. Non-finite samples are skipped (kernel
/// renormalised) so a bad pixel does not poison its neighbours.
fn blur_channel(src: &[f32], w: usize, h: usize, sigma: f32) -> Vec<f32> {
let n = w.saturating_mul(h);
if n == 0 || src.len() < n {
return vec![0.04; n];
}
let k = crate::blur::gaussian_kernel(sigma);
let r = (k.len() / 2) as i32;
let acc_at = |buf: &[f32], ww: usize, hh: usize, x: i32, y: i32, col: bool| -> f32 {
let mut acc = 0.0f64;
let mut wgt = 0.0f64;
for (i, kv) in k.iter().enumerate() {
let t = i as i32 - r;
let (sx, sy) = if col { (x, y + t) } else { (x + t, y) };
if sx < 0 || sy < 0 || (sx as usize) >= ww || (sy as usize) >= hh {
continue;
}
let Some(v) = buf.get((sy as usize).saturating_mul(ww).saturating_add(sx as usize)) else {
continue;
};
if !v.is_finite() {
continue;
}
acc += *v as f64 * *kv as f64;
wgt += *kv as f64;
}
if wgt > 1e-12 { (acc / wgt) as f32 } else { 0.5 }
};
let mut tmp = vec![0.0f32; n];
for y in 0..h {
for x in 0..w {
if let Some(slot) = tmp.get_mut(y.saturating_mul(w).saturating_add(x)) {
*slot = acc_at(src, w, h, x as i32, y as i32, false);
}
}
}
let mut out = vec![0.0f32; n];
for y in 0..h {
for x in 0..w {
if let Some(slot) = out.get_mut(y.saturating_mul(w).saturating_add(x)) {
*slot = acc_at(&tmp, w, h, x as i32, y as i32, true);
}
}
}
out
}
fn read_rgba(src: &Image, ctx: &Ctx, x: i32, y: i32) -> [f32; 4] {
let n = src.ch.min(MAXC);
let mut tmp = [0.0f32; MAXC];
for c in 0..n {
if let Some(slot) = tmp.get_mut(c) {
*slot = src.get(x, y, c);
}
}
rgba(ctx, &tmp[..n])
}
/// Relight `out` of `src`. Identity when `intensity` is 0 (or not finite).
pub(crate) fn relight(src: &Image, out: Rect, ctx: &Ctx, p: Params) -> Vec<f32> {
let n = src.ch;
if out.is_empty() || n == 0 {
return src.crop(out);
}
let mut res = src.crop(out);
if !p.intensity.is_finite() || p.intensity == 0.0 {
return res;
}
let sig = sigma(p.softness, ctx.bounds);
let radius = (sig * 3.0).ceil() as i32;
// Finite differences at the shading scale (`σ`), not 1 px: a 1 px step vanishes on
// large documents and the lamp collapses to a flat multiply.
const SCALE: f32 = 2.0;
let step = sig.round().clamp(1.0, 32.0) as i32;
let s_rect = out.inflate(step.max(1));
let y_rect = s_rect.inflate(radius.max(0));
if y_rect.is_empty() {
return res;
}
let yw = y_rect.width() as usize;
let yh = y_rect.height() as usize;
let n_px = yw.saturating_mul(yh);
if n_px == 0 || n_px > 8_000_000 {
return res;
}
let mut ybuf = vec![0.5f32; n_px];
for y in y_rect.y0..y_rect.y1 {
for x in y_rect.x0..y_rect.x1 {
let ox = (x - y_rect.x0) as usize;
let oy = (y - y_rect.y0) as usize;
let Some(slot) = ybuf.get_mut(oy.saturating_mul(yw).saturating_add(ox)) else {
continue;
};
let c = read_rgba(src, ctx, x, y);
if !finite_all(&c) {
*slot = f32::NAN;
continue;
}
let a = if ctx.alpha { c[3] } else { 1.0 };
*slot = if a > 0.0 && a.is_finite() { rec709_y(c) } else { f32::NAN };
}
}
let sbuf = blur_channel(&ybuf, yw, yh, sig);
let ldir = light_dir(p.angle, p.elevation);
let lcol = light_color(p.warmth);
let amb = (if p.ambient.is_finite() { p.ambient } else { 0.0 }).clamp(0.0, 100.0) / 100.0;
let inten = p.intensity.clamp(0.0, 100.0) / 100.0;
for (i, px) in res.chunks_exact_mut(n).enumerate() {
let (x, y) = xy(out, i);
if !finite_all(px) {
continue;
}
let a = if ctx.alpha { px[n - 1] } else { 1.0 };
if ctx.alpha && a <= 0.0 {
continue;
}
let c = rgba(ctx, px);
if !finite_all(&c) {
continue;
}
let s0 = sample_y(&sbuf, yw, yh, y_rect, x, y).max(0.04);
let nx = (sample_y(&sbuf, yw, yh, y_rect, x - step, y) - sample_y(&sbuf, yw, yh, y_rect, x + step, y)) * SCALE;
let ny = (sample_y(&sbuf, yw, yh, y_rect, x, y - step) - sample_y(&sbuf, yw, yh, y_rect, x, y + step)) * SCALE;
let nrm = norm3([nx, ny, 1.0]);
let ndl = (nrm[0] * ldir[0] + nrm[1] * ldir[1] + nrm[2] * ldir[2]).max(0.0);
let shade = amb + inten * ndl;
let mut out_c = c;
for (k, lc) in lcol.iter().enumerate() {
let Some(ch) = c.get(k).copied() else { continue };
let Some(slot) = out_c.get_mut(k) else { continue };
*slot = (ch / s0) * shade * *lc;
}
if !finite_all(&out_c) {
continue;
}
set_rgba(ctx, px, out_c);
if ctx.alpha
&& let Some(slot) = px.get_mut(n - 1)
{
*slot = a;
}
}
res
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{FilterParams, Halo, apply, apply_tiled, output_area};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_raster::Surface;
fn fmt(s: SampleType) -> PixelFormat {
PixelFormat::new(ColorMode::Rgb, s, true)
}
fn gray_fmt(s: SampleType) -> PixelFormat {
PixelFormat::new(ColorMode::Grayscale, s, true)
}
fn params(intensity: f32, angle: f32, warmth: f32, softness: f32) -> FilterParams {
FilterParams::Relight { angle, elevation: 40.0, intensity, ambient: 55.0, warmth, softness }
}
fn relight_p(angle: f32, elevation: f32, intensity: f32, ambient: f32, warmth: f32, softness: f32) -> FilterParams {
FilterParams::Relight { angle, elevation, intensity, ambient, warmth, softness }
}
fn run(s: &Surface, p: &FilterParams, bounds: Rect) -> Surface {
let area = output_area(p, s.content_bounds(), bounds, None);
apply(s, p, area, bounds, None)
}
fn max_diff(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
}
fn luma(px: &[f32]) -> f32 {
0.2126 * px[0] + 0.7152 * px[1] + 0.0722 * px[2]
}
fn shaded_sphere(st: SampleType, size: i32) -> Surface {
let r = Rect::new(0, 0, size, size);
let mut surf = Surface::new(fmt(st));
let cx = size as f32 / 2.0;
let rad = size as f32 * 0.42;
let mut v = Vec::new();
for y in 0..size {
for x in 0..size {
let dx = x as f32 + 0.5 - cx;
let dy = y as f32 + 0.5 - cx;
let d2 = dx * dx + dy * dy;
if d2 <= rad * rad {
let nz = (rad * rad - d2).max(0.0).sqrt() / rad;
let sh = 0.22 + 0.78 * nz;
v.extend_from_slice(&[0.55 * sh, 0.55 * sh, 0.55 * sh, 1.0]);
} else {
v.extend_from_slice(&[0.18, 0.18, 0.18, 1.0]);
}
}
}
surf.write_region(r, &v);
surf
}
fn sphere_half_mean(s: &Surface, size: i32, left: bool) -> f32 {
let cx = size as f32 / 2.0;
let rad = size as f32 * 0.42;
let mut sum = 0.0;
let mut n: f32 = 0.0;
for y in 0..size {
for x in 0..size {
let dx = x as f32 + 0.5 - cx;
let dy = y as f32 + 0.5 - cx;
if dx * dx + dy * dy > rad * rad {
continue;
}
if left && (x as f32) >= cx - 4.0 {
continue;
}
if !left && (x as f32) < cx + 4.0 {
continue;
}
sum += luma(&s.pixel(x, y));
n += 1.0;
}
}
sum / n.max(1.0)
}
fn pattern(st: SampleType, r: Rect) -> Surface {
let mut surf = Surface::new(fmt(st));
let mut v = Vec::new();
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
v.extend_from_slice(&[((x * 7 + y * 3) % 64) as f32 / 63.0, ((x * x + y) % 50) as f32 / 49.0, (y % 9) as f32 / 8.0, 1.0]);
}
}
surf.write_region(r, &v);
surf
}
fn flat(st: SampleType, r: Rect, px: [f32; 4]) -> Surface {
let mut surf = Surface::new(fmt(st));
surf.fill_rect(r, &px);
surf
}
#[test]
fn halo_is_zero_at_identity_and_97_otherwise() {
assert_eq!(params(0.0, 45.0, 0.0, 25.0).halo(), Halo::Radius(0));
assert_eq!(params(40.0, 45.0, 0.0, 25.0).halo(), Halo::Radius(HALO_RADIUS));
}
#[test]
fn identity_at_zero_intensity_all_depths() {
let r = Rect::new(0, 0, 24, 16);
let p = params(0.0, 120.0, 80.0, 90.0);
for st in [SampleType::U8, SampleType::U16, SampleType::F32] {
let s = pattern(st, r);
let out = run(&s, &p, r);
assert_eq!(out.read_region(r), s.read_region(r), "{st:?}");
}
let mut g = Surface::new(gray_fmt(SampleType::U8));
g.fill_rect(r, &[0.4, 1.0]);
let out = run(&g, &p, r);
assert_eq!(out.read_region(r), g.read_region(r));
let mut g32 = Surface::new(gray_fmt(SampleType::F32));
g32.fill_rect(r, &[0.4, 1.0]);
let out = run(&g32, &p, r);
assert_eq!(out.read_region(r), g32.read_region(r));
let mut g16 = Surface::new(gray_fmt(SampleType::U16));
g16.fill_rect(r, &[0.4, 1.0]);
let out = run(&g16, &p, r);
assert_eq!(out.read_region(r), g16.read_region(r));
}
fn assert_direction_flips(size: i32) {
let bounds = Rect::new(0, 0, size, size);
let s = shaded_sphere(SampleType::F32, size);
let left_lit = run(&s, &relight_p(180.0, 30.0, 90.0, 12.0, 0.0, 15.0), bounds);
let right_lit = run(&s, &relight_p(0.0, 30.0, 90.0, 12.0, 0.0, 15.0), bounds);
let l_left = sphere_half_mean(&left_lit, size, true);
let l_right = sphere_half_mean(&left_lit, size, false);
let r_left = sphere_half_mean(&right_lit, size, true);
let r_right = sphere_half_mean(&right_lit, size, false);
assert!(l_left - l_right >= 0.05, "light from the left ({size}px): {l_left} vs {l_right}");
assert!(r_right - r_left >= 0.05, "light from the right ({size}px): {r_left} vs {r_right}");
}
#[test]
fn direction_flips_on_a_shaded_sphere() {
assert_direction_flips(64);
}
#[test]
fn direction_flips_on_a_large_shaded_sphere() {
// A 1 px gradient vanishes at this size unless the kernel steps by σ.
assert_direction_flips(256);
}
#[test]
fn warmth_tints_a_neutral_field() {
let r = Rect::new(0, 0, 32, 32);
let s = flat(SampleType::F32, r, [0.5, 0.5, 0.5, 1.0]);
let warm = run(&s, &relight_p(45.0, 40.0, 50.0, 55.0, 80.0, 25.0), r);
let cool = run(&s, &relight_p(45.0, 40.0, 50.0, 55.0, -80.0, 25.0), r);
let w = warm.pixel(16, 16);
let c = cool.pixel(16, 16);
assert!(w[0] > w[2], "warmth should raise R over B: {w:?}");
assert!(c[2] > c[0], "cool light should raise B over R: {c:?}");
}
#[test]
fn high_ambient_keeps_white_finite() {
let r = Rect::new(0, 0, 16, 16);
let s = flat(SampleType::F32, r, [1.0, 1.0, 1.0, 1.0]);
let out = run(&s, &relight_p(45.0, 40.0, 40.0, 100.0, 0.0, 25.0), r);
let px = out.pixel(8, 8);
assert!(finite_all(&px), "{px:?}");
}
#[test]
fn alpha_is_preserved() {
let r = Rect::new(0, 0, 16, 16);
let mut s = Surface::new(fmt(SampleType::F32));
s.fill_rect(r, &[0.4, 0.4, 0.5, 1.0]);
s.write_pixel(3, 3, &[0.4, 0.4, 0.5, 0.0]);
s.write_pixel(4, 4, &[0.2, 0.3, 0.4, 0.5]);
let out = run(&s, &params(50.0, 180.0, 20.0, 25.0), r);
assert_eq!(out.pixel(3, 3)[3], 0.0);
assert!((out.pixel(5, 5)[3] - 1.0).abs() < 1e-6);
assert!((out.pixel(4, 4)[3] - 0.5).abs() < 1e-6);
}
#[test]
fn tiles_match_a_full_apply() {
let size = 64;
let bounds = Rect::new(0, 0, size, size);
let s = shaded_sphere(SampleType::F32, size);
let p = relight_p(180.0, 40.0, 60.0, 40.0, 0.0, 25.0);
let area = output_area(&p, s.content_bounds(), bounds, None);
let full = apply_tiled(&s, &p, area, bounds, None, 256, None);
let tiled = apply_tiled(&s, &p, area, bounds, None, 32, None);
let inner = Rect::new(0, 0, size, size);
let d = max_diff(&full.read_region(inner), &tiled.read_region(inner));
assert!(d <= 1e-4, "tile seam {d}");
}
#[test]
fn hostile_sizes_do_not_panic() {
let p = params(40.0, 45.0, 0.0, 25.0);
for (w, h) in [(1, 1), (2, 2), (1, 2), (0, 0)] {
let r = Rect::new(0, 0, w, h);
let mut s = Surface::new(fmt(SampleType::F32));
if w > 0 && h > 0 {
s.fill_rect(r, &[0.4, 0.5, 0.6, 1.0]);
}
let _ = run(&s, &p, if r.is_empty() { Rect::new(0, 0, 1, 1) } else { r });
let _ = apply(&s, &p, Rect::EMPTY, Rect::new(0, 0, 4, 4), None);
}
}
#[test]
fn nan_and_inf_pixels_pass_through() {
let r = Rect::new(0, 0, 24, 24);
let mut s = pattern(SampleType::F32, r);
s.write_pixel(5, 5, &[f32::NAN, 0.4, 0.4, 1.0]);
s.write_pixel(6, 8, &[f32::INFINITY, 0.2, 0.2, 1.0]);
let before_n = s.pixel(7, 5);
let out = run(&s, &params(50.0, 180.0, 0.0, 20.0), r);
assert!(out.pixel(5, 5)[0].is_nan());
assert!(out.pixel(6, 8)[0].is_infinite());
let n = out.pixel(7, 5);
assert!(finite_all(&n));
assert_ne!(n, before_n);
}
}
+61 -6
View File
@@ -71,15 +71,17 @@ struct Taps {
}
/// Taps for mapping output coordinate `o` (in `[dst0, dst1)`) to the source
/// via `u = (o + 0.5) / scale - 0.5`.
fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample) -> Vec<Taps> {
/// via `u = (o + 0.5) / scale - 0.5`. With `edge` (`[first, last]` source index), taps outside it
/// read the nearest sample inside it: the image repeats its edge instead of fading out.
fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample, edge: Option<(i32, i32)>) -> Vec<Taps> {
let stretch = if scale < 1.0 { 1.0 / scale } else { 1.0 };
let support = filter.support() * stretch;
let clamp = |i: i32| edge.map_or(i, |(a, b)| i.clamp(a, b));
(dst0..dst1)
.map(|o| {
let u = (o as f64 + 0.5) / scale - 0.5;
if filter == Resample::Nearest && scale >= 1.0 {
return Taps { start: (u + 0.5).floor() as i32, weights: vec![1.0] };
return Taps { start: clamp((u + 0.5).floor() as i32), weights: vec![1.0] };
}
let lo = (u - support).ceil() as i32;
let hi = (u + support).floor() as i32;
@@ -90,7 +92,15 @@ fn taps(dst0: i32, dst1: i32, scale: f64, filter: Resample) -> Vec<Taps> {
*v /= s;
}
}
Taps { start: lo, weights: w.into_iter().map(|v| v as f32).collect() }
// Fold the weights of taps beyond the edge onto the edge sample.
let start = clamp(lo);
let mut folded = vec![0.0f32; (clamp(hi) - start + 1).max(1) as usize];
for (i, v) in (lo..=hi).zip(w) {
if let Some(slot) = folded.get_mut((clamp(i) - start) as usize) {
*slot += v as f32;
}
}
Taps { start, weights: folded }
})
.collect()
}
@@ -106,6 +116,19 @@ pub fn scaled_rect(r: Rect, sx: f64, sy: f64) -> Rect {
/// Resizes a surface by `(sx, sy)` about the document origin. The surface's
/// default pixel is kept (masks keep their "reveal all" background).
pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surface {
resize(s, sx, sy, filter, None)
}
/// [`resize_surface`] for a layer, mask or channel of a document whose canvas is `canvas`
/// (Image Size): at the canvas edges the pixels repeat outwards, so content that reaches an edge
/// keeps it (an opaque layer stays opaque to the border) instead of fading into the default pixel.
/// Pixels beyond the canvas count as the surface's own, and inside the canvas nothing changes.
pub fn resize_surface_in_canvas(s: &Surface, sx: f64, sy: f64, filter: Resample, canvas: Rect) -> Surface {
let area = s.content_bounds().union(&canvas);
resize(s, sx, sy, filter, (!area.is_empty()).then_some(area))
}
fn resize(s: &Surface, sx: f64, sy: f64, filter: Resample, edge: Option<Rect>) -> Surface {
let fmt = s.format();
let mut out = Surface::with_default(fmt, &s.default_pixel());
let src = s.content_bounds();
@@ -115,8 +138,8 @@ pub fn resize_surface(s: &Surface, sx: f64, sy: f64, filter: Resample) -> Surfac
}
let n = fmt.channels();
let alpha = fmt.alpha;
let hx = taps(dst.x0, dst.x1, sx, filter);
let vy = taps(dst.y0, dst.y1, sy, filter);
let hx = taps(dst.x0, dst.x1, sx, filter, edge.map(|r| (r.x0, r.x1 - 1)));
let vy = taps(dst.y0, dst.y1, sy, filter, edge.map(|r| (r.y0, r.y1 - 1)));
let x_lo = hx.iter().map(|t| t.start).min().unwrap_or(0);
let x_hi = hx.iter().map(|t| t.start + t.weights.len() as i32).max().unwrap_or(0);
let dw = dst.width() as usize;
@@ -268,6 +291,38 @@ mod tests {
assert!(edge[3] > 0.0 && (edge[0] - 1.0).abs() < 0.05 && edge[1].abs() < 0.05, "{edge:?}");
}
/// Image Size of a layer that fills the canvas: its edges stay opaque and keep their colour,
/// up or down, at every depth; a layer away from the edges keeps its soft border.
#[test]
fn canvas_edges_repeat_instead_of_fading() {
let canvas = Rect::new(0, 0, 30, 20);
let colour = [0.2, 0.6, 1.0, 1.0];
let filters = [Resample::Nearest, Resample::Bilinear, Resample::Bicubic, Resample::Lanczos, Resample::PreserveDetails];
for sample in [SampleType::U8, SampleType::U16, SampleType::F32] {
let fmt = PixelFormat::new(ColorMode::Rgb, sample, true);
let mut s = Surface::new(fmt);
s.fill_rect(canvas, &colour);
for f in filters {
for k in [2.0, 1.37, 0.5] {
let o = resize_surface_in_canvas(&s, k, k, f, canvas);
let dst = scaled_rect(canvas, k, k);
assert_eq!(o.content_bounds(), dst, "{sample:?} {f:?} {k}");
for (x, y) in [(0, 0), (dst.x1 - 1, 0), (0, dst.y1 - 1), (dst.x1 - 1, dst.y1 - 1), (dst.x1 / 2, 0), (0, dst.y1 / 2)] {
let p = o.pixel(x, y);
let off = p.iter().zip(colour).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(off < 0.01, "{sample:?} {f:?} {k} ({x},{y}): {p:?}");
}
}
}
// Inside the canvas the default pixel is still what lies around a layer.
let mut inner = Surface::new(fmt);
inner.fill_rect(Rect::new(10, 5, 20, 15), &[1.0, 0.0, 0.0, 1.0]);
let r = Rect::new(0, 0, 60, 40);
let a = resize_surface_in_canvas(&inner, 1.7, 1.7, Resample::Lanczos, canvas);
assert_eq!(a.read_region(r), resize_surface(&inner, 1.7, 1.7, Resample::Lanczos).read_region(r), "{sample:?}");
}
}
#[test]
fn masks_keep_default_pixel() {
let fmt = PixelFormat::GRAY8;
+516 -87
View File
@@ -11,6 +11,8 @@
//! seams avoid protected pixels. Buffers are interleaved `w × h × ch` normalised floats of any
//! colour model and depth (alpha is carried like any other channel). Deterministic.
use photocraft_raster::{Cancelled, Interrupt};
/// Energy added per unit of protection: far above any gradient magnitude.
const PROTECT_ENERGY: f32 = 1.0e4;
@@ -19,69 +21,206 @@ const PROTECT_ENERGY: f32 = 1.0e4;
/// carry energy too.
pub fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
let mut e = vec![0.0f32; w * h];
for y in 0..h {
for x in 0..w {
let (xl, xr) = (x.saturating_sub(1), (x + 1).min(w - 1));
let (yu, yd) = (y.saturating_sub(1), (y + 1).min(h - 1));
let at = |xx: usize, yy: usize, c: usize| img[(yy * w + xx) * ch + c];
let mut s = 0.0;
for c in 0..ch {
let v = at(x, y, c);
s += (at(xr, y, c) - v).abs() + (v - at(xl, y, c)).abs();
s += (at(x, yd, c) - v).abs() + (v - at(x, yu, c)).abs();
}
e[y * w + x] = s * 0.5;
}
if w == 0 || h == 0 {
return e;
}
let row = |(y, out): (usize, &mut [f32])| {
for (x, v) in out.iter_mut().enumerate() {
*v = energy_at(img, w, w, h, ch, x, y);
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
e.par_chunks_mut(w).enumerate().for_each(row);
}
#[cfg(target_arch = "wasm32")]
e.chunks_mut(w).enumerate().for_each(row);
e
}
/// [`energy`] of one pixel of a `cw`-wide image whose rows are `stride` pixels apart. Shared by
/// the full map and the per-seam updates so both give bit-identical values.
#[inline]
fn energy_at(img: &[f32], stride: usize, cw: usize, h: usize, ch: usize, x: usize, y: usize) -> f32 {
let (xl, xr) = (x.saturating_sub(1), (x + 1).min(cw - 1));
let (yu, yd) = (y.saturating_sub(1), (y + 1).min(h - 1));
let at = |xx: usize, yy: usize, c: usize| img[(yy * stride + xx) * ch + c];
let mut s = 0.0;
for c in 0..ch {
let v = at(x, y, c);
s += (at(xr, y, c) - v).abs() + (v - at(xl, y, c)).abs();
s += (at(x, yd, c) - v).abs() + (v - at(x, yu, c)).abs();
}
s * 0.5
}
/// The lowest-energy vertical seam: one x per row.
pub fn find_seam(w: usize, h: usize, energy: &[f32]) -> Vec<usize> {
let mut cost = energy[..w].to_vec();
let mut from = vec![0u8; w * h]; // 0 = up-left, 1 = up, 2 = up-right
let mut next = vec![0.0f32; w];
for y in 1..h {
for x in 0..w {
let mut best = (cost[x], 1u8);
if x > 0 && cost[x - 1] < best.0 {
best = (cost[x - 1], 0);
}
if x + 1 < w && cost[x + 1] < best.0 {
best = (cost[x + 1], 2);
}
next[x] = best.0 + energy[y * w + x];
from[y * w + x] = best.1;
}
std::mem::swap(&mut cost, &mut next);
if w == 0 || h == 0 || energy.len() < w * h {
return vec![0; h];
}
let mut x = (0..w).min_by(|a, b| cost[*a].total_cmp(&cost[*b])).unwrap_or(0);
let mut seam = vec![0usize; h];
for y in (0..h).rev() {
seam[y] = x;
if y > 0 {
x = match from[y * w + x] {
0 => x - 1,
2 => x + 1,
_ => x,
};
}
}
seam
Dp::default().seam(w, w, h, energy)
}
fn remove_seam(w: usize, h: usize, ch: usize, img: &[f32], seam: &[usize]) -> Vec<f32> {
let mut out = Vec::with_capacity((w - 1) * h * ch);
for (y, &sx) in seam.iter().enumerate().take(h) {
let row = &img[y * w * ch..(y + 1) * w * ch];
out.extend_from_slice(&row[..sx * ch]);
out.extend_from_slice(&row[(sx + 1) * ch..]);
}
out
/// Scratch buffers for the seam search, reused across seams.
#[derive(Default)]
struct Dp {
cost: Vec<f32>,
next: Vec<f32>,
/// Per pixel, where the cheapest path came from: 0 = up-left, 1 = up, 2 = up-right.
from: Vec<u8>,
}
fn remove_seam_1(w: usize, h: usize, v: &[f32], seam: &[usize]) -> Vec<f32> {
remove_seam(w, h, 1, v, seam)
impl Dp {
/// The lowest-energy seam of a `cw`-wide, `h`-tall energy map with rows `stride` apart.
/// Ties go to the pixel straight up, then up-left, then up-right; at the bottom, to the
/// leftmost end.
fn seam(&mut self, stride: usize, cw: usize, h: usize, energy: &[f32]) -> Vec<usize> {
self.cost.clear();
self.cost.extend_from_slice(&energy[..cw]);
self.next.resize(cw, 0.0);
self.from.resize(cw * h, 0);
for y in 1..h {
let (cost, next) = (&self.cost[..cw], &mut self.next[..cw]);
let e = &energy[y * stride..y * stride + cw];
let from = &mut self.from[y * cw..(y + 1) * cw];
if cw == 1 {
(next[0], from[0]) = (cost[0] + e[0], 1);
} else {
let (b, d) = if cost[1] < cost[0] { (cost[1], 2) } else { (cost[0], 1) };
(next[0], from[0]) = (b + e[0], d);
// The interior has all three parents: no edge tests in the hot loop.
let inner = next[1..cw - 1].iter_mut().zip(&mut from[1..cw - 1]).zip(&e[1..cw - 1]).zip(cost.windows(3));
for (((n, f), &ev), c) in inner {
let (b, d) = if c[0] < c[1] { (c[0], 0) } else { (c[1], 1) };
let (b, d) = if c[2] < b { (c[2], 2) } else { (b, d) };
(*n, *f) = (b + ev, d);
}
let (b, d) = if cost[cw - 2] < cost[cw - 1] { (cost[cw - 2], 0) } else { (cost[cw - 1], 1) };
(next[cw - 1], from[cw - 1]) = (b + e[cw - 1], d);
}
std::mem::swap(&mut self.cost, &mut self.next);
}
let cost = &self.cost[..cw];
let mut x = (0..cw).min_by(|a, b| cost[*a].total_cmp(&cost[*b])).unwrap_or(0);
let mut seam = vec![0usize; h];
for y in (0..h).rev() {
seam[y] = x;
if y > 0 {
x = match self.from[y * cw + x] {
0 => x - 1,
2 => x + 1,
_ => x,
};
}
}
seam
}
}
/// Shrinks an image one seam at a time in place. Rows keep their original stride, so removing a
/// seam shifts only the rest of each row (in parallel), and the energy map is updated only next
/// to the removed seam instead of being recomputed: the gradient of a pixel changes only when a
/// neighbour does, which happens within a pixel of the seam in its own row and the rows around it.
struct Carver {
stride: usize,
cw: usize,
h: usize,
ch: usize,
img: Vec<f32>,
prot: Option<Vec<f32>>,
/// Energy plus protection, as the seam search sees it.
e: Vec<f32>,
/// Original column of each pixel, when the caller needs to know which ones were removed.
idx: Option<Vec<usize>>,
dp: Dp,
}
impl Carver {
fn new(w: usize, h: usize, ch: usize, img: Vec<f32>, prot: Option<Vec<f32>>, track: bool) -> Self {
let mut e = energy(w, h, ch, &img);
if let Some(p) = &prot {
for (e, p) in e.iter_mut().zip(p) {
*e += p.clamp(0.0, 1.0) * PROTECT_ENERGY;
}
}
let idx = track.then(|| (0..h).flat_map(|_| 0..w).collect());
Carver { stride: w, cw: w, h, ch, img, prot, e, idx, dp: Dp::default() }
}
/// Remove the lowest-energy seam. Returns, per row, the removed column (the original column
/// when tracking).
fn remove_seam(&mut self) -> Vec<usize> {
let (stride, cw, h, ch) = (self.stride, self.cw, self.h, self.ch);
let seam = self.dp.seam(stride, cw, h, &self.e);
let removed = match &self.idx {
Some(idx) => seam.iter().enumerate().map(|(y, &x)| idx[y * stride + x]).collect(),
None => seam.clone(),
};
shift_rows(&mut self.img, stride * ch, &seam, ch, cw);
shift_rows(&mut self.e, stride, &seam, 1, cw);
if let Some(p) = &mut self.prot {
shift_rows(p, stride, &seam, 1, cw);
}
if let Some(idx) = &mut self.idx {
shift_rows(idx, stride, &seam, 1, cw);
}
self.cw -= 1;
self.update_energy(&seam);
removed
}
/// Recompute the energy of the pixels whose neighbours changed when `seam` was removed: in
/// row y, from one left of the leftmost of the seam's x in rows y-1..=y+1 to the rightmost.
fn update_energy(&mut self, seam: &[usize]) {
let (stride, cw, h, ch) = (self.stride, self.cw, self.h, self.ch);
if cw == 0 {
return;
}
let (img, prot) = (&self.img, self.prot.as_deref());
let row = |(y, e): (usize, &mut [f32])| {
let near = &seam[y.saturating_sub(1)..(y + 2).min(h)];
let lo = near.iter().min().map_or(0, |&s| s.saturating_sub(1));
let hi = near.iter().max().map_or(0, |&s| s.min(cw - 1));
for x in lo..=hi {
let mut v = energy_at(img, stride, cw, h, ch, x, y);
if let Some(p) = prot {
v += p[y * stride + x].clamp(0.0, 1.0) * PROTECT_ENERGY;
}
e[x] = v;
}
};
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
self.e.par_chunks_mut(stride).enumerate().for_each(row);
}
#[cfg(target_arch = "wasm32")]
self.e.chunks_mut(stride).enumerate().for_each(row);
}
/// The carved pixels, `cw × h × ch`, rows packed.
fn into_pixels(self) -> Vec<f32> {
let (row, used) = (self.stride * self.ch, self.cw * self.ch);
if row == used {
return self.img;
}
self.img.chunks(row).flat_map(|r| &r[..used]).copied().collect()
}
}
/// Remove pixel `seam[y]` (of `n` values) from each `row_len`-long row whose first `cw` pixels
/// are in use, by shifting the rest of the row left.
fn shift_rows<T: Copy + Send>(buf: &mut [T], row_len: usize, seam: &[usize], n: usize, cw: usize) {
let shift = |(r, &x): (&mut [T], &usize)| r.copy_within((x + 1) * n..cw * n, x * n);
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
buf.par_chunks_mut(row_len).zip(seam.par_iter()).for_each(shift);
}
#[cfg(target_arch = "wasm32")]
buf.chunks_mut(row_len).zip(seam).for_each(shift);
}
fn transpose(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
@@ -97,29 +236,25 @@ fn transpose(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
/// Change the width of `img` to `new_w` by removing or duplicating vertical seams.
pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize) -> Vec<f32> {
// Never cancelled; the fallback is unreachable.
carve_width_with(w, h, ch, img, protect, new_w, &Interrupt::NONE).unwrap_or_else(|_| img.to_vec())
}
/// [`carve_width`] that checks `ctl` before every seam and reports progress per seam.
pub fn carve_width_with(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, ctl: &Interrupt) -> Result<Vec<f32>, Cancelled> {
let new_w = new_w.max(1);
if new_w == w || w == 0 || h == 0 {
return img.to_vec();
return Ok(img.to_vec());
}
let protect_e = |e: &mut [f32], p: Option<&Vec<f32>>| {
if let Some(p) = p {
for (e, p) in e.iter_mut().zip(p) {
*e += p.clamp(0.0, 1.0) * PROTECT_ENERGY;
}
}
};
let seams = new_w.abs_diff(w).max(1) as f32;
if new_w < w {
let (mut cur, mut cw) = (img.to_vec(), w);
let mut prot = protect.map(<[f32]>::to_vec);
while cw > new_w {
let mut e = energy(cw, h, ch, &cur);
protect_e(&mut e, prot.as_ref());
let seam = find_seam(cw, h, &e);
cur = remove_seam(cw, h, ch, &cur, &seam);
prot = prot.map(|p| remove_seam_1(cw, h, &p, &seam));
cw -= 1;
let mut carver = Carver::new(w, h, ch, img.to_vec(), protect.map(<[f32]>::to_vec), false);
while carver.cw > new_w {
ctl.check()?;
ctl.progress((w - carver.cw) as f32 / seams);
carver.remove_seam();
}
return cur;
return Ok(carver.into_pixels());
}
// Enlarge in rounds of at most half the current width, each duplicating the k lowest seams.
let (mut cur, mut cw) = (img.to_vec(), w);
@@ -127,23 +262,18 @@ pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&
while cw < new_w {
let k = (new_w - cw).min((cw / 2).max(1));
// Find k seams on a shrinking copy, mapping each back to original columns.
let mut idx: Vec<Vec<usize>> = (0..h).map(|_| (0..cw).collect()).collect();
let (mut tmp, mut tw) = (cur.clone(), cw);
let mut tprot = prot.clone();
let mut carver = Carver::new(cw, h, ch, cur.clone(), prot.clone(), true);
let mut dup: Vec<Vec<usize>> = vec![Vec::with_capacity(k); h];
for _ in 0..k {
if tw <= 1 {
// A 1 px wide image's only seam is its one column: take it, so it is duplicated.
if carver.cw == 0 {
break;
}
let mut e = energy(tw, h, ch, &tmp);
protect_e(&mut e, tprot.as_ref());
let seam = find_seam(tw, h, &e);
for (y, &sx) in seam.iter().enumerate() {
dup[y].push(idx[y].remove(sx));
ctl.check()?;
ctl.progress((cw + (cw - carver.cw)).saturating_sub(w) as f32 / seams);
for (d, x) in dup.iter_mut().zip(carver.remove_seam()) {
d.push(x);
}
tmp = remove_seam(tw, h, ch, &tmp, &seam);
tprot = tprot.map(|p| remove_seam_1(tw, h, &p, &seam));
tw -= 1;
}
let added = dup[0].len();
if added == 0 {
@@ -177,21 +307,44 @@ pub fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&
prot = nprot;
cw = nw;
}
cur
Ok(cur)
}
/// Content-aware resize of `img` to `new_w × new_h` (width first, then height by transposing).
pub fn carve(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, new_h: usize) -> Vec<f32> {
// Never cancelled; the fallback is unreachable.
carve_with(w, h, ch, img, protect, new_w, new_h, &Interrupt::NONE).unwrap_or_else(|_| img.to_vec())
}
/// [`carve`] that checks `ctl` before every seam (a cancel takes effect within one seam) and
/// reports progress over both passes.
#[allow(clippy::too_many_arguments)]
pub fn carve_with(
w: usize,
h: usize,
ch: usize,
img: &[f32],
protect: Option<&[f32]>,
new_w: usize,
new_h: usize,
ctl: &Interrupt,
) -> Result<Vec<f32>, Cancelled> {
let (new_w, new_h) = (new_w.max(1), new_h.max(1));
let wide = carve_width(w, h, ch, img, protect, new_w);
// Split progress between the passes by their seam counts.
let (sw, sh) = (new_w.abs_diff(w) as f32, new_h.abs_diff(h) as f32);
let split = if sw + sh > 0.0 { sw / (sw + sh) } else { 1.0 };
let cancel = || ctl.cancelled();
let first = |f: f32| ctl.progress(f * split);
let wide = carve_width_with(w, h, ch, img, protect, new_w, &Interrupt::new(&cancel, &first))?;
if new_h == h {
return wide;
return Ok(wide);
}
// Protection follows the width change by plain resampling (it only steers seams).
let prot_t = protect.map(|p| transpose(new_w, h, 1, &resize_bilinear(w, h, 1, p, new_w, h)));
let t = transpose(new_w, h, ch, &wide);
let tall = carve_width(h, new_w, ch, &t, prot_t.as_deref(), new_h);
transpose(new_h, new_w, ch, &tall)
let second = |f: f32| ctl.progress(split + f * (1.0 - split));
let tall = carve_width_with(h, new_w, ch, &t, prot_t.as_deref(), new_h, &Interrupt::new(&cancel, &second))?;
Ok(transpose(new_h, new_w, ch, &tall))
}
/// Plain bilinear resize (the non-content-aware part of the Amount blend).
@@ -241,6 +394,211 @@ pub fn skin_mask(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
mod tests {
use super::*;
/// The straightforward implementation the per-seam carver replaced (full energy map, fresh
/// seam search and a new buffer per seam), kept as the oracle for `carver_matches_reference`.
mod reference {
use super::super::{PROTECT_ENERGY, resize_bilinear, transpose};
pub(super) fn energy(w: usize, h: usize, ch: usize, img: &[f32]) -> Vec<f32> {
let mut e = vec![0.0f32; w * h];
for y in 0..h {
for x in 0..w {
let (xl, xr) = (x.saturating_sub(1), (x + 1).min(w - 1));
let (yu, yd) = (y.saturating_sub(1), (y + 1).min(h - 1));
let at = |xx: usize, yy: usize, c: usize| img[(yy * w + xx) * ch + c];
let mut s = 0.0;
for c in 0..ch {
let v = at(x, y, c);
s += (at(xr, y, c) - v).abs() + (v - at(xl, y, c)).abs();
s += (at(x, yd, c) - v).abs() + (v - at(x, yu, c)).abs();
}
e[y * w + x] = s * 0.5;
}
}
e
}
pub(super) fn find_seam(w: usize, h: usize, energy: &[f32]) -> Vec<usize> {
let mut cost = energy[..w].to_vec();
let mut from = vec![0u8; w * h]; // 0 = up-left, 1 = up, 2 = up-right
let mut next = vec![0.0f32; w];
for y in 1..h {
for x in 0..w {
let mut best = (cost[x], 1u8);
if x > 0 && cost[x - 1] < best.0 {
best = (cost[x - 1], 0);
}
if x + 1 < w && cost[x + 1] < best.0 {
best = (cost[x + 1], 2);
}
next[x] = best.0 + energy[y * w + x];
from[y * w + x] = best.1;
}
std::mem::swap(&mut cost, &mut next);
}
let mut x = (0..w).min_by(|a, b| cost[*a].total_cmp(&cost[*b])).unwrap_or(0);
let mut seam = vec![0usize; h];
for y in (0..h).rev() {
seam[y] = x;
if y > 0 {
x = match from[y * w + x] {
0 => x - 1,
2 => x + 1,
_ => x,
};
}
}
seam
}
fn remove_seam(w: usize, h: usize, ch: usize, img: &[f32], seam: &[usize]) -> Vec<f32> {
let mut out = Vec::with_capacity((w - 1) * h * ch);
for (y, &sx) in seam.iter().enumerate().take(h) {
let row = &img[y * w * ch..(y + 1) * w * ch];
out.extend_from_slice(&row[..sx * ch]);
out.extend_from_slice(&row[(sx + 1) * ch..]);
}
out
}
pub(super) fn carve_width(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize) -> Vec<f32> {
let new_w = new_w.max(1);
if new_w == w || w == 0 || h == 0 {
return img.to_vec();
}
let protect_e = |e: &mut [f32], p: Option<&Vec<f32>>| {
if let Some(p) = p {
for (e, p) in e.iter_mut().zip(p) {
*e += p.clamp(0.0, 1.0) * PROTECT_ENERGY;
}
}
};
if new_w < w {
let (mut cur, mut cw) = (img.to_vec(), w);
let mut prot = protect.map(<[f32]>::to_vec);
while cw > new_w {
let mut e = energy(cw, h, ch, &cur);
protect_e(&mut e, prot.as_ref());
let seam = find_seam(cw, h, &e);
cur = remove_seam(cw, h, ch, &cur, &seam);
prot = prot.map(|p| remove_seam(cw, h, 1, &p, &seam));
cw -= 1;
}
return cur;
}
// Enlarge in rounds of at most half the current width, each duplicating the k lowest seams.
let (mut cur, mut cw) = (img.to_vec(), w);
let mut prot = protect.map(<[f32]>::to_vec);
while cw < new_w {
let k = (new_w - cw).min((cw / 2).max(1));
// Find k seams on a shrinking copy, mapping each back to original columns.
let mut idx: Vec<Vec<usize>> = (0..h).map(|_| (0..cw).collect()).collect();
let (mut tmp, mut tw) = (cur.clone(), cw);
let mut tprot = prot.clone();
let mut dup: Vec<Vec<usize>> = vec![Vec::with_capacity(k); h];
for _ in 0..k {
// A 1 px wide image's only seam is its one column: take it, so it is duplicated.
if tw == 0 {
break;
}
let mut e = energy(tw, h, ch, &tmp);
protect_e(&mut e, tprot.as_ref());
let seam = find_seam(tw, h, &e);
for (y, &sx) in seam.iter().enumerate() {
dup[y].push(idx[y].remove(sx));
}
tmp = remove_seam(tw, h, ch, &tmp, &seam);
tprot = tprot.map(|p| remove_seam(tw, h, 1, &p, &seam));
tw -= 1;
}
let added = dup[0].len();
if added == 0 {
break;
}
let nw = cw + added;
let mut out = Vec::with_capacity(nw * h * ch);
let mut nprot = prot.as_ref().map(|_| Vec::with_capacity(nw * h));
for (y, d) in dup.iter_mut().enumerate() {
d.sort_unstable();
let row = &cur[y * cw * ch..(y + 1) * cw * ch];
let mut di = 0;
for x in 0..cw {
out.extend_from_slice(&row[x * ch..(x + 1) * ch]);
if let (Some(np), Some(p)) = (nprot.as_mut(), prot.as_ref()) {
np.push(p[y * cw + x]);
}
while di < d.len() && d[di] == x {
let xr = (x + 1).min(cw - 1);
for c in 0..ch {
out.push((row[x * ch + c] + row[xr * ch + c]) * 0.5);
}
if let (Some(np), Some(p)) = (nprot.as_mut(), prot.as_ref()) {
np.push(p[y * cw + x]);
}
di += 1;
}
}
}
cur = out;
prot = nprot;
cw = nw;
}
cur
}
pub(super) fn carve(w: usize, h: usize, ch: usize, img: &[f32], protect: Option<&[f32]>, new_w: usize, new_h: usize) -> Vec<f32> {
let (new_w, new_h) = (new_w.max(1), new_h.max(1));
let wide = carve_width(w, h, ch, img, protect, new_w);
if new_h == h {
return wide;
}
let prot_t = protect.map(|p| transpose(new_w, h, 1, &resize_bilinear(w, h, 1, p, new_w, h)));
let t = transpose(new_w, h, ch, &wide);
let tall = carve_width(h, new_w, ch, &t, prot_t.as_deref(), new_h);
transpose(new_h, new_w, ch, &tall)
}
}
/// The carver gives bit-identical output to the reference implementation it replaced, over
/// random shapes (including 1 px wide and tall), channel counts, tie-heavy quantised noise,
/// protection, shrinking, enlarging past half the width and both axes at once.
#[test]
fn carver_matches_reference() {
let mut seed = 1u32;
let mut next = |n: usize| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed as usize % n
};
for case in 0..400u32 {
let (w, h, ch) = (1 + next(24), 1 + next(18), [1, 3, 4][next(3)]);
let levels = [2, 5, 255, 65_536][next(4)];
let img = noise(w * h * ch, levels, 11 + case);
let protect = (next(3) == 0).then(|| noise(w * h, 3, 101 + case));
let (nw, nh) = (1 + next(2 * w + 2), 1 + next(2 * h + 2));
let got = carve(w, h, ch, &img, protect.as_deref(), nw, nh);
let want = reference::carve(w, h, ch, &img, protect.as_deref(), nw, nh);
let bits = |v: &[f32]| v.iter().map(|f| f.to_bits()).collect::<Vec<_>>();
assert_eq!(bits(&got), bits(&want), "case {case}: {w}x{h}x{ch} -> {nw}x{nh}, levels {levels}, protect {}", protect.is_some());
}
// The seam search alone, on raw energy maps full of ties.
for case in 0..200u32 {
let (w, h) = (1 + next(40), 1 + next(30));
let e = noise(w * h, [2, 3, 1000][next(3)], 7 + case);
assert_eq!(find_seam(w, h, &e), reference::find_seam(w, h, &e), "seam case {case}: {w}x{h}");
let img = noise(w * h * 3, 7, 3 + case);
let bits = |v: Vec<f32>| v.into_iter().map(f32::to_bits).collect::<Vec<_>>();
assert_eq!(bits(energy(w, h, 3, &img)), bits(reference::energy(w, h, 3, &img)), "energy case {case}");
}
}
#[test]
fn cancelled_carve_stops() {
let (w, h) = (40, 20);
let img = vec![0.5f32; w * h * 3];
let yes = || true;
assert_eq!(carve_with(w, h, 3, &img, None, 30, 15, &Interrupt::cancel_only(&yes)), Err(Cancelled));
assert_eq!(carve_with(w, h, 3, &img, None, 30, 15, &Interrupt::NONE).unwrap(), carve(w, h, 3, &img, None, 30, 15));
}
/// A flat grey image with a salient vertical bar at x = 10..14 (red/blue columns).
fn bar(w: usize, h: usize) -> Vec<f32> {
let mut v = vec![0.5f32; w * h * 3];
@@ -315,6 +673,77 @@ mod tests {
assert!((0..4).all(|y| out[y * 8..y * 8 + 8].iter().any(|v| (*v - 0.51).abs() < 1e-6)));
}
/// Noise quantised to `levels` values, so the energy map is full of exact ties.
fn noise(len: usize, levels: u32, seed: u32) -> Vec<f32> {
let mut s = seed | 1;
(0..len)
.map(|_| {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
(s % levels) as f32 / (levels - 1) as f32
})
.collect()
}
fn fnv(v: &[f32]) -> u64 {
v.iter().flat_map(|f| f.to_bits().to_le_bytes()).fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3))
}
/// The exact output bits of a spread of carves (shrink, enlarge past half the width, both
/// axes, protection, 1/3/4 channels, tie-heavy and smooth images), pinned so that speed work
/// can't change which seams are chosen or how pixels are blended.
#[test]
fn output_bits_are_pinned() {
let cases: [(usize, usize, usize, u32, bool, usize, usize); 8] = [
(61, 47, 3, 2, false, 40, 47),
(61, 47, 3, 255, false, 61, 30),
(61, 47, 4, 4, true, 35, 29),
(33, 20, 1, 3, false, 80, 20),
(33, 20, 3, 1000, true, 50, 45),
(48, 36, 4, 7, false, 70, 18),
(5, 3, 1, 2, false, 1, 1),
(40, 40, 3, 2, true, 39, 41),
];
let got: Vec<u64> = cases
.iter()
.enumerate()
.map(|(i, &(w, h, ch, levels, prot, nw, nh))| {
let img = noise(w * h * ch, levels, 7 + i as u32);
let protect = prot.then(|| noise(w * h, 3, 99 + i as u32));
let out = carve(w, h, ch, &img, protect.as_deref(), nw, nh);
assert_eq!(out.len(), nw * nh * ch);
fnv(&out)
})
.collect();
assert_eq!(got, PINNED);
}
const PINNED: [u64; 8] = [
0x24ea_9353_02c2_7c58,
0x5a6e_8554_a0cb_ebb1,
0x8b18_d63f_396e_21c9,
0x1db2_23ad_3e6a_a7c5,
0x721d_6cc5_34e0_3fdf,
0x961a_ea6d_e512_ec7d,
0x4d25_767f_9dce_13f5,
0x2157_9f86_27d6_b835,
];
#[test]
fn one_pixel_lines_enlarge_by_repeating() {
// Width 1 has no seam to spare: its one column is repeated. Height 1 likewise (through
// the transposed pass), alone or with the other axis.
let col = [0.1f32, 0.2, 0.3, 0.4, 0.5];
assert_eq!(carve(1, 5, 1, &col, None, 4, 5), col.iter().flat_map(|v| [*v; 4]).collect::<Vec<_>>());
assert_eq!(carve(5, 1, 1, &col, None, 5, 3), col.repeat(3));
assert_eq!(carve(1, 1, 3, &[0.7, 0.8, 0.9], None, 3, 2), [0.7, 0.8, 0.9].repeat(6));
for (w, h, nw, nh) in [(1, 5, 6, 10), (1, 17, 6, 34), (16, 1, 21, 2), (7, 1, 3, 8), (2, 1, 1, 8)] {
let img = vec![0.5f32; w * h * 4];
assert_eq!(carve(w, h, 4, &img, Some(&vec![1.0; w * h]), nw, nh).len(), nw * nh * 4, "{w}x{h} -> {nw}x{nh}");
}
}
#[test]
fn skin_rule() {
let px = [0.86f32, 0.62, 0.5, 0.2, 0.4, 0.8];
+174 -87
View File
@@ -4,10 +4,12 @@
//! and combining with an existing selection.
use photocraft_color::PixelFormat;
use photocraft_geom::Rect;
use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
use photocraft_raster::Surface;
use serde::{Deserialize, Serialize};
use crate::photo_util::par_rows;
/// How a new selection combines with the current one.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -36,6 +38,32 @@ pub fn mask_from_surface(s: Option<&Surface>, area: Rect) -> Vec<f32> {
let n = area.width() as usize * area.height() as usize;
match s {
None => vec![0.0; n],
// Selections are GRAY8: copy tile bytes through a table, tile rows in parallel.
Some(s) if s.format() == PixelFormat::GRAY8 && !area.is_empty() => {
let w = area.width() as usize;
let lut: [f32; 256] = std::array::from_fn(|i| i as f32 / 255.0);
let dp = s.default_pixel().first().copied().unwrap_or(0.0);
let mut out = vec![0.0f32; n];
par_rows(&mut out, w, 1, |yy, row| {
let y = area.y0 + yy as i32;
let mut x = area.x0;
while x < area.x1 {
let tc = TileCoord::containing(x, y);
let x1 = (tc.rect().x1).min(area.x1);
let dst = row.get_mut((x - area.x0) as usize..(x1 - area.x0) as usize).unwrap_or_default();
let src = s.tile(tc).and_then(|t| {
let base = (y - tc.ty * TILE_SIZE) as usize * TILE_SIZE as usize + (x - tc.tx * TILE_SIZE) as usize;
t.bytes().get(base..base + dst.len())
});
match src {
Some(src) => dst.iter_mut().zip(src).for_each(|(d, b)| *d = lut[*b as usize]),
None => dst.fill(dp),
}
x = x1;
}
});
out
}
Some(s) => {
let mut v = Vec::new();
s.read_region_into(area, &mut v);
@@ -45,35 +73,81 @@ pub fn mask_from_surface(s: Option<&Surface>, area: Rect) -> Vec<f32> {
}
}
/// 8-bit selection value of a coverage (clamped and rounded, as GRAY8 surfaces store it; NaN → 0).
#[inline]
fn coverage8(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
}
/// Builds a GRAY8 selection surface from 8-bit coverage over `area`, allocating only the tiles
/// that hold something (as [`Surface::prune`] would leave them).
fn surface_from_coverage8(bytes: &[u8], area: Rect) -> Surface {
let mut s = Surface::new(PixelFormat::GRAY8);
let w = area.width() as usize;
let ts = TILE_SIZE as usize;
for tc in area.tiles() {
let tr = tc.rect().intersect(&area);
let (lx, span) = ((tr.x0 - area.x0) as usize, tr.width() as usize);
let row = |y: i32| bytes.get((y - area.y0) as usize * w + lx..(y - area.y0) as usize * w + lx + span);
if !(tr.y0..tr.y1).any(|y| row(y).is_some_and(|r| r.iter().any(|b| *b != 0))) {
continue;
}
let data = s.tile_mut(tc).bytes_mut();
for y in tr.y0..tr.y1 {
let o = (y - tc.ty * TILE_SIZE) as usize * ts + (tr.x0 - tc.tx * TILE_SIZE) as usize;
if let (Some(src), Some(dst)) = (row(y), data.get_mut(o..o + span)) {
dst.copy_from_slice(src);
}
}
}
s
}
/// 8-bit coverage of `m` (missing samples are 0), computed in parallel.
fn to_coverage8(m: &[f32], area: Rect, f: impl Fn(usize, f32) -> f32 + Sync + Send) -> Vec<u8> {
let w = area.width() as usize;
let mut bytes = vec![0u8; w * area.height() as usize];
par_rows(&mut bytes, w, 1, |y, row| {
for (x, b) in row.iter_mut().enumerate() {
let i = y * w + x;
*b = coverage8(f(i, m.get(i).copied().unwrap_or(0.0)));
}
});
bytes
}
/// Builds a GRAY8 selection surface from a mask over `area`.
pub fn mask_to_surface(m: &[f32], area: Rect) -> Surface {
let mut s = Surface::new(PixelFormat::GRAY8);
if !area.is_empty() {
s.write_region(area, m);
if area.is_empty() {
return Surface::new(PixelFormat::GRAY8);
}
s.prune();
s
surface_from_coverage8(&to_coverage8(m, area, |_, v| v), area)
}
/// Combines `new` with `old` by `mode`. Returns `None` when nothing is selected.
pub fn combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
let prev = mask_from_surface(old, area);
// Quantize to the 8-bit grid selections are stored on, so combining a
// mask with itself is exact.
let q: Vec<f32> = new.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect();
let new = &q[..];
let out: Vec<f32> = match mode {
SelectionMode::Replace => new.to_vec(),
SelectionMode::Add => prev.iter().zip(new).map(|(a, b)| a.max(*b)).collect(),
SelectionMode::Subtract => prev.iter().zip(new).map(|(a, b)| (a - b).max(0.0)).collect(),
SelectionMode::Intersect => prev.iter().zip(new).map(|(a, b)| a.min(*b)).collect(),
};
// Below half an 8-bit step is nothing (selections are stored as 8-bit).
let out: Vec<f32> = out.into_iter().map(|v| if v < 0.5 / 255.0 { 0.0 } else { v }).collect();
if out.iter().all(|v| *v <= 0.0) {
if area.is_empty() {
return None;
}
Some(mask_to_surface(&out, area))
// Quantize to the 8-bit grid selections are stored on, so combining a mask with itself is
// exact. Below half an 8-bit step is nothing.
let q = |v: f32| f32::from(coverage8(v)) / 255.0;
let bytes = match mode {
SelectionMode::Replace => to_coverage8(new, area, |_, v| v),
_ => {
let prev = mask_from_surface(old, area);
let at = |i: usize| prev.get(i).copied().unwrap_or(0.0);
match mode {
SelectionMode::Add => to_coverage8(new, area, |i, v| at(i).max(q(v))),
SelectionMode::Subtract => to_coverage8(new, area, |i, v| (at(i) - q(v)).max(0.0)),
_ => to_coverage8(new, area, |i, v| at(i).min(q(v))),
}
}
};
if bytes.iter().all(|b| *b == 0) {
return None;
}
Some(surface_from_coverage8(&bytes, area))
}
fn close(a: [f32; 4], b: [f32; 4], tol: f32) -> bool {
@@ -548,76 +622,17 @@ pub fn border(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
outer.iter().zip(inner).map(|(o, i)| (o - i).max(0.0)).collect()
}
fn box_blur(m: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
if r == 0 {
return m.to_vec();
}
let mut tmp = vec![0.0f32; m.len()];
let win = (2 * r + 1) as f32;
for y in 0..h {
for x in 0..w {
let mut s = 0.0;
for k in x as i64 - r as i64..=x as i64 + r as i64 {
s += m[y * w + k.clamp(0, w as i64 - 1) as usize];
}
tmp[y * w + x] = s / win;
}
}
let mut out = vec![0.0f32; m.len()];
for y in 0..h {
for x in 0..w {
let mut s = 0.0;
for k in y as i64 - r as i64..=y as i64 + r as i64 {
s += tmp[k.clamp(0, h as i64 - 1) as usize * w + x];
}
out[y * w + x] = s / win;
}
}
out
}
/// Smooth: removes specks and rounds corners (box average, then re-threshold).
/// Smooth: removes specks and rounds corners (box average of radius `round(r)`, edges
/// replicated, then re-thresholded). Parallel and independent of the radius (#211).
pub fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
box_blur(m, w, h, r.max(0.0).round() as usize).into_iter().map(|v| ((v - 0.5) * 4.0 + 0.5).clamp(0.0, 1.0)).collect()
crate::selection_blur::smooth(m, w, h, r)
}
/// Feather: Gaussian blur of the mask with sigma = radius / 2.
/// Feather: Gaussian blur of the mask with sigma = radius / 2 (kernel truncated at 3σ, zero
/// beyond the canvas). Parallel, confined to the selection's bounds, and independent of the
/// radius (#211).
pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
let sigma = radius.max(0.0) / 2.0;
if sigma < 0.1 {
return m.to_vec();
}
let r = (sigma * 3.0).ceil() as i64;
let k: Vec<f32> = (-r..=r).map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma)).exp()).collect();
let s: f32 = k.iter().sum();
let k: Vec<f32> = k.iter().map(|v| v / s).collect();
let mut tmp = vec![0.0f32; m.len()];
for y in 0..h {
for x in 0..w {
let mut a = 0.0;
for (i, kv) in k.iter().enumerate() {
let xx = x as i64 + i as i64 - r;
if xx >= 0 && xx < w as i64 {
a += m[y * w + xx as usize] * kv;
}
}
tmp[y * w + x] = a;
}
}
let mut out = vec![0.0f32; m.len()];
for y in 0..h {
for x in 0..w {
let mut a = 0.0;
for (i, kv) in k.iter().enumerate() {
let yy = y as i64 + i as i64 - r;
if yy >= 0 && yy < h as i64 {
a += tmp[yy as usize * w + x] * kv;
}
}
out[y * w + x] = a;
}
}
out
crate::selection_blur::feather(m, w, h, radius)
}
/// Anti-aliased polygon (even-odd) coverage over `area`: exact horizontal
@@ -828,6 +843,78 @@ mod tests {
assert_eq!(SelectionMode::parse("add"), SelectionMode::Add);
}
/// The previous (per-sample) conversions, kept as the oracle for the tiled fast paths.
fn old_mask_to_surface(m: &[f32], area: Rect) -> Surface {
let mut s = Surface::new(PixelFormat::GRAY8);
if !area.is_empty() {
s.write_region(area, m);
}
s.prune();
s
}
fn old_combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
let mut prev = Vec::new();
match old {
Some(s) => s.read_region_into(area, &mut prev),
None => prev = vec![0.0; new.len()],
}
let q: Vec<f32> = new.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect();
let out: Vec<f32> = match mode {
SelectionMode::Replace => q,
SelectionMode::Add => prev.iter().zip(&q).map(|(a, b)| a.max(*b)).collect(),
SelectionMode::Subtract => prev.iter().zip(&q).map(|(a, b)| (a - b).max(0.0)).collect(),
SelectionMode::Intersect => prev.iter().zip(&q).map(|(a, b)| a.min(*b)).collect(),
};
let out: Vec<f32> = out.into_iter().map(|v| if v < 0.5 / 255.0 { 0.0 } else { v }).collect();
if out.iter().all(|v| *v <= 0.0) {
return None;
}
Some(old_mask_to_surface(&out, area))
}
#[test]
fn tiled_conversions_match_per_sample_ones() {
let mut rng = crate::photo_util::Rng(7);
let mut u = move || (rng.next() >> 11) as f32 / (1u64 << 53) as f32;
// Off-grid areas spanning several tiles, including negative origins.
for area in [Rect::new(0, 0, 300, 20), Rect::new(-37, 250, 530, 263), Rect::new(255, -3, 258, 600), Rect::new(5, 5, 6, 6)] {
let n = area.width() as usize * area.height() as usize;
let old_m: Vec<f32> = (0..n).map(|i| if i % 97 < 40 { u() } else { 0.0 }).collect();
let old = old_mask_to_surface(&old_m, area);
let fast = mask_to_surface(&old_m, area);
assert_eq!(fast, old, "{area:?}");
let mut slow_read = Vec::new();
old.read_region_into(area, &mut slow_read);
assert_eq!(mask_from_surface(Some(&old), area), slow_read);
for kind in 0..3 {
let new: Vec<f32> = (0..n)
.map(|i| match kind {
0 => u() * 1.2 - 0.1,
1 => {
if i % 131 < 50 {
1.0
} else {
0.0
}
}
_ => 0.001,
})
.collect();
for mode in [SelectionMode::Replace, SelectionMode::Add, SelectionMode::Subtract, SelectionMode::Intersect] {
for prev in [None, Some(&old)] {
assert_eq!(combine(prev, &new, area, mode), old_combine(prev, &new, area, mode), "{area:?} {kind} {mode:?}");
}
}
}
}
// A short mask buffer reads as zeros instead of panicking.
let a = Rect::new(0, 0, 10, 10);
assert!(combine(None, &[1.0; 5], a, SelectionMode::Replace).is_some());
assert_eq!(mask_to_surface(&[], a).tile_count(), 0);
assert!(combine(None, &[], Rect::EMPTY, SelectionMode::Replace).is_none());
}
fn blobs(w: i32, h: i32) -> Vec<[u8; 4]> {
// Deterministic blotchy image with gradients and hard edges.
(0..w * h)
+788
View File
@@ -0,0 +1,788 @@
//! Fast mask blurs behind Select › Modify › Smooth and Feather (#211).
//!
//! Both are separable and give the same numbers as the direct definitions (see the oracle tests):
//!
//! * **Smooth** is a box average with edge-replicated borders: a running sum per line, `O(1)` per
//! pixel whatever the radius.
//! * **Feather** is a Gaussian (`σ = radius / 2`, kernel truncated at `⌈3σ⌉` and normalized, zero
//! outside the canvas). Small kernels are convolved directly. Wide ones are written as a short
//! cosine series over the kernel window, `k(j) ≈ a₀ + Σ aₖ cos(ωₖ j)`, and every term is a
//! sliding windowed sum `Σ m[x+j]·e^{iωₖj}` updated in `O(1)` per pixel, so the cost doesn't grow
//! with the radius. The series is fitted per call by least squares and its error is checked
//! (≤ 0.3/255 per pass); a fit that misses falls back to the direct convolution.
//!
//! Work is confined to the mask's non-zero bounding box grown by the kernel radius (pixels
//! farther away stay exactly 0). Rows run in parallel (`par_rows`/`par_map`, serial on wasm);
//! the vertical pass walks strips of 128 columns row by row (vectorizable, no transpose). Windows
//! lying entirely in a constant run (inside or outside the selection) are written without
//! arithmetic.
use crate::photo_util::{par_map, par_rows};
/// Cosine terms in the wide-kernel series (5 gives ≈ 0.1/255 per pass; see the module docs).
const TERMS: usize = 5;
/// Kernels up to this radius are convolved directly.
const DIRECT_MAX_R: usize = 12;
/// Largest error (in mask units, per pass) a cosine fit may have before falling back.
const FIT_TOL: f64 = 0.3 / 255.0;
/// σ cap: far beyond any canvas, keeps the kernel arithmetic finite for absurd radii.
const MAX_SIGMA: f32 = 100_000.0;
/// Box radius cap for the same reason.
const MAX_BOX_R: usize = 1 << 24;
/// Columns per task in the vertical pass.
const STRIP: usize = 64;
/// A window of the mask: columns `x0..x1`, rows `y0..y1`.
#[derive(Clone, Copy, Debug)]
struct Win {
x0: usize,
x1: usize,
y0: usize,
y1: usize,
}
impl Win {
fn w(&self) -> usize {
self.x1 - self.x0
}
fn h(&self) -> usize {
self.y1 - self.y0
}
}
/// Bounding box of the non-zero samples (NaN counts as non-zero), or `None` when all are 0.
fn nonzero_bounds(m: &[f32], w: usize, h: usize) -> Option<Win> {
let rows: Vec<Option<(usize, usize)>> = par_map(h, |y| {
let row = m.get(y * w..(y + 1) * w)?;
let first = row.iter().position(|v| *v != 0.0)?;
let last = row.iter().rposition(|v| *v != 0.0)?;
Some((first, last + 1))
});
let mut b: Option<Win> = None;
for (y, r) in rows.into_iter().enumerate() {
if let Some((a, z)) = r {
b = Some(match b {
None => Win { x0: a, x1: z, y0: y, y1: y + 1 },
Some(b) => Win { x0: b.x0.min(a), x1: b.x1.max(z), y0: b.y0, y1: y + 1 },
});
}
}
b
}
/// `b` grown by `g` on every side, clamped to `w × h`.
fn grow(b: Win, g: usize, w: usize, h: usize) -> Win {
Win { x0: b.x0.saturating_sub(g), x1: b.x1.saturating_add(g).min(w), y0: b.y0.saturating_sub(g), y1: b.y1.saturating_add(g).min(h) }
}
/// The horizontal pass's output as the vertical pass reads it: `bh` rows of `gw` samples placed
/// at window rows `off..off + bh` of a window `gh` rows tall (other rows are zero).
struct Rows<'a> {
tmp: &'a [f32],
gw: usize,
bh: usize,
off: usize,
gh: usize,
}
impl Rows<'_> {
/// Columns `c0..c0 + sw` of window row `yy`, or `None` for a zero row.
fn get(&self, yy: usize, c0: usize, sw: usize) -> Option<&[f32]> {
let r = yy.checked_sub(self.off).filter(|r| *r < self.bh)?;
self.tmp.get(r * self.gw + c0..r * self.gw + c0 + sw)
}
}
/// Runs the vertical pass `f(c0, sw)` on strips of `STRIP` columns in parallel; each returns its
/// `gh × sw` result, row-major.
fn strips(gw: usize, f: impl Fn(usize, usize) -> Vec<f32> + Sync + Send) -> Vec<Vec<f32>> {
par_map(gw.div_ceil(STRIP), |s| {
let c0 = s * STRIP;
f(c0, STRIP.min(gw - c0))
})
}
/// Writes the strips into a fresh `w × h` mask through `f`; outside the window it is `outside`.
fn assemble(strips: &[Vec<f32>], win: Win, w: usize, h: usize, outside: f32, f: impl Fn(f32) -> f32 + Sync + Send) -> Vec<f32> {
let mut out = vec![outside; w * h];
par_rows(&mut out, w, 1, |y, row| {
let Some(yy) = y.checked_sub(win.y0).filter(|_| y < win.y1) else { return };
for (s, strip) in strips.iter().enumerate() {
let c0 = s * STRIP;
let sw = STRIP.min(win.w().saturating_sub(c0));
if let (Some(src), Some(dst)) = (strip.get(yy * sw..(yy + 1) * sw), row.get_mut(win.x0 + c0..win.x0 + c0 + sw)) {
for (d, v) in dst.iter_mut().zip(src) {
*d = f(*v);
}
}
}
});
out
}
/// Adds `k ×` window row `yy` (columns `c0..c0 + acc.len()`) into `acc`.
fn add_row(acc: &mut [f64], rows: &Rows, yy: usize, c0: usize, k: f64) {
if k == 0.0 {
return;
}
if let Some(row) = rows.get(yy, c0, acc.len()) {
for (a, v) in acc.iter_mut().zip(row) {
*a += k * f64::from(*v);
}
}
}
// ---- Smooth: box average, edges replicated -------------------------------------------------
/// Running-sum box average of one line (radius `r`, edges replicated) into `dst`.
fn box_line(src: &[f32], dst: &mut [f32], r: usize) {
let n = src.len().min(dst.len());
let (Some(&first), Some(&last)) = (src.first(), src.get(n.wrapping_sub(1))) else { return };
if r == 0 {
dst[..n].copy_from_slice(&src[..n]);
return;
}
let at = |i: i64| -> f64 { f64::from(src.get(i.clamp(0, n as i64 - 1) as usize).copied().unwrap_or(0.0)) };
// Window at x = 0: r copies of the first sample, then src[0..=r], then copies of the last.
let inside = r.min(n - 1);
let mut acc = r as f64 * f64::from(first) + src[..=inside].iter().map(|v| f64::from(*v)).sum::<f64>() + (r - inside) as f64 * f64::from(last);
let norm = 1.0 / (2 * r + 1) as f64;
let ri = r as i64;
for (x, d) in dst[..n].iter_mut().enumerate() {
*d = (acc * norm) as f32;
let x = x as i64;
acc += at(x + ri + 1) - at(x - ri);
}
}
/// Select › Modify › Smooth: box average of radius `round(r)` (edges replicated), then the
/// contrast curve `(v − ½)·4 + ½` that re-hardens the edge and drops specks.
pub(crate) fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
let curve = |v: f32| ((v - 0.5) * 4.0 + 0.5).clamp(0.0, 1.0);
let n = w.saturating_mul(h);
if m.len() != n || n == 0 {
return m.iter().map(|v| curve(*v)).collect();
}
let r = if r.is_finite() {
(r.max(0.0).round() as usize).min(MAX_BOX_R)
} else if r > 0.0 {
MAX_BOX_R
} else {
0
};
let Some(b) = nonzero_bounds(m, w, h) else {
return vec![curve(0.0); n];
};
// Grown one past the radius, so a window edge inside the canvas sits on zeros and edge
// replication there equals the zeros beyond it.
let win = grow(b, r.saturating_add(1), w, h);
let (gw, gh) = (win.w(), win.h());
// Rows: only the bounding box's rows are non-zero.
let mut tmp = vec![0.0f32; b.h() * gw];
par_rows(&mut tmp, gw, 1, |y, row| {
let o = (b.y0 + y) * w + win.x0;
if let Some(s) = m.get(o..o + gw) {
box_line(s, row, r);
}
});
// Columns: a running sum of rows per strip of columns.
let rows = Rows { tmp: &tmp, gw, bh: b.h(), off: b.y0 - win.y0, gh };
let res = strips(gw, |c0, sw| box_strip(&rows, c0, sw, r));
drop(tmp);
assemble(&res, win, w, h, curve(0.0), curve)
}
/// The vertical box pass over columns `c0..c0 + sw` (edges replicated).
fn box_strip(rows: &Rows, c0: usize, sw: usize, r: usize) -> Vec<f32> {
let gh = rows.gh;
let mut out = vec![0.0f32; gh * sw];
if gh == 0 || sw == 0 {
return out;
}
let mut acc = vec![0.0f64; sw];
// Window at row 0: r copies of row 0, rows 0..=r, then copies of the last row.
let inside = r.min(gh - 1);
add_row(&mut acc, rows, 0, c0, r as f64);
for j in 0..=inside {
add_row(&mut acc, rows, j, c0, 1.0);
}
add_row(&mut acc, rows, gh - 1, c0, (r - inside) as f64);
let norm = 1.0 / (2 * r + 1) as f64;
for (yy, dst) in out.chunks_exact_mut(sw).enumerate() {
for (d, a) in dst.iter_mut().zip(&acc) {
*d = (a * norm) as f32;
}
add_row(&mut acc, rows, (yy + r + 1).min(gh - 1), c0, 1.0);
add_row(&mut acc, rows, yy.saturating_sub(r), c0, -1.0);
}
out
}
// ---- Feather: truncated Gaussian, zero outside -----------------------------------------------
/// Sliding cosine-series form of a symmetric kernel over `-r..=r`.
struct CosKernel {
r: usize,
a0: f64,
a: [f64; TERMS],
/// `e^{-iω}`: shifts the window by one sample.
rot: [(f64, f64); TERMS],
/// `e^{-iωr}` (leaving sample) and `e^{iωr}` (entering sample).
out_w: [(f64, f64); TERMS],
in_w: [(f64, f64); TERMS],
/// `Σ_{|j|≤r} cos(ωj)`: a window full of ones.
full: [f64; TERMS],
omega: [f64; TERMS],
}
enum Kernel {
/// Taps for `-r..=r`.
Direct(Vec<f32>),
Cos(Box<CosKernel>),
}
#[allow(clippy::needless_range_loop)] // textbook elimination reads clearer with indices
/// Solves the small dense system `a·x = b` (partial pivoting); `None` when singular.
fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
let n = b.len();
for c in 0..n {
let p = (c..n).max_by(|i, j| a[*i][c].abs().total_cmp(&a[*j][c].abs()))?;
if a[p][c].abs() < 1e-12 {
return None;
}
a.swap(c, p);
b.swap(c, p);
for i in c + 1..n {
let f = a[i][c] / a[c][c];
for j in c..n {
a[i][j] -= f * a[c][j];
}
b[i] -= f * b[c];
}
}
let mut x = vec![0.0; n];
for i in (0..n).rev() {
let s: f64 = (i + 1..n).map(|j| a[i][j] * x[j]).sum();
x[i] = (b[i] - s) / a[i][i];
}
x.iter().all(|v| v.is_finite()).then_some(x)
}
impl Kernel {
/// The feather kernel (σ, truncation radius `r`) as applied along lines of length `n`.
fn new(sigma: f64, r: usize, n: usize) -> Kernel {
// Normalized over the full truncated window, as the direct definition does.
let g = |j: f64| (-(j * j) / (2.0 * sigma * sigma)).exp();
let total: f64 = 1.0 + 2.0 * (1..=r).map(|j| g(j as f64)).sum::<f64>();
// Only offsets inside the line matter.
let jr = r.min(n.saturating_sub(1));
if jr == 0 {
// One-sample line: the centre tap alone.
return Kernel::Direct(vec![(1.0 / total) as f32]);
}
let taps: Vec<f64> = (0..=jr).map(|j| g(j as f64) / total).collect();
let direct = || Kernel::Direct((0..=2 * jr).map(|i| taps[i.abs_diff(jr)] as f32).collect());
if jr <= DIRECT_MAX_R {
return direct();
}
// Least squares over j = -jr..=jr (symmetric: weight 2 for j > 0).
let l = (jr + 1) as f64;
let omega: [f64; TERMS] = std::array::from_fn(|k| std::f64::consts::PI * (k + 1) as f64 / l);
let basis = |q: usize, j: usize| if q == 0 { 1.0 } else { (omega[q - 1] * j as f64).cos() };
let wt = |j: usize| if j == 0 { 1.0 } else { 2.0 };
let mut a = vec![vec![0.0; TERMS + 1]; TERMS + 1];
let mut b = vec![0.0; TERMS + 1];
for (j, t) in taps.iter().enumerate() {
let phi: Vec<f64> = (0..=TERMS).map(|q| basis(q, j)).collect();
for p in 0..=TERMS {
b[p] += wt(j) * phi[p] * t;
for q in 0..=TERMS {
a[p][q] += wt(j) * phi[p] * phi[q];
}
}
}
let Some(c) = solve(a, b) else { return direct() };
// Error bound per pass: half the L1 distance between the series and the kernel.
let err: f64 = taps.iter().enumerate().map(|(j, t)| wt(j) * ((0..=TERMS).map(|q| c[q] * basis(q, j)).sum::<f64>() - t).abs()).sum::<f64>() / 2.0;
if !err.is_finite() || err > FIT_TOL {
return direct();
}
let jf = jr as f64;
let full: [f64; TERMS] = std::array::from_fn(|k| 1.0 + 2.0 * (1..=jr).map(|j| (omega[k] * j as f64).cos()).sum::<f64>());
Kernel::Cos(Box::new(CosKernel {
r: jr,
a0: c[0],
a: std::array::from_fn(|k| c[k + 1]),
rot: std::array::from_fn(|k| (omega[k].cos(), -omega[k].sin())),
out_w: std::array::from_fn(|k| ((omega[k] * jf).cos(), -(omega[k] * jf).sin())),
in_w: std::array::from_fn(|k| ((omega[k] * jf).cos(), (omega[k] * jf).sin())),
full,
omega,
}))
}
/// Convolves one line (zero outside it) into `dst`.
fn line(&self, src: &[f32], dst: &mut [f32], runs: &mut Vec<usize>) {
let n = src.len().min(dst.len());
match self {
Kernel::Direct(taps) => {
let r = taps.len() / 2;
dst[..n].fill(0.0);
for (i, k) in taps.iter().enumerate() {
// Output x reads src[x + i - r].
let (d0, s0) = if i >= r { (0, i - r) } else { (r - i, 0) };
if d0 >= n || s0 >= n {
continue;
}
let len = (n - d0).min(n - s0);
for (d, s) in dst[d0..d0 + len].iter_mut().zip(&src[s0..s0 + len]) {
*d += k * s;
}
}
}
Kernel::Cos(k) => k.line(&src[..n], &mut dst[..n], runs),
}
}
/// The vertical pass over columns `c0..c0 + sw` (zero outside the window).
fn strip(&self, rows: &Rows, c0: usize, sw: usize) -> Vec<f32> {
match self {
Kernel::Direct(taps) => {
let r = taps.len() / 2;
let mut out = vec![0.0f32; rows.gh * sw];
if sw == 0 {
return out;
}
for (yy, dst) in out.chunks_exact_mut(sw).enumerate() {
for (i, k) in taps.iter().enumerate() {
let Some(src) = (yy + i).checked_sub(r).and_then(|y| rows.get(y, c0, sw)) else { continue };
for (d, s) in dst.iter_mut().zip(src) {
*d += k * s;
}
}
}
out
}
Kernel::Cos(k) => k.strip(rows, c0, sw),
}
}
}
impl CosKernel {
#[allow(clippy::needless_range_loop)] // `x` and `k` index several arrays in lockstep
fn line(&self, src: &[f32], dst: &mut [f32], runs: &mut Vec<usize>) {
let n = src.len();
let r = self.r;
// runs[i]: end (exclusive) of the constant run containing i.
runs.clear();
runs.resize(n, n);
for i in (0..n.saturating_sub(1)).rev() {
runs[i] = if src[i] == src[i + 1] { runs[i + 1] } else { i + 1 };
}
let at = |i: usize| f64::from(src.get(i).copied().unwrap_or(0.0));
// Window sums for the last position handled (valid when `have`).
let mut have = false;
let mut sb = 0.0f64;
let mut s = [(0.0f64, 0.0f64); TERMS];
for x in 0..n {
let lo = x.saturating_sub(r);
let hi = (x + r + 1).min(n);
let c = src[lo];
if runs[lo] >= hi && (c == 0.0 || (x >= r && x + r < n)) {
// The whole window is one value (zeros may run off the line; ones may not).
dst[x] = c;
let cf = f64::from(c);
sb = cf * (2 * r + 1) as f64;
for (sk, fk) in s.iter_mut().zip(&self.full) {
*sk = (cf * fk, 0.0);
}
have = true;
continue;
}
if have {
// Slide from x - 1: drop src[x - 1 - r], take src[x + r].
let gone = if x > r { at(x - 1 - r) } else { 0.0 };
let come = at(x + r);
sb += come - gone;
for k in 0..TERMS {
let (re, im) = (s[k].0 - gone * self.out_w[k].0, s[k].1 - gone * self.out_w[k].1);
let (cr, ci) = self.rot[k];
s[k] = (re * cr - im * ci + come * self.in_w[k].0, re * ci + im * cr + come * self.in_w[k].1);
}
} else {
// Direct sums over the in-line part of the window.
sb = 0.0;
s = [(0.0, 0.0); TERMS];
for (i, v) in src.iter().enumerate().take(hi).skip(lo) {
let v = f64::from(*v);
if v == 0.0 {
continue;
}
let j = i as f64 - x as f64;
sb += v;
for (sk, w) in s.iter_mut().zip(&self.omega) {
sk.0 += v * (w * j).cos();
sk.1 += v * (w * j).sin();
}
}
have = true;
}
let v = self.a0 * sb + s.iter().zip(&self.a).map(|(sk, a)| a * sk.0).sum::<f64>();
dst[x] = v as f32;
}
}
}
impl CosKernel {
/// [`CosKernel::line`] down columns `c0..c0 + sw`, all columns of a row at once.
#[allow(clippy::needless_range_loop)] // `k` indexes several term arrays in lockstep
fn strip(&self, rows: &Rows, c0: usize, sw: usize) -> Vec<f32> {
let gh = rows.gh;
let r = self.r;
let mut out = vec![0.0f32; gh * sw];
if sw == 0 {
return out;
}
// Rows whose strip segment is one value, and the runs of equal such rows.
let flag: Vec<Option<f32>> = (0..gh)
.map(|yy| match rows.get(yy, c0, sw) {
None => Some(0.0),
Some(s) => {
let c = s.first().copied().unwrap_or(0.0);
s.iter().all(|v| *v == c).then_some(c)
}
})
.collect();
let mut runs = vec![gh; gh];
for i in (0..gh.saturating_sub(1)).rev() {
runs[i] = match (flag[i], flag[i + 1]) {
(Some(a), Some(b)) if a == b => runs[i + 1],
_ => i + 1,
};
}
let zeros = vec![0.0f32; sw];
let mut sb = vec![0.0f64; sw];
let mut re = vec![vec![0.0f64; sw]; TERMS];
let mut im = vec![vec![0.0f64; sw]; TERMS];
let mut acc = vec![0.0f64; sw];
let mut have = false;
for (yy, dst) in out.chunks_exact_mut(sw).enumerate() {
let lo = yy.saturating_sub(r);
let hi = (yy + r + 1).min(gh);
// The whole window is one value (zero rows may run off the window; others may not).
if let Some(c) = flag[lo]
&& runs[lo] >= hi
&& (c == 0.0 || (yy >= r && yy + r < gh))
{
dst.fill(c);
let cf = f64::from(c);
sb.fill(cf * (2 * r + 1) as f64);
for k in 0..TERMS {
re[k].fill(cf * self.full[k]);
im[k].fill(0.0);
}
have = true;
continue;
}
if have {
// Slide from yy - 1: drop row yy - 1 - r, take row yy + r.
let gone = if yy > r { rows.get(yy - 1 - r, c0, sw) } else { None }.unwrap_or(&zeros);
let come = rows.get(yy + r, c0, sw).unwrap_or(&zeros);
for ((s, g), c) in sb.iter_mut().zip(gone).zip(come) {
*s += f64::from(*c) - f64::from(*g);
}
for k in 0..TERMS {
let (cr, ci) = self.rot[k];
let (o0, o1) = self.out_w[k];
let (i0, i1) = self.in_w[k];
for (((sr, si), g), c) in re[k].iter_mut().zip(im[k].iter_mut()).zip(gone).zip(come) {
let (g, c) = (f64::from(*g), f64::from(*c));
let (a, b) = (*sr - g * o0, *si - g * o1);
*sr = a * cr - b * ci + c * i0;
*si = a * ci + b * cr + c * i1;
}
}
} else {
// Direct sums over the in-window rows.
sb.fill(0.0);
for k in 0..TERMS {
re[k].fill(0.0);
im[k].fill(0.0);
}
for i in lo..hi {
let Some(row) = rows.get(i, c0, sw) else { continue };
let j = i as f64 - yy as f64;
add_row(&mut sb, rows, i, c0, 1.0);
for k in 0..TERMS {
let (cs, sn) = ((self.omega[k] * j).cos(), (self.omega[k] * j).sin());
for ((sr, si), v) in re[k].iter_mut().zip(im[k].iter_mut()).zip(row) {
*sr += f64::from(*v) * cs;
*si += f64::from(*v) * sn;
}
}
}
have = true;
}
for (a, s) in acc.iter_mut().zip(&sb) {
*a = self.a0 * s;
}
for k in 0..TERMS {
for (a, s) in acc.iter_mut().zip(&re[k]) {
*a += self.a[k] * s;
}
}
for (d, a) in dst.iter_mut().zip(&acc) {
*d = *a as f32;
}
}
out
}
}
/// Select › Modify › Feather: Gaussian blur of the mask with `σ = radius / 2`, the kernel
/// truncated at `⌈3σ⌉` and normalized, zero beyond the canvas.
pub(crate) fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
let n = w.saturating_mul(h);
let sigma = if radius.is_finite() {
radius.max(0.0) / 2.0
} else if radius > 0.0 {
MAX_SIGMA
} else {
0.0
};
if sigma < 0.1 || m.len() != n || n == 0 {
return m.to_vec();
}
let sigma = sigma.min(MAX_SIGMA);
let r = (sigma * 3.0).ceil() as usize;
let Some(b) = nonzero_bounds(m, w, h) else {
return m.to_vec();
};
let win = grow(b, r, w, h);
let (gw, gh) = (win.w(), win.h());
let s = f64::from(sigma);
let (kh, kv) = (Kernel::new(s, r, gw), Kernel::new(s, r, gh));
// Rows: only the bounding box's rows are non-zero.
let mut tmp = vec![0.0f32; b.h() * gw];
par_rows(&mut tmp, gw, 1, |y, row| {
let o = (b.y0 + y) * w + win.x0;
if let Some(s) = m.get(o..o + gw) {
kh.line(s, row, &mut Vec::new());
}
});
// Columns, a strip of columns at a time.
let rows = Rows { tmp: &tmp, gw, bh: b.h(), off: b.y0 - win.y0, gh };
let res = strips(gw, |c0, sw| kv.strip(&rows, c0, sw));
drop(tmp);
// Rounding residue of the sliding sums is not coverage.
assemble(&res, win, w, h, 0.0, |v| if v.abs() < 1e-7 { 0.0 } else { v })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::photo_util::Rng;
/// The previous direct implementations, kept as the oracle.
mod oracle {
pub fn box_blur(m: &[f32], w: usize, h: usize, r: usize) -> Vec<f32> {
if r == 0 {
return m.to_vec();
}
let mut tmp = vec![0.0f32; m.len()];
let win = (2 * r + 1) as f32;
for y in 0..h {
for x in 0..w {
let mut s = 0.0;
for k in x as i64 - r as i64..=x as i64 + r as i64 {
s += m[y * w + k.clamp(0, w as i64 - 1) as usize];
}
tmp[y * w + x] = s / win;
}
}
let mut out = vec![0.0f32; m.len()];
for y in 0..h {
for x in 0..w {
let mut s = 0.0;
for k in y as i64 - r as i64..=y as i64 + r as i64 {
s += tmp[k.clamp(0, h as i64 - 1) as usize * w + x];
}
out[y * w + x] = s / win;
}
}
out
}
pub fn smooth(m: &[f32], w: usize, h: usize, r: f32) -> Vec<f32> {
box_blur(m, w, h, r.max(0.0).round() as usize).into_iter().map(|v| ((v - 0.5) * 4.0 + 0.5).clamp(0.0, 1.0)).collect()
}
pub fn feather(m: &[f32], w: usize, h: usize, radius: f32) -> Vec<f32> {
let sigma = radius.max(0.0) / 2.0;
if sigma < 0.1 {
return m.to_vec();
}
let r = (sigma * 3.0).ceil() as i64;
let k: Vec<f32> = (-r..=r).map(|i| (-(i * i) as f32 / (2.0 * sigma * sigma)).exp()).collect();
let s: f32 = k.iter().sum();
let k: Vec<f32> = k.iter().map(|v| v / s).collect();
let mut tmp = vec![0.0f32; m.len()];
for y in 0..h {
for x in 0..w {
let mut a = 0.0;
for (i, kv) in k.iter().enumerate() {
let xx = x as i64 + i as i64 - r;
if xx >= 0 && xx < w as i64 {
a += m[y * w + xx as usize] * kv;
}
}
tmp[y * w + x] = a;
}
}
let mut out = vec![0.0f32; m.len()];
for y in 0..h {
for x in 0..w {
let mut a = 0.0;
for (i, kv) in k.iter().enumerate() {
let yy = y as i64 + i as i64 - r;
if yy >= 0 && yy < h as i64 {
a += tmp[yy as usize * w + x] * kv;
}
}
out[y * w + x] = a;
}
}
out
}
}
fn max_diff(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len());
a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
}
/// Random masks of several kinds: binary noise, soft noise, blobs with soft edges, a sparse
/// scatter and an off-centre rectangle.
fn masks(w: usize, h: usize, seed: u64) -> Vec<(&'static str, Vec<f32>)> {
let mut rng = Rng(seed);
let mut u = move || (rng.next() >> 11) as f32 / (1u64 << 53) as f32;
let binary: Vec<f32> = (0..w * h).map(|_| if u() < 0.5 { 1.0 } else { 0.0 }).collect();
let soft: Vec<f32> = (0..w * h).map(|_| u()).collect();
let (cx, cy, rad) = (w as f32 * u(), h as f32 * u(), (w.min(h) as f32 * 0.4).max(1.0));
let blob: Vec<f32> = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32 + 0.5, (i / w) as f32 + 0.5);
(rad - ((x - cx).powi(2) + (y - cy).powi(2)).sqrt()).clamp(0.0, 1.0)
})
.collect();
let sparse: Vec<f32> = (0..w * h).map(|_| if u() < 0.02 { 1.0 } else { 0.0 }).collect();
let rect: Vec<f32> = (0..w * h).map(|i| if (i % w) * 3 >= w && (i / w) * 4 < h * 3 { 1.0 } else { 0.0 }).collect();
vec![("binary", binary), ("soft", soft), ("blob", blob), ("sparse", sparse), ("rect", rect)]
}
#[test]
fn feather_matches_direct_gaussian() {
for (w, h, seed) in [(97, 61, 1), (40, 150, 2), (13, 7, 3)] {
for (kind, m) in masks(w, h, seed) {
for radius in [0.1, 0.3, 1.0, 2.5, 8.0, 9.0, 17.0, 25.0, 50.0, 120.0, 1000.0] {
let d = max_diff(&feather(&m, w, h, radius), &oracle::feather(&m, w, h, radius));
assert!(d <= 1.0 / 255.0, "{kind} {w}x{h} r {radius}: {d}");
}
}
}
}
#[test]
fn smooth_matches_direct_box() {
for (w, h, seed) in [(97, 61, 4), (40, 150, 5), (13, 7, 6)] {
for (kind, m) in masks(w, h, seed) {
for radius in [0.0, 0.4, 1.0, 2.0, 5.0, 16.0, 50.0, 500.0] {
let d = max_diff(&smooth(&m, w, h, radius), &oracle::smooth(&m, w, h, radius));
assert!(d <= 1.0 / 255.0, "{kind} {w}x{h} r {radius}: {d}");
}
}
}
}
#[test]
fn edge_cases() {
// Empty selection stays empty; nothing selected far from a selection stays exactly 0.
let z = vec![0.0; 50 * 40];
assert_eq!(feather(&z, 50, 40, 30.0), z);
assert_eq!(smooth(&z, 50, 40, 10.0), z);
let mut dot = z.clone();
dot[20 * 50 + 25] = 1.0;
let f = feather(&dot, 50, 40, 4.0);
assert_eq!(f[0], 0.0);
assert!(f[20 * 50 + 25] > 0.0);
// A full canvas: smooth keeps it; feather fades only at the canvas edge, as before.
let one = vec![1.0; 50 * 40];
assert_eq!(smooth(&one, 50, 40, 25.0), one);
let f = feather(&one, 50, 40, 6.0);
assert!((f[20 * 50 + 25] - 1.0).abs() < 1e-5);
let f30 = feather(&one, 50, 40, 30.0);
assert!(max_diff(&f30, &oracle::feather(&one, 50, 40, 30.0)) <= 1.0 / 255.0);
assert!(f[0] < 0.5);
assert!(max_diff(&f, &oracle::feather(&one, 50, 40, 6.0)) <= 1.0 / 255.0);
// Radius 0 is identity (feather) / the bare contrast curve (smooth).
let m: Vec<f32> = (0..50 * 40).map(|i| (i % 7) as f32 / 6.0).collect();
assert_eq!(feather(&m, 50, 40, 0.0), m);
assert_eq!(smooth(&m, 50, 40, 0.0), oracle::smooth(&m, 50, 40, 0.0));
// 1×1 canvas and empty canvases.
for r in [0.0, 1.0, 50.0, 1000.0] {
assert!((feather(&[1.0], 1, 1, r)[0] - oracle::feather(&[1.0], 1, 1, r)[0]).abs() <= 1.0 / 255.0);
assert_eq!(smooth(&[1.0], 1, 1, r), vec![1.0]);
}
assert!(feather(&[], 0, 0, 5.0).is_empty());
assert!(smooth(&[], 0, 5, 5.0).is_empty());
// Non-finite or absurd radii and a mismatched buffer return without panicking.
for r in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -5.0, 1e30] {
let f = feather(&dot, 50, 40, r);
assert_eq!(f.len(), dot.len());
assert!(f.iter().all(|v| v.is_finite()), "{r}");
let s = smooth(&dot, 50, 40, r);
assert_eq!(s.len(), dot.len());
}
assert_eq!(feather(&dot, 50, 41, 5.0).len(), dot.len());
assert_eq!(smooth(&dot, 49, 40, 5.0).len(), dot.len());
}
/// Timings on a 24 MP ellipse (run with `--release -- --ignored --nocapture`).
#[test]
#[ignore]
fn bench_24mp() {
let (w, h) = (6000usize, 4000usize);
let m: Vec<f32> = (0..w * h)
.map(|i| {
let (x, y) = ((i % w) as f32 / w as f32 - 0.5, (i / w) as f32 / h as f32 - 0.5);
if x * x + y * y < 0.11 { 1.0 } else { 0.0 }
})
.collect();
// `SEL_BENCH=feather:250` runs one case (e.g. under `time` for its CPU cost).
let only = std::env::var("SEL_BENCH").ok();
for (name, r) in [("smooth", 50.0), ("feather", 50.0), ("feather", 250.0), ("feather", 1000.0)] {
if only.as_ref().is_some_and(|o| *o != format!("{name}:{r}")) {
continue;
}
let mut best = f64::MAX;
for _ in 0..5 {
let t = std::time::Instant::now();
let out = if name == "smooth" { smooth(&m, w, h, r) } else { feather(&m, w, h, r) };
best = best.min(t.elapsed().as_secs_f64() * 1000.0);
assert_eq!(out.len(), m.len());
}
eprintln!("{name} r {r}: {best:.1} ms (best of 5)");
}
}
#[test]
fn cosine_fit_is_used_and_accurate() {
for sigma in [7.0, 25.0, 125.0] {
let r = (sigma * 3.0f64).ceil() as usize;
match Kernel::new(sigma, r, 4000) {
Kernel::Cos(_) => {}
_ => panic!("σ {sigma}: expected the sliding cosine kernel"),
}
}
}
}
+26
View File
@@ -74,6 +74,7 @@ fn new_filters() -> Vec<FilterParams> {
height: 50.0,
white_is_high: true,
},
FilterParams::Relight { angle: 45.0, elevation: 40.0, intensity: 40.0, ambient: 55.0, warmth: 0.0, softness: 25.0 },
FilterParams::ReduceNoise { strength: 6.0, preserve_details: 60.0, reduce_color_noise: 45.0, sharpen_details: 25.0, remove_jpeg_artifact: true },
FilterParams::SmartBlur { radius: 3.0, threshold: 40.0, quality: BlurQuality::High, mode: SmartBlurMode::Normal },
FilterParams::SmartBlur { radius: 3.0, threshold: 25.0, quality: BlurQuality::Low, mode: SmartBlurMode::OverlayEdge },
@@ -666,3 +667,28 @@ fn oil_paint_smooths_along_strokes() {
let inner = Rect::new(8, 8, 32, 22);
assert!(variance(&out, inner, 0) < variance(&s, inner, 0) * 0.5);
}
#[test]
fn cancelled_filters_return_none_and_progress_never_regresses() {
use std::sync::atomic::{AtomicU32, Ordering};
let s = pattern(SampleType::U8, R);
let p = FilterParams::GaussianBlur { radius: 3.0 };
let area = output_area(&p, s.content_bounds(), R, None);
let yes = || true;
assert!(apply_tiled_with(&s, &p, area, R, None, 16, Some(R), &photocraft_raster::Interrupt::cancel_only(&yes)).is_none());
// Tile workers report progress from their own threads, so values can arrive out of order;
// consumers keep the high-water mark (as `JobCtx::progress` does with `fetch_max`).
// Non-negative f32s order like their bits, so `fetch_max` on the bits needs no lock.
let high = AtomicU32::new(0);
let calls = AtomicU32::new(0);
let no = || false;
let progress = |f: f32| {
assert!((0.0..=1.0).contains(&f), "progress in range: {f}");
high.fetch_max(f.to_bits(), Ordering::Relaxed);
calls.fetch_add(1, Ordering::Relaxed);
};
let out = apply_tiled_with(&s, &p, area, R, None, 16, Some(R), &photocraft_raster::Interrupt::new(&no, &progress)).unwrap();
assert_eq!(f32::from_bits(high.load(Ordering::Relaxed)), 1.0);
assert!(calls.load(Ordering::Relaxed) >= 1);
assert_eq!(out.read_region(R), apply_tiled(&s, &p, area, R, None, 16, Some(R)).read_region(R));
}
+3 -1
View File
@@ -343,7 +343,9 @@ pub fn hdr_toning(px: &mut [[f32; 4]], w: usize, h: usize, p: &HdrToning) {
let c = [q[0], q[1], q[2]];
let l0 = luma(c).max(0.0) + EPS;
let new_log = mean + (base[i] - mean) * compress + (logl[i] - base[i]) * detail;
let mut l1 = (new_log.exp() * exposure).max(0.0).powf(gamma);
// The contract is "lower = more contrast", i.e. gamma is the divisor of
// the exponent, matching the crate's other gamma controls (hdr.rs, proof_sim.rs).
let mut l1 = (new_log.exp() * exposure).max(0.0).powf(1.0 / gamma);
l1 += p.shadow / 100.0 * (1.0 - l1).powi(3) * 0.5 - p.highlight / 100.0 * l1.powi(3) * 0.5;
l1 = curve_eval(&sorted, l1.clamp(0.0, 1.0));
let ratio = l1 / l0;
+72
View File
@@ -0,0 +1,72 @@
//! Bounded hue/saturation distribution. Input colours must already be in the chosen RGB space.
//! Display orientation and colour management belong to callers, not to this analysis.
pub const HUES: usize = 128;
pub const SATURATIONS: usize = 64;
#[derive(Clone, Debug, PartialEq)]
pub struct Vectorscope {
pub counts: Vec<u32>,
pub samples: u64,
}
impl Default for Vectorscope {
fn default() -> Self {
Self { counts: vec![0; HUES * SATURATIONS], samples: 0 }
}
}
impl Vectorscope {
pub fn from_rgba(pixels: &[[f32; 4]]) -> Self {
let mut out = Self::default();
for p in pixels {
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
continue;
}
let rgb = [p[0].clamp(0.0, 1.0), p[1].clamp(0.0, 1.0), p[2].clamp(0.0, 1.0)];
let (h, s) = hue_saturation(rgb);
let hue = ((h * HUES as f32) as usize).min(HUES - 1);
let saturation = ((s * (SATURATIONS - 1) as f32).round() as usize).min(SATURATIONS - 1);
if let Some(n) = out.counts.get_mut(hue * SATURATIONS + saturation) {
*n = n.saturating_add(1);
}
out.samples = out.samples.saturating_add(1);
}
out
}
}
/// Hue as turns and saturation in 0..1; achromatic pixels have zero saturation.
pub fn hue_saturation([r, g, b]: [f32; 3]) -> (f32, f32) {
let high = r.max(g).max(b);
let low = r.min(g).min(b);
let delta = high - low;
if delta <= f32::EPSILON || high <= 0.0 {
return (0.0, 0.0);
}
let h = if high == r {
(g - b) / delta
} else if high == g {
2.0 + (b - r) / delta
} else {
4.0 + (r - g) / delta
};
((h / 6.0).rem_euclid(1.0), delta / high)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgb_primary_positions_neutrals_and_transparency() {
for (rgb, expected) in [([1.0, 0.0, 0.0], 0.0), ([1.0, 1.0, 0.0], 1.0 / 6.0), ([0.0, 1.0, 0.0], 1.0 / 3.0), ([0.0, 0.0, 1.0], 2.0 / 3.0)] {
let (h, s) = hue_saturation(rgb);
assert!((h - expected).abs() < 1e-6);
assert_eq!(s, 1.0);
}
let v = Vectorscope::from_rgba(&[[0.5, 0.5, 0.5, 1.0], [1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.0], [f32::NAN, 0.0, 0.0, 1.0]]);
assert_eq!(v.samples, 2);
assert_eq!(v.counts[0], 1);
assert_eq!(v.counts[SATURATIONS - 1], 1);
assert_eq!(v.counts.iter().map(|&c| u64::from(c)).sum::<u64>(), v.samples);
}
}
+33
View File
@@ -26,6 +26,39 @@ const CELL: f64 = 4.0;
/// Most cells along one axis (bounds memory and time on huge layers).
const MAX_CELLS: usize = 512;
/// Places a smart object's source image (`src`, its pixels in `src_rect`) in the document:
/// through `warp` (source space) and then the affine `t` (source → document) in one resampling
/// pass, an exact shift for whole-pixel translations (so conversions and re-renders are
/// lossless), bicubic otherwise. Shared by the engine's re-render and PSD export's filter cache.
pub fn place_source(src: &Surface, src_rect: Rect, t: &photocraft_geom::Affine, warp: Option<&photocraft_geom::warp::Warp>) -> Surface {
let [a, b, c, d, e, f] = t.m;
if let Some(w) = warp.filter(|w| !w.is_identity()) {
let map = |x: f64, y: f64| {
let (u, v) = w.map(x, y);
(a * u + c * v + e, b * u + d * v + f)
};
return warp_mesh_surface(src, src_rect, &map, Interp::Bicubic);
}
let near = |x: f64, y: f64| (x - y).abs() < 1e-9;
if near(a, 1.0) && near(b, 0.0) && near(c, 0.0) && near(d, 1.0) && near(e, e.round()) && near(f, f.round()) {
return crate::resample::translate_surface(src, e.round() as i32, f.round() as i32);
}
crate::transform::warp_surface(src, src_rect, &crate::transform::Homography([a, c, e, b, d, f, 0.0, 0.0, 1.0]), Interp::Bicubic)
}
/// [`place_source`] through a projective map `h` (source → document; Distort, Perspective): the
/// warp first (in source space), then `h`.
pub fn place_source_projective(src: &Surface, src_rect: Rect, h: &crate::transform::Homography, warp: Option<&photocraft_geom::warp::Warp>) -> Surface {
if let Some(w) = warp.filter(|w| !w.is_identity()) {
let map = |x: f64, y: f64| {
let (u, v) = w.map(x, y);
h.apply(u, v)
};
return warp_mesh_surface(src, src_rect, &map, Interp::Bicubic);
}
crate::transform::warp_surface(src, src_rect, h, Interp::Bicubic)
}
/// Warps the content of `src` inside `src_rect` through the forward map `f` (source document
/// coordinates → destination document coordinates). Output has `src`'s format with alpha
/// added; pixels outside the warped area are transparent.
+33 -4
View File
@@ -103,19 +103,34 @@ pub fn write_reply(out: &mut impl Write, reply: &Value) -> io::Result<()> {
/// many individually valid replies cannot accumulate into an enormous batch.
pub struct BatchReplyBudget {
remaining: usize,
/// Charge each result as it costs inside a JSON string (MCP text content).
escaped: bool,
}
impl Default for BatchReplyBudget {
fn default() -> Self {
Self { remaining: MAX_RESPONSE_BYTES - MAX_REQUEST_BYTES - 4096 }
Self { remaining: MAX_RESPONSE_BYTES - MAX_REQUEST_BYTES - 4096, escaped: false }
}
}
impl BatchReplyBudget {
/// For a batch reply sent as MCP text content: the encoded reply is embedded in a JSON
/// string, which escapes every quote and backslash again, so a batch the plain budget accepts
/// could exceed the tool-result ceiling and lose every step result.
pub fn escaped() -> Self {
Self { escaped: true, ..Self::default() }
}
/// Must be called before retaining the result or dispatching the next step.
pub fn charge(&mut self, result: &Value) -> Result<(), AutomationError> {
let bytes = encode_with_limit(result, self.remaining.saturating_sub(1))?;
self.remaining = self.remaining.saturating_sub(bytes.len() + 1);
let limit = self.remaining.saturating_sub(1);
let bytes = encode_with_limit(result, limit)?;
// Compact JSON has no raw control characters, so only `"` and `\` grow when escaped.
let size = if self.escaped { bytes.len() + bytes.iter().filter(|&&b| b == b'"' || b == b'\\').count() } else { bytes.len() };
if size > limit {
return Err(bad(format!("response exceeds {limit} bytes once escaped")));
}
self.remaining = self.remaining.saturating_sub(size + 1);
Ok(())
}
}
@@ -161,9 +176,23 @@ mod tests {
#[test]
fn batch_cannot_retain_more_than_the_aggregate_reply_budget() {
let mut budget = BatchReplyBudget { remaining: 10 };
let mut budget = BatchReplyBudget { remaining: 10, escaped: false };
budget.charge(&json!("abc")).unwrap();
assert!(budget.charge(&json!("abc")).is_err());
assert_eq!(budget.remaining, 4);
}
#[test]
fn escaped_budget_charges_the_size_inside_a_json_string() {
// `"a\"b"` is 6 bytes plain and 10 once embedded in a string (`\"a\\\"b\"`).
let value = json!("a\"b");
let escaped_len = serde_json::to_string(&Value::String(value.to_string())).unwrap().len() - 2;
assert_eq!(escaped_len, 10);
let mut budget = BatchReplyBudget { remaining: 11, escaped: true };
budget.charge(&value).unwrap();
assert_eq!(budget.remaining, 0);
let mut budget = BatchReplyBudget { remaining: 10, escaped: true };
assert!(budget.charge(&value).is_err());
assert!(BatchReplyBudget { remaining: 10, escaped: false }.charge(&value).is_ok());
}
}
+3 -5
View File
@@ -30,7 +30,7 @@ pub fn open(path: &Path) -> Result<Opened, AutomationError> {
if path.is_dir() {
return Ok(Opened { document: photocraft_format::load_path(path)?, warnings: Vec::new() });
}
let bytes = std::fs::read(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let bytes = photocraft_format::read_file(path).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
open_bytes(&name, &bytes)
}
@@ -61,10 +61,8 @@ pub fn save(
return Ok(Vec::new());
}
let r = photocraft_io::export(doc, &ext, opts)?;
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent).map_err(|e| AutomationError::Io(e.to_string()))?;
}
std::fs::write(path, &r.bytes).map_err(|e| AutomationError::Io(format!("{}: {e}", path.display())))?;
// Crash-safe: a failed write never destroys the previous file.
photocraft_format::atomic_write(path, &r.bytes).map_err(|e| AutomationError::Io(e.to_string()))?;
Ok(r.warnings)
}
+58 -10
View File
@@ -4,14 +4,20 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use photocraft_engine::{Session, command_specs};
use photocraft_engine::{Session, command_specs, file_cmds};
use photocraft_format::PcraftWriter;
use photocraft_io::ExportOptions;
use serde_json::{Value, json};
use crate::workspace::authorize_engine_command;
use crate::workspace::{authorize_engine_command, authorize_engine_step};
use crate::{AuthorizedWorkspace, AutomationError, files};
fn untrusted(filesystem: Filesystem) -> Headless {
let mut session = Session::new();
session.authorize = Some(authorize_engine_step);
Headless { session, writers: HashMap::new(), filesystem }
}
enum Filesystem {
Denied,
TrustedLocal,
@@ -28,7 +34,7 @@ pub struct Headless {
impl Default for Headless {
fn default() -> Self {
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::Denied }
untrusted(Filesystem::Denied)
}
}
@@ -39,14 +45,23 @@ impl Headless {
}
/// Create a session for an explicit local CLI invocation. The CLI caller,
/// not a remote automation client, supplies these host paths.
/// not a remote automation client, supplies these host paths. Nested
/// `actions.play` steps are not re-checked: this caller is already trusted.
pub fn trusted_local() -> Self {
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::TrustedLocal }
}
/// Create an automation session with capability-scoped file access.
/// Each step of `actions.play` is checked with [`authorize_engine_step`].
pub fn with_workspace(workspace: AuthorizedWorkspace) -> Self {
Self { session: Session::new(), writers: HashMap::new(), filesystem: Filesystem::Workspace(workspace) }
untrusted(Filesystem::Workspace(workspace))
}
/// Apply background jobs that finished since the last request, so every request (save,
/// export, inspect, preview, `session.list`, commands) sees their result. Cheap when no job
/// runs. The JSON-lines server and the MCP server call it before each request.
pub fn sync_jobs(&mut self) {
self.session.poll_jobs();
}
fn doc_index(&self, index: Option<usize>) -> Result<usize, AutomationError> {
@@ -60,7 +75,7 @@ impl Headless {
/// Open a file and make it the active document.
pub fn open(&mut self, path: &Path) -> Result<Value, AutomationError> {
let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
let o = match &self.filesystem {
let mut o = match &self.filesystem {
Filesystem::Denied => return Err(AutomationError::BadRequest("automation filesystem access is not granted: read authority is absent".into())),
Filesystem::TrustedLocal => files::open(path)?,
Filesystem::Workspace(workspace) => {
@@ -69,7 +84,14 @@ impl Headless {
files::open_bytes(name, &bytes)?
}
};
let index = self.session.add_document(o.document, Some(requested.to_string()));
let path = match file_cmds::template_name(&self.session, requested) {
Some(untitled) => {
o.document.name = untitled;
None
}
None => Some(requested.to_string()),
};
let index = self.session.add_document(o.document, path);
let d = &self.session.documents()[index];
Ok(json!({
"index": index,
@@ -91,7 +113,18 @@ impl Headless {
};
let target: PathBuf = match (path, stored_path.as_deref()) {
(Some(p), _) => p.to_path_buf(),
(None, Some(p)) => PathBuf::from(p),
// Like the desktop's File › Save: without a new path, only a layered file is written
// back, in its own format; a flattened or converted copy never replaces it (#416).
(None, Some(p)) => {
let own = file_cmds::extension(p);
let written = format.map(|f| f.trim_start_matches('.').to_ascii_lowercase()).or_else(|| own.clone());
if !file_cmds::saves_in_place(p) || written != own {
return Err(AutomationError::BadRequest(format!(
"pass `path`: without one, only a PSD, PSB or .pcraft file is written back, in its own format, so `{p}` was left unchanged"
)));
}
PathBuf::from(p)
}
(None, None) => {
return Err(AutomationError::BadRequest("document has no path; pass `path`".into()));
}
@@ -127,7 +160,7 @@ impl Headless {
let requested = path.to_str().ok_or_else(|| AutomationError::BadRequest("automation paths must be valid UTF-8".into()))?;
match &self.filesystem {
Filesystem::Denied => Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())),
Filesystem::TrustedLocal => std::fs::write(path, bytes).map_err(|error| AutomationError::Io(format!("{}: {error}", path.display()))),
Filesystem::TrustedLocal => photocraft_format::atomic_write(path, bytes).map_err(|error| AutomationError::Io(error.to_string())),
Filesystem::Workspace(workspace) => workspace.write(requested, bytes),
}
}
@@ -185,10 +218,25 @@ impl Headless {
}
pub fn command_run(&mut self, id: &str, params: Value) -> Result<Value, AutomationError> {
self.command_start(id, params, true)
}
/// Run a command; with `wait` false, a job-capable command (filters, Content-Aware Fill,
/// Photomerge, …) runs in the background and this returns `{"job": id}` at once (poll with
/// `jobs.list`, stop with `jobs.cancel`). Other commands finish before returning either way.
pub fn command_start(&mut self, id: &str, params: Value, wait: bool) -> Result<Value, AutomationError> {
let params = if params.is_null() { json!({}) } else { params };
if !matches!(&self.filesystem, Filesystem::TrustedLocal) {
authorize_engine_command(id, &params)?;
}
Ok(self.session.execute(id, params)?)
// Background jobs that finished since the last request (or batch step) are applied first.
self.sync_jobs();
if wait {
return Ok(self.session.execute(id, params)?);
}
Ok(match self.session.start(id, params)? {
photocraft_engine::jobs::Started::Done(v) => v,
photocraft_engine::jobs::Started::Job(job) => json!({"job": job.0, "pending": true}),
})
}
}
+166 -22
View File
@@ -5,12 +5,13 @@
//! loopback TCP port.
//!
//! Methods (camelCase params):
//! - `engine.execute {command, params?}` / `engine.commands {filter?}`
//! - `engine.execute {command, params?, wait?}` / `engine.commands {filter?}`
//! - `jobs.list` / `jobs.cancel {job?}`: background jobs (#210)
//! - `session.list`, `doc.open {path}`, `doc.new {…file.new params}`,
//! `doc.save {path?, format?, quality?, index?}`, `doc.inspect {index?}`,
//! `doc.render {index?, maxSide?, path?}` (writes a PNG to `path`, else
//! returns it base64-encoded), `doc.select {index}`, `doc.close {index?}`
//! - `batch {steps: [{command, params?} | {method, params?}], stopOnError?}`
//! - `batch {steps: [{command, params?, wait?} | {method, params?}], stopOnError?}`
//! - `methods`: this list.
use std::io::{BufRead, Write};
@@ -23,13 +24,16 @@ 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, read_bounded_line,
ConnectionLimiter, LineRead, MAX_BATCH_STEPS, MAX_CONNECTIONS, MAX_REQUEST_BYTES, authentication_reply, configure_stream, discard_rest_of_line,
read_bounded_line,
};
use crate::{AutomationError, Headless};
/// Method names served by [`Headless::handle`].
pub const METHODS: &[&str] = &[
"engine.execute",
"jobs.list",
"jobs.cancel",
"engine.commands",
"session.list",
"doc.open",
@@ -58,12 +62,17 @@ fn str_of<'a>(p: &'a Value, k: &str) -> Option<&'a str> {
impl Headless {
/// Dispatch one request. Unknown methods and bad params are errors, never panics.
pub fn handle(&mut self, method: &str, params: Value) -> Result<Value, AutomationError> {
self.sync_jobs();
let p = if params.is_null() { json!({}) } else { params };
match method {
"engine.execute" => {
let id = str_of(&p, "command").ok_or_else(|| bad("engine.execute needs `command`"))?;
self.command_run(id, p.get("params").cloned().unwrap_or(Value::Null))
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(true);
self.command_start(id, p.get("params").cloned().unwrap_or(Value::Null), wait)
}
// Background jobs (#210): list (applying any that finished) and cancel.
"jobs.list" => self.command_start("jobs.list", json!({}), true),
"jobs.cancel" => self.command_start("jobs.cancel", p, true),
"engine.commands" => {
let all = self.command_list();
Ok(match str_of(&p, "filter").map(str::to_lowercase) {
@@ -88,7 +97,8 @@ impl Headless {
}
"doc.new" => self.command_run("file.new", p),
"doc.save" => {
let mut opts = photocraft_io::ExportOptions::default();
let mut opts =
photocraft_io::ExportOptions { tiff_layers: p.get("tiffLayers").and_then(Value::as_bool).unwrap_or(false), ..Default::default() };
if let Some(q) = p.get("quality").and_then(Value::as_u64) {
opts.encode.jpeg_quality = q.clamp(1, 100) as u8;
}
@@ -126,10 +136,16 @@ impl Headless {
}
}
/// Run `steps` in order. Each step is `{command, params?}` (an engine command) or
/// Run `steps` in order. Each step is `{command, params?, wait?}` (an engine command) or
/// `{method, params?}` (any [`METHODS`] entry). Stops at the first error unless
/// `stopOnError` is false; the reply lists every step's result.
pub fn batch(&mut self, p: &Value) -> Result<Value, AutomationError> {
self.batch_with_budget(p, BatchReplyBudget::default())
}
/// [`Headless::batch`] with a caller's reply budget (MCP charges escaped sizes, see
/// [`BatchReplyBudget::escaped`]). Steps stop once the budget runs out.
pub fn batch_with_budget(&mut self, p: &Value, mut reply_budget: BatchReplyBudget) -> Result<Value, AutomationError> {
let steps = p.get("steps").and_then(Value::as_array).ok_or_else(|| bad("batch needs `steps`"))?;
if steps.len() > MAX_BATCH_STEPS {
return Err(bad(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", steps.len())));
@@ -137,11 +153,12 @@ impl Headless {
let stop = p.get("stopOnError").and_then(Value::as_bool).unwrap_or(true);
let mut results = Vec::with_capacity(steps.len());
let mut failed = 0usize;
let mut reply_budget = BatchReplyBudget::default();
for (i, s) in steps.iter().enumerate() {
let params = s.get("params").cloned().unwrap_or(Value::Null);
let r = if let Some(c) = str_of(s, "command") {
self.command_run(c, params)
// Like `engine.execute`: `wait: false` starts a long command as a background job.
let wait = s.get("wait").and_then(Value::as_bool).unwrap_or(true);
self.command_start(c, params, wait)
} else if let Some(m) = str_of(s, "method") {
if m == "batch" { Err(bad("nested batch")) } else { self.handle(m, params) }
} else {
@@ -192,25 +209,24 @@ pub fn respond(h: &Mutex<Headless>, line: &str) -> Value {
}
}
/// Serve JSON lines from `r` to `w` until EOF. Blank lines are ignored.
/// Serve JSON lines from `r` to `w` until EOF. Blank lines are ignored. An over-long or non-UTF-8
/// line gets an error reply and is skipped; the session (and its open documents) keeps serving.
pub fn serve_lines(h: &Mutex<Headless>, mut r: impl BufRead, mut w: impl Write) -> std::io::Result<()> {
let mut line = String::new();
loop {
match read_bounded_line(&mut r, &mut line)? {
LineRead::Eof => return Ok(()),
LineRead::TooLong => {
write_reply(&mut w, &json!({"id": null, "ok": false, "error": format!("request exceeds {MAX_REQUEST_BYTES} bytes")}))?;
w.flush()?;
return Ok(());
}
LineRead::Line => {}
}
if line.trim().is_empty() {
continue;
}
let reply = respond(h, &line);
let (reply, unread_rest) = match read_bounded_line(&mut r, &mut line) {
Ok(LineRead::Eof) => return Ok(()),
Ok(LineRead::TooLong) => (json!({"id": null, "ok": false, "error": format!("request exceeds {MAX_REQUEST_BYTES} bytes")}), !line.ends_with('\n')),
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => (json!({"id": null, "ok": false, "error": "request is not valid UTF-8"}), false),
Err(e) => return Err(e),
Ok(LineRead::Line) if line.trim().is_empty() => continue,
Ok(LineRead::Line) => (respond(h, &line), false),
};
write_reply(&mut w, &reply)?;
w.flush()?;
if unread_rest {
discard_rest_of_line(&mut r)?;
}
}
}
@@ -378,6 +394,96 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn background_jobs_list_wait_and_cancel() {
let mut h = Headless::new();
h.handle("doc.new", json!({"width": 600, "height": 400})).unwrap();
h.handle("engine.execute", json!({"command": "layer.new.layer"})).unwrap();
h.handle("engine.execute", json!({"command": "edit.fill", "params": {"color": "#808080"}})).unwrap();
// Default: waits, the result is the command's.
let r = h.handle("engine.execute", json!({"command": "filter.noise.addNoise", "params": {"amount": 20}})).unwrap();
assert!(r["filter"].is_object(), "{r}");
// wait: false returns a job id at once.
let r = h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false})).unwrap();
let job = r["job"].as_u64().unwrap();
let listed = h.handle("jobs.list", json!({})).unwrap();
assert!(listed["jobs"].as_array().unwrap().iter().any(|j| j["id"] == job), "{listed}");
// Wait for it via jobs.list (which applies finished jobs).
let t = std::time::Instant::now();
loop {
let l = h.handle("jobs.list", json!({})).unwrap();
let state = l["jobs"].as_array().unwrap().iter().find(|j| j["id"] == job).map(|j| j["state"].clone());
if state == Some(json!("done")) {
break;
}
assert!(t.elapsed().as_secs() < 60, "{l}");
std::thread::sleep(std::time::Duration::from_millis(5));
}
// Cancel: a second job, cancelled at once, leaves the document unchanged.
let before = h.session.active().unwrap().doc.clone();
let r = h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false})).unwrap();
let job = r["job"].as_u64().unwrap();
h.handle("jobs.cancel", json!({"job": job})).unwrap();
assert!(h.handle("jobs.cancel", json!({"job": "nope"})).is_err());
let l = h.handle("jobs.list", json!({})).unwrap();
assert_eq!(l["jobs"].as_array().unwrap().iter().find(|j| j["id"] == job).unwrap()["state"], "cancelled");
assert!(std::sync::Arc::ptr_eq(&h.session.active().unwrap().doc, &before));
}
#[test]
fn batch_steps_can_start_background_jobs() {
let mut h = Headless::new();
h.handle("doc.new", json!({"width": 600, "height": 400})).unwrap();
let steps = json!([
{"command": "layer.new.layer"},
{"command": "edit.fill", "params": {"color": "#808080"}},
{"command": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false}
]);
let r = h.handle("batch", json!({"steps": steps})).unwrap();
assert_eq!(r["completed"], 3, "{r}");
let job = r["results"][2]["result"]["job"].as_u64().unwrap_or_else(|| panic!("no job id: {r}"));
assert_eq!(r["results"][2]["result"]["pending"], true);
let listed = h.handle("jobs.list", json!({})).unwrap();
assert!(listed["jobs"].as_array().unwrap().iter().any(|j| j["id"] == job), "{listed}");
let t = std::time::Instant::now();
while h.session.jobs().iter().any(|j| j.id.0 == job) {
assert!(t.elapsed().as_secs() < 60);
std::thread::sleep(std::time::Duration::from_millis(5));
h.handle("jobs.list", json!({})).unwrap();
}
}
/// Regression (#503): a finished `wait: false` job was only applied by a later command or
/// `jobs.list`, so save, inspect, render and `session.list` still saw the document without it.
#[test]
fn every_request_sees_a_finished_background_job() {
let dir = std::env::temp_dir().join(format!("pc-rpc-job-sync-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (before, after) = (dir.join("before.png"), dir.join("after.png"));
let mut h = Headless::trusted_local();
h.handle("doc.new", json!({"width": 64, "height": 48})).unwrap();
h.handle("engine.execute", json!({"command": "filter.noise.addNoise", "params": {"amount": 50}})).unwrap();
h.handle("doc.save", json!({"path": before.to_string_lossy()})).unwrap();
let render = |h: &mut Headless| h.handle("doc.render", json!({"maxSide": 0})).unwrap()["base64"].clone();
let unblurred = render(&mut h);
let revision = |h: &mut Headless| h.handle("session.list", json!({})).unwrap()["documents"][0]["revision"].clone();
let start = revision(&mut h);
h.handle("engine.execute", json!({"command": "filter.blur.gaussianBlur", "params": {"radius": 4}, "wait": false})).unwrap();
// Only reading requests from here on: no command and no `jobs.list`.
let t = std::time::Instant::now();
while revision(&mut h) == start {
assert!(t.elapsed().as_secs() < 60, "session.list never saw the finished job");
std::thread::sleep(std::time::Duration::from_millis(5));
}
let history = h.handle("doc.inspect", json!({})).unwrap()["history"].clone();
assert_eq!(history.as_array().unwrap().last().unwrap(), "Gaussian Blur", "{history}");
h.handle("doc.save", json!({"path": after.to_string_lossy()})).unwrap();
let decode = |p: &std::path::Path| photocraft_codecs::decode(&std::fs::read(p).unwrap()).unwrap().to_rgba8();
assert_ne!(decode(&before), decode(&after), "the export holds the blur");
assert_ne!(render(&mut h), unblurred, "the preview holds the blur");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn automation_preview_rejects_oversized_full_size_and_numeric_wraparound() {
let mut h = Headless::new();
@@ -404,6 +510,28 @@ mod tests {
assert!(h.lock().unwrap().session.documents().is_empty());
}
/// Regression (#505): an over-long or non-UTF-8 line ended the stdio session, losing every
/// open document. Now it gets one error reply, the rest of the line is skipped (never
/// dispatched), and the next request is served.
#[test]
fn stdio_skips_a_rejected_line_and_keeps_the_session() {
let h = Mutex::new(Headless::new());
let mut input = b"{\"id\":1,\"method\":\"doc.new\",\"params\":{\"width\":8,\"height\":8}}\n".to_vec();
input.extend(" ".repeat(MAX_REQUEST_BYTES + 1).into_bytes());
input.extend(b"{\"id\":9,\"method\":\"doc.close\"}\n");
input.extend(b"\xff\xfe{\"id\":8,\"method\":\"doc.close\"}\n");
input.extend(b"{\"id\":2,\"method\":\"session.list\"}\n");
let mut out = Vec::new();
serve_lines(&h, input.as_slice(), &mut out).unwrap();
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(replies.len(), 4, "{replies:?}");
assert_eq!(replies[0]["ok"], true);
assert!(replies[1]["error"].as_str().unwrap().contains("request exceeds"), "{}", replies[1]);
assert!(replies[2]["error"].as_str().unwrap().contains("not valid UTF-8"), "{}", replies[2]);
assert_eq!(replies[3]["id"], 2);
assert_eq!(replies[3]["result"]["documents"].as_array().unwrap().len(), 1, "the document stays open");
}
#[test]
fn preview_rejection_precedes_output_file_creation() {
let root = std::env::temp_dir().join(format!("pc-preview-budget-{}", std::process::id()));
@@ -431,6 +559,22 @@ mod tests {
assert_eq!(photocraft_codecs::decode(&png).unwrap().dimensions(), (2049, 1));
}
#[test]
fn methods_lists_the_job_methods_and_every_listed_name_is_served() {
let mut h = Headless::new();
let listed = h.handle("methods", Value::Null).unwrap();
let listed: Vec<&str> = listed.as_array().unwrap().iter().map(|m| m.as_str().unwrap()).collect();
assert_eq!(listed, METHODS);
// The job methods `engine.execute {"wait": false}` depends on are discoverable (#414).
assert!(listed.contains(&"jobs.list") && listed.contains(&"jobs.cancel"));
for m in METHODS {
if let Err(e) = h.handle(m, Value::Null) {
assert!(!e.to_string().contains("unknown method"), "{m} is listed but not served: {e}");
}
}
assert!(h.handle("jobs.nope", Value::Null).unwrap_err().to_string().contains("unknown method"));
}
#[test]
fn batch_response_budget_stops_later_steps_even_when_stop_on_error_is_false() {
let mut h = Headless::new();
+45 -2
View File
@@ -34,19 +34,43 @@ pub enum LineRead {
}
/// Read one line without ever buffering more than [`MAX_REQUEST_BYTES`] plus one byte.
///
/// `TooLong` leaves the bytes read so far in `line` (lossily decoded); the rest of that line is
/// still unread unless `line` ends with a newline (see [`discard_rest_of_line`]). A line within
/// the limit that is not UTF-8 is an `InvalidData` error, and the whole line has been consumed.
pub fn read_bounded_line(reader: &mut impl BufRead, line: &mut String) -> std::io::Result<LineRead> {
line.clear();
let mut limited = std::io::Read::take(reader, (MAX_REQUEST_BYTES + 1) as u64);
let n = limited.read_line(line)?;
let mut bytes = Vec::new();
let n = std::io::Read::take(reader, (MAX_REQUEST_BYTES + 1) as u64).read_until(b'\n', &mut bytes)?;
if n == 0 {
Ok(LineRead::Eof)
} else if n > MAX_REQUEST_BYTES {
// A cut can split a multi-byte character; the length, not the text, decides.
line.push_str(&String::from_utf8_lossy(&bytes));
Ok(LineRead::TooLong)
} else {
*line = String::from_utf8(bytes).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(LineRead::Line)
}
}
/// Skip the rest of the current line (through its newline, or to EOF) without buffering it, so
/// a stream transport can answer an over-long request and keep reading the next one.
pub fn discard_rest_of_line(reader: &mut impl BufRead) -> std::io::Result<()> {
loop {
let buf = reader.fill_buf()?;
if buf.is_empty() {
return Ok(());
}
if let Some(end) = buf.iter().position(|&b| b == b'\n') {
reader.consume(end + 1);
return Ok(());
}
let n = buf.len();
reader.consume(n);
}
}
/// Generate a 256-bit bearer token with the operating system CSPRNG.
pub fn generate_token() -> Result<String, AutomationError> {
let mut bytes = [0u8; TOKEN_BYTES];
@@ -235,6 +259,25 @@ mod tests {
let mut line = String::new();
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::TooLong);
assert_eq!(line.len(), MAX_REQUEST_BYTES + 1);
discard_rest_of_line(&mut reader).unwrap();
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Eof);
}
#[test]
fn bounded_reader_reports_a_cut_multibyte_line_as_too_long_and_bad_utf8_as_invalid_data() {
// The cut at the limit falls inside a two-byte character.
let input = format!("xx{}\nnext\n", "é".repeat(MAX_REQUEST_BYTES / 2));
let mut reader = std::io::Cursor::new(input);
let mut line = String::new();
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::TooLong);
discard_rest_of_line(&mut reader).unwrap();
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "next\n");
// Invalid UTF-8 consumes its whole line, so the next read starts on the next line.
let mut reader = std::io::Cursor::new(b"\xff\xfe\nnext\n".to_vec());
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap_err().kind(), std::io::ErrorKind::InvalidData);
assert_eq!(read_bounded_line(&mut reader, &mut line).unwrap(), LineRead::Line);
assert_eq!(line, "next\n");
}
#[test]
+141 -25
View File
@@ -72,7 +72,9 @@ pub struct NewParams {
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct SaveParams {
/// Forward-slash relative target beneath the configured automation write root.
/// The extension selects the format. Omit to save to the document's own relative path.
/// The extension selects the format. Omit to write back to the document's own file, which
/// works only for a PSD, PSB or .pcraft file kept in its own format; any other save needs
/// `path`, so a flattened or converted copy never replaces the opened file.
#[serde(default)]
pub path: Option<String>,
/// Format override as an extension (pcraft, psd, png, jpg, tif, webp, exr, …).
@@ -81,6 +83,9 @@ pub struct SaveParams {
/// JPEG quality 1..100 for lossy exports.
#[serde(default)]
pub quality: Option<u8>,
/// TIFF: keep the layers (Photoshop layer data). Off by default: a flat TIFF.
#[serde(default, rename = "tiffLayers")]
pub tiff_layers: bool,
#[serde(default)]
pub index: Option<usize>,
}
@@ -112,29 +117,51 @@ pub struct ListParams {
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RunParams {
/// Command id, e.g. `layer.new.layer`, `filter.blur.gaussian`.
/// Command id, e.g. `layer.new.layer`, `filter.blur.gaussianBlur`.
pub id: String,
/// Command parameters as a JSON object (see the `params` doc in `command_list`).
#[serde(default)]
pub params: Option<Value>,
/// Wait for a long command (filters, Content-Aware Fill, Photomerge, …) to finish (default
/// true). With false it runs as a background job and the result is `{"job": id}`: poll it
/// with `jobs_list`, stop it with `jobs_cancel`.
#[serde(default)]
pub wait: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct JobCancelParams {
/// The job id from `command_run` / `jobs_list` (omit to cancel every running job).
#[serde(default)]
pub job: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct BatchParams {
/// Commands to run in order: `[{"id": "layer.new.layer", "params": {"name": "Ink"}}, …]`.
/// Commands to run in order: `[{"id": "layer.new.layer", "params": {"name": "Ink"}}, …]`. A
/// step with `"wait": false` starts a long command as a background job (its result is
/// `{"job": id}`, as in `command_run`); later steps that edit the same document fail until
/// the job ends.
pub steps: Vec<RunParams>,
/// Stop at the first failing step (default true).
#[serde(default)]
pub stop_on_error: Option<bool>,
}
/// Strict: an argument the tool doesn't forward is an error, not silently dropped.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct PointerParams {
/// Events in document coordinates: `[{"kind":"down|move|up","x":..,"y":..,"pressure":..}]`.
pub events: Vec<Value>,
/// Modifier keys, e.g. `{"shift":true}`.
/// Modifier keys, e.g. `{"shift":true}` (also `alt`, `command`, `ctrl`, `space`).
#[serde(default)]
pub modifiers: Option<Value>,
/// Mouse button: `left` (default), or `right` / `secondary`: with the Move tool or
/// `{"command":true}` it opens the canvas layer menu, with a painting tool the Brush Preset
/// picker.
#[serde(default)]
pub button: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
@@ -145,7 +172,10 @@ pub struct MenuParams {
#[derive(Debug, Deserialize, JsonSchema)]
pub struct UiSetParams {
/// Fields accepted by the control method `ui.set` (tool, panels, zoom, center, dark).
/// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, maskTarget,
/// vectorMaskTarget, selectionMode, zoom, center, fit, theme (pro, proMedium, studio,
/// studioLight, classic), brushSection, brushTab, brushesView, brushSize. Other fields are an
/// error.
pub fields: Value,
}
@@ -252,6 +282,7 @@ impl PhotocraftMcp {
// of the document (`Session::edit`), so the session is still
// consistent: keep serving instead of failing every later call.
let mut g = h.lock().unwrap_or_else(PoisonError::into_inner);
g.sync_jobs();
f(&mut g)
})
.await
@@ -281,22 +312,24 @@ impl PhotocraftMcp {
Ok(bytes) => bytes,
Err(error) => return Ok(fail(format!("app screenshot data: {error}"))),
};
if let Err(error) = check_png(bytes.len()) {
return Ok(fail(error));
}
let bytes = match downscale_png(&bytes, max_side.unwrap_or(0)) {
Ok(Some(small)) => small,
Ok(None) => bytes,
Err(error) => return Ok(fail(error)),
};
if let Err(error) = check_png(bytes.len()) {
return Ok(fail(error));
}
Ok(png_result(&bytes, "screenshot of the live app window".into()))
}
}
fn downscale_png(png: &[u8], max_side: u32) -> Result<Option<Vec<u8>>, AutomationError> {
// A compressed reply's byte limit does not bound its decoded pixel allocation.
let opts =
photocraft_codecs::DecodeOptions { limits: photocraft_codecs::Limits { max_width: 8192, max_height: 8192, max_pixels: 16 << 20, max_alloc: 64 << 20 } };
let opts = photocraft_codecs::DecodeOptions {
limits: photocraft_codecs::Limits { max_width: 8192, max_height: 8192, max_pixels: 16 << 20, max_alloc: 64 << 20 },
..Default::default()
};
let img = photocraft_codecs::decode_as_with(photocraft_codecs::Format::Png, png, &opts).map_err(|error| AutomationError::Other(error.to_string()))?;
let (w, h) = img.dimensions();
if max_side == 0 || w.max(h) <= max_side {
@@ -372,11 +405,11 @@ impl PhotocraftMcp {
if let Some(v) = p.name {
params.insert("name".into(), v.into());
}
self.run_command("file.new".into(), Value::Object(params)).await
self.run_command("file.new".into(), Value::Object(params), true).await
}
#[tool(description = "Save the document. `.pcraft` is the lossless native format (incremental); other extensions \
(psd, png, jpg, tif, webp, exr, …) export. Returns warnings about anything the format cannot hold.")]
(psd, png, jpg, tif, webp, exr, …) export. Without `path` only a PSD, PSB or .pcraft document is written back to its own file. Returns warnings about anything the format cannot hold.")]
async fn doc_save(&self, Parameters(p): Parameters<SaveParams>) -> Result<CallToolResult, McpError> {
self.save_impl(p).await
}
@@ -397,7 +430,8 @@ impl PhotocraftMcp {
let Some(b) = self.bridge_client() else {
return Ok(no_backend());
};
to_result(b.call("engine.execute", json!({"command": "document.inspect", "params": {}})).await)
let params = p.index.map(|index| json!({"document": index})).unwrap_or_else(|| json!({}));
to_result(b.call("engine.execute", json!({"command": "document.inspect", "params": params})).await)
}
#[tool(description = "Render the flattened document and return it as a PNG image (bridge mode: window screenshot).")]
@@ -461,9 +495,23 @@ impl PhotocraftMcp {
Ok(ok_json(&Value::Array(items)))
}
#[tool(description = "Run an engine command by id with JSON params (see command_list). Returns the command's JSON result.")]
#[tool(description = "Run an engine command by id with JSON params (see command_list). Returns the command's JSON result. \
Long commands (filters, Content-Aware Fill/Scale, Photomerge, brush import) wait to finish unless `wait` is false: \
then they run as a background job and the result is {job: id} (see jobs_list, jobs_cancel).")]
async fn command_run(&self, Parameters(p): Parameters<RunParams>) -> Result<CallToolResult, McpError> {
self.run_command(p.id, p.params.unwrap_or_else(|| json!({}))).await
self.run_command(p.id, p.params.unwrap_or_else(|| json!({})), p.wait.unwrap_or(true)).await
}
#[tool(description = "List background jobs: running ones with progress (0-1), message and elapsed time, then the last few \
that ended (state done|failed|cancelled with their result or error). Finished jobs are applied first.")]
async fn jobs_list(&self) -> Result<CallToolResult, McpError> {
self.jobs_call("jobs.list", json!({})).await
}
#[tool(description = "Cancel a background job by id (or every running job when `job` is omitted). The document is left \
unchanged.")]
async fn jobs_cancel(&self, Parameters(p): Parameters<JobCancelParams>) -> Result<CallToolResult, McpError> {
self.jobs_call("jobs.cancel", p.job.map_or_else(|| json!({}), |j| json!({"job": j}))).await
}
#[tool(description = "Run several engine commands in one call (fewer round trips). Returns {completed, failed, \
@@ -473,9 +521,11 @@ impl PhotocraftMcp {
return Ok(fail(format!("batch contains {} steps; maximum is {MAX_BATCH_STEPS}", p.steps.len())));
}
let stop = p.stop_on_error.unwrap_or(true);
let steps: Vec<Value> = p.steps.into_iter().map(|s| json!({"command": s.id, "params": s.params.unwrap_or_else(|| json!({}))})).collect();
let steps: Vec<Value> =
p.steps.into_iter().map(|s| json!({"command": s.id, "params": s.params.unwrap_or_else(|| json!({})), "wait": s.wait.unwrap_or(true)})).collect();
let args = json!({"steps": steps, "stopOnError": stop});
if let Some(r) = self.headless_op(move |h| h.batch(&args)).await {
// The reply travels as escaped JSON text, so steps are charged their escaped size.
if let Some(r) = self.headless_op(move |h| h.batch_with_budget(&args, BatchReplyBudget::escaped())).await {
return to_result(r);
}
let Some(b) = self.bridge_client() else {
@@ -483,7 +533,7 @@ impl PhotocraftMcp {
};
let mut results = Vec::new();
let mut failed = 0;
let mut reply_budget = BatchReplyBudget::default();
let mut reply_budget = BatchReplyBudget::escaped();
for s in &steps {
let response = b.call("engine.execute", s.clone()).await;
let was_error = response.is_err();
@@ -509,7 +559,9 @@ impl PhotocraftMcp {
// ----- live-GUI tools (bridge mode) -----
#[tool(description = "Bridge mode: full UI state of the live app (tool, panels, views, dialogs, menu tree).")]
#[tool(
description = "Bridge mode: full UI state of the live app (tool, panels, views, dialogs, windows). For the menu tree, call `control_call` with method `ui.menu.list`."
)]
async fn ui_inspect(&self) -> Result<CallToolResult, McpError> {
match self.bridge_client() {
Some(b) => to_result(b.call("ui.inspect", json!({})).await),
@@ -525,7 +577,9 @@ impl PhotocraftMcp {
}
}
#[tool(description = "Bridge mode: send pointer events (document coordinates) to the active tool.")]
#[tool(
description = "Bridge mode: send pointer events (document coordinates) to the active tool; with a Color Picker open they sample the image into it instead."
)]
async fn ui_pointer(&self, Parameters(p): Parameters<PointerParams>) -> Result<CallToolResult, McpError> {
match self.bridge_client() {
Some(b) => {
@@ -533,6 +587,9 @@ impl PhotocraftMcp {
if let Some(m) = p.modifiers {
params["modifiers"] = m;
}
if let Some(button) = p.button {
params["button"] = button.into();
}
to_result(b.call("ui.pointer", params).await)
}
None => bridge_only("ui_pointer"),
@@ -547,7 +604,9 @@ impl PhotocraftMcp {
}
}
#[tool(description = "Bridge mode: change UI state (tool, panels, zoom, center, dark).")]
#[tool(
description = "Bridge mode: change UI state (tool, panels, dock, zoom, center, fit, theme, brush settings; see `fields`). Unknown fields are an error."
)]
async fn ui_set(&self, Parameters(p): Parameters<UiSetParams>) -> Result<CallToolResult, McpError> {
match self.bridge_client() {
Some(b) => to_result(b.call("ui.set", p.fields).await),
@@ -569,15 +628,27 @@ fn bridge_only_headless(name: &str) -> Result<CallToolResult, McpError> {
}
impl PhotocraftMcp {
async fn run_command(&self, id: String, params: Value) -> Result<CallToolResult, McpError> {
async fn run_command(&self, id: String, params: Value, wait: bool) -> Result<CallToolResult, McpError> {
let (id2, params2) = (id.clone(), params.clone());
if let Some(r) = self.headless_op(move |h| h.command_run(&id2, params2)).await {
if let Some(r) = self.headless_op(move |h| h.command_start(&id2, params2, wait)).await {
return to_result(r);
}
let Some(b) = self.bridge_client() else {
return Ok(no_backend());
};
to_result(b.call("engine.execute", json!({"command": id, "params": params})).await)
to_result(b.call("engine.execute", json!({"command": id, "params": params, "wait": wait})).await)
}
/// `jobs.list` / `jobs.cancel` in either backend.
async fn jobs_call(&self, method: &'static str, params: Value) -> Result<CallToolResult, McpError> {
let p2 = params.clone();
if let Some(r) = self.headless_op(move |h| h.command_start(method, p2, true)).await {
return to_result(r);
}
let Some(b) = self.bridge_client() else {
return Ok(no_backend());
};
to_result(b.call(method, params).await)
}
async fn save_impl(&self, p: SaveParams) -> Result<CallToolResult, McpError> {
@@ -589,7 +660,7 @@ impl PhotocraftMcp {
}
let Some(r) = self
.headless_op(move |h| {
let mut opts = photocraft_io::ExportOptions::default();
let mut opts = photocraft_io::ExportOptions { tiff_layers: p.tiff_layers, ..Default::default() };
if let Some(q) = p.quality {
opts.encode.jpeg_quality = q.clamp(1, 100);
}
@@ -631,6 +702,7 @@ mod tests {
fn bridge_screenshot_decode_limits_apply_even_without_downscaling() {
let image = photocraft_codecs::Image::from_u8(8193, 1, photocraft_codecs::ChannelLayout::Rgba, vec![0; 8193 * 4]).unwrap();
let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
assert!(downscale_png(b"not a png", 32).is_err());
assert!(downscale_png(&png, 0).is_err());
assert!(downscale_png(&png, 1024).is_err());
}
@@ -643,4 +715,48 @@ mod tests {
let r = mcp.headless_op(|h| h.command_run("file.new", json!({"width": 8, "height": 8}))).await.unwrap();
assert!(r.is_ok(), "session still usable after a panic: {r:?}");
}
/// Regression (#504): the batch budget counted plain JSON, but MCP sends the reply as escaped
/// text, so a batch the budget stopped could still exceed the tool-result ceiling and the
/// client lost every step result.
#[tokio::test(flavor = "multi_thread")]
async fn mcp_command_batch_stopped_by_the_budget_keeps_its_step_results() {
let mcp = PhotocraftMcp::headless();
let step = |id: &str, params: Value| RunParams { id: id.into(), params: Some(params), wait: None };
let mut steps: Vec<RunParams> = (0..MAX_BATCH_STEPS - 1).map(|_| step("command.list", json!({}))).collect();
steps.push(step("file.new", json!({"width": 8, "height": 8})));
let result = mcp.command_batch(Parameters(BatchParams { steps, stop_on_error: Some(false) })).await.unwrap();
assert_ne!(result.is_error, Some(true), "{:?}", result.content.first().and_then(|c| c.as_text()).map(|t| &t.text[..200.min(t.text.len())]));
let text = &result.content.first().and_then(|c| c.as_text()).unwrap().text;
let reply: Value = serde_json::from_str(text).unwrap();
let results = reply["results"].as_array().unwrap();
assert!(results.last().unwrap()["error"].as_str().unwrap().contains("batch response budget exceeded"), "{}", results.last().unwrap());
assert_eq!(reply["completed"].as_u64().unwrap() as usize, results.len() - 1);
assert_eq!(reply["failed"], 1);
let docs = mcp.headless_op(|h| Ok(h.session.documents().len())).await.unwrap().unwrap();
assert_eq!(docs, 0, "the step after the budget ran out did not run");
}
/// The MCP server applies finished jobs before every tool call too (#503).
#[tokio::test(flavor = "multi_thread")]
async fn mcp_tools_see_a_finished_background_job() {
let mcp = PhotocraftMcp::headless();
mcp.headless_op(|h| {
h.command_run("file.new", json!({"width": 64, "height": 48}))?;
h.command_run("filter.noise.addNoise", json!({"amount": 50}))?;
h.command_start("filter.blur.gaussianBlur", json!({"radius": 4}), false)
})
.await
.unwrap()
.unwrap();
let t = std::time::Instant::now();
loop {
let inspected = mcp.headless_op(|h| h.inspect(None)).await.unwrap().unwrap();
if inspected["history"].as_array().unwrap().last().unwrap() == "Gaussian Blur" {
break;
}
assert!(t.elapsed().as_secs() < 60, "doc_inspect never saw the finished job: {inspected}");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
}
+254 -16
View File
@@ -1,11 +1,12 @@
//! Capability-based filesystem policy for untrusted automation paths.
use std::io::{Read, Write};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use cap_std::ambient_authority;
use cap_std::fs::{Dir, OpenOptions};
use photocraft_format::atomic::RenameRetry;
use serde_json::Value;
use crate::AutomationError;
@@ -45,12 +46,14 @@ impl AuthorizedWorkspace {
if !metadata.is_file() {
return Err(AutomationError::BadRequest(format!("automation read path is not a regular file: `{path}`")));
}
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).map_err(|e| file_error("read", path, e))?;
Ok(bytes)
// Bounded reads, and a clear error for a file larger than memory (#375).
photocraft_format::read::read_all(&mut file, metadata.len()).map_err(|e| file_error("read", path, e))
}
/// Create or replace one file below the configured write root.
/// Create or replace one file below the configured write root, crash-safely: the bytes go to
/// a temporary file beside the target, which is synced and renamed over it (the same steps
/// as [`photocraft_format::atomic_write`], through the directory capability). On failure
/// the previous file is untouched and the temporary file is removed.
///
/// The parent directory must already exist. `cap-std` performs path
/// resolution and file creation relative to the held directory handle, so
@@ -58,11 +61,29 @@ impl AuthorizedWorkspace {
pub fn write(&self, path: &str, bytes: &[u8]) -> Result<(), AutomationError> {
let relative = relative_path(path)?;
let root = self.write.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: write authority is absent")))?;
let leaf = relative.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let parent = relative.parent().map(Path::to_path_buf).unwrap_or_default();
let tmp = parent.join(photocraft_format::atomic::temp_name(&leaf));
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(true);
let mut file = root.dir.open_with(&relative, &options).map_err(|e| file_error("write", path, e))?;
file.write_all(bytes).map_err(|e| file_error("write", path, e))?;
file.flush().map_err(|e| file_error("write", path, e))
options.write(true).create_new(true);
let written = root.dir.open_with(&tmp, &options).and_then(|mut file| {
file.write_all(bytes)?;
file.sync_all()
});
let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative)));
if let Err(e) = renamed {
let _ = root.dir.remove_file(&tmp);
return Err(file_error("write", path, e));
}
// Flush the directory entry (Unix); best effort, the new bytes are already in place.
#[cfg(unix)]
{
let dir = if parent.as_os_str().is_empty() { PathBuf::from(".") } else { parent };
if let Ok(d) = root.dir.open(&dir) {
let _ = d.sync_all();
}
}
Ok(())
}
}
@@ -132,6 +153,7 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
let safe_file_command = matches!(
id,
"file.new"
| "file.newFromClipboard"
| "file.close"
| "file.closeAll"
| "file.closeOthers"
@@ -158,6 +180,7 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
| "layer.videoLayers.reloadFrame"
| "image.applyDataSet"
)
|| command_uses_ambient_path(id, params)
|| profile_command_may_read_ambient(id, params)
|| preferences_may_grant_ambient_paths(id, params)
|| params_contain_ambient_path(id, params)
@@ -179,11 +202,23 @@ pub fn authorize_desktop_engine_command(id: &str, params: &Value) -> Result<(),
Ok(())
}
/// [`authorize_engine_command`] as the function pointer [`photocraft_engine::Session::authorize`]
/// stores. `actions.play` calls it for every nested step.
pub fn authorize_engine_step(id: &str, params: &Value) -> photocraft_engine::Result<()> {
authorize_engine_command(id, params).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
}
/// [`authorize_desktop_engine_command`] as a [`photocraft_engine::Session::authorize`] hook.
pub fn authorize_desktop_engine_step(id: &str, params: &Value) -> photocraft_engine::Result<()> {
authorize_desktop_engine_command(id, params).map_err(|e| photocraft_engine::EngineError::Other(e.to_string()))
}
fn params_contain_ambient_path(id: &str, params: &Value) -> bool {
let keys: &[&str] = match id {
"image.adjustments.colorLookup" | "layer.newAdjustmentLayer.colorLookup" | "layer.setAdjustment" => &["file"],
"filter.distort.displace" => &["mapPath"],
"layer.quickExportAsPng" | "layer.exportAs" => &["path"],
"image.mode.rgb" | "image.mode.grayscale" | "image.mode.cmyk" | "image.mode.lab" => &["profile"],
"edit.assignProfile" | "edit.convertToProfile" | "edit.profileInfo" | "view.proofSetup" | "view.gamutWarning" => &["profile"],
"edit.colorSettings" => &["workingRgb", "workingCmyk", "workingGray"],
_ => &[],
@@ -210,16 +245,29 @@ fn preferences_may_grant_ambient_paths(id: &str, params: &Value) -> bool {
if id != "prefs.set" {
return false;
}
let direct = params
.get("path")
.and_then(Value::as_str)
.is_some_and(|path| path == "colorSettings" || path.starts_with("colorSettings.") || path == "scriptEvents" || path.starts_with("scriptEvents."));
let batch = params.get("values").and_then(Value::as_object).is_some_and(|values| {
values.keys().any(|path| path == "colorSettings" || path.starts_with("colorSettings.") || path == "scriptEvents" || path.starts_with("scriptEvents."))
});
let direct = params.get("path").and_then(Value::as_str).is_some_and(preference_uses_ambient_filesystem);
let batch = params.get("values").and_then(Value::as_object).is_some_and(|values| values.keys().any(|path| preference_uses_ambient_filesystem(path)));
direct || batch
}
fn preference_uses_ambient_filesystem(path: &str) -> bool {
// The engine skips empty segments (`.scriptEvents.enabled` sets `scriptEvents.enabled`), so
// judge the first non-empty one; no segment at all is the whole-preferences update.
let Some(section) = path.split('.').find(|segment| !segment.is_empty()) else { return true };
matches!(section, "colorSettings" | "scriptEvents" | "historyLog" | "plugIns" | "scratchDisks")
}
fn command_uses_ambient_path(id: &str, params: &Value) -> bool {
match id {
"brush.presets.importAbr" | "gradient.presets.importGrd" | "plugin.install" => {
// `data` wins over `path` in these commands; any `path` without it reads the filesystem.
params.get("data").is_none() && params.get("path").is_some()
}
"plugin.reload" => true,
_ => false,
}
}
fn looks_like_path(value: &str) -> bool {
value.contains('/')
|| value.contains('\\')
@@ -256,6 +304,43 @@ mod tests {
assert_eq!(std::fs::read(inside.join("new-output.txt")).unwrap(), b"created");
}
fn temp_files(dir: &Path) -> Vec<String> {
std::fs::read_dir(dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".tmp")).collect()
}
#[test]
fn write_replaces_atomically_and_leaves_no_temp_file() {
let (inside, _, workspace) = roots("atomic");
std::fs::create_dir_all(inside.join("sub")).unwrap();
workspace.write("sub/out.psd", b"original").unwrap();
workspace.write("sub/out.psd", b"replacement").unwrap();
assert_eq!(std::fs::read(inside.join("sub/out.psd")).unwrap(), b"replacement");
assert!(temp_files(&inside.join("sub")).is_empty());
// Renaming over a directory fails: nothing is left behind and the directory survives.
std::fs::create_dir_all(inside.join("adir")).unwrap();
assert!(workspace.write("adir", b"x").is_err());
assert!(inside.join("adir").is_dir());
assert!(temp_files(&inside).is_empty());
}
#[cfg(unix)]
#[test]
fn write_into_read_only_folder_keeps_the_original() {
use std::os::unix::fs::PermissionsExt;
let (inside, _, workspace) = roots("readonly");
let ro = inside.join("ro");
std::fs::create_dir_all(&ro).unwrap();
std::fs::write(ro.join("doc.psd"), b"original").unwrap();
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o555)).unwrap();
let root_user = std::fs::File::create(ro.join("probe")).is_ok();
if !root_user {
assert!(workspace.write("ro/doc.psd", b"new").is_err());
assert_eq!(std::fs::read(ro.join("doc.psd")).unwrap(), b"original");
assert!(temp_files(&ro).is_empty());
}
std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn rejects_absolute_traversal_mixed_prefix_and_malformed_paths_without_panicking() {
let (_, _, workspace) = roots("reject");
@@ -348,7 +433,11 @@ mod tests {
#[test]
fn filesystem_commands_fail_closed() {
for id in [
"file.open",
"file.openAs",
"file.save",
"file.saveAs",
"file.saveACopy",
"file.export.saveForWebLegacy",
"pattern.import",
"layer.smartObjects.exportContents",
@@ -360,10 +449,159 @@ mod tests {
assert!(authorize_engine_command(id, &serde_json::json!({})).is_err());
}
assert!(authorize_engine_command("file.new", &serde_json::json!({})).is_ok());
// The UI-level examples in docs/control-protocol.md.
for id in ["view.zoomIn", "window.theme.pro", "edit.search"] {
assert!(authorize_desktop_engine_command(id, &serde_json::json!({})).is_ok(), "{id}");
}
assert!(authorize_engine_command("image.mode.cmyk", &serde_json::json!({})).is_ok());
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());
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());
assert!(authorize_engine_step("file.open", &serde_json::json!({})).is_err());
assert!(authorize_engine_step("actions.play", &serde_json::json!({})).is_ok());
assert!(authorize_desktop_engine_step("file.saveACopy", &serde_json::json!({})).is_err());
}
#[test]
fn automation_rejects_ambient_path_commands_and_preferences() {
for (id, params) in [
("brush.presets.importAbr", serde_json::json!({"path": "/outside/set.abr"})),
("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"})),
("plugin.reload", serde_json::json!({})),
("plugin.install", serde_json::json!({"path": " "})),
] {
assert!(authorize_engine_command(id, &params).is_err(), "{id}: {params}");
}
for (id, params) in [
("brush.presets.importAbr", serde_json::json!({"data": "QUJD"})),
("gradient.presets.importGrd", serde_json::json!({"data": "QUJD"})),
("plugin.install", serde_json::json!({"data": "QUJD"})),
] {
assert!(authorize_engine_command(id, &params).is_ok(), "{id}: {params}");
}
for path in [
"",
"colorSettings",
"colorSettings.workingRgb",
"scriptEvents",
"scriptEvents.enabled",
".scriptEvents",
".scriptEvents.enabled",
"historyLog.filePath",
".historyLog.filePath",
"plugIns.additionalPluginsFolder",
"scratchDisks.disks",
".",
"..historyLog.filePath",
] {
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": path, "value": {}})).is_err(), "{path}");
}
assert!(
authorize_engine_command("prefs.set", &serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})).is_err()
);
assert!(authorize_engine_command("prefs.set", &serde_json::json!({"path": "interface.language", "value": "fr"})).is_ok());
}
#[test]
fn registry_filesystem_path_params_are_classified() {
let probe = serde_json::json!({
"path": "/outside/photocraft-probe",
"file": "/outside/photocraft-probe.icc",
"mapPath": "/outside/photocraft-probe.png",
"profile": "/outside/photocraft-probe.icc",
"workingRgb": "/outside/photocraft-probe.icc",
"workingCmyk": "/outside/photocraft-probe.icc",
"workingGray": "/outside/photocraft-probe.icc",
"input": "/outside/photocraft-input",
"output": "/outside/photocraft-output",
"paths": ["/outside/photocraft-probe.psd"],
});
let preference_probe = serde_json::json!({"path": ".scriptEvents", "value": {}});
let unclassified: Vec<_> = photocraft_engine::command_specs()
.iter()
.filter(|spec| documents_filesystem_path_params(spec.id, spec.params))
.filter(|spec| {
let params = if spec.id == "prefs.set" { &preference_probe } else { &probe };
authorize_engine_command(spec.id, params).is_ok()
})
.map(|spec| spec.id)
.collect();
assert!(unclassified.is_empty(), "filesystem path parameters in the command registry need an automation policy: {unclassified:?}");
}
fn documents_filesystem_path_params(id: &str, params: &str) -> bool {
if matches!(id, "prefs.get" | "prefs.reset") {
return false;
}
// These registry descriptions refer to document vector paths, not host files.
if matches!(
id,
"filter.blurGallery.pathBlur"
| "filter.render.flame"
| "shape.create"
| "shape.edit"
| "shape.info"
| "shape.presets.list"
| "shape.presets.new"
| "path.list"
| "path.info"
| "path.set"
| "path.transform"
| "path.clippingPath.set"
| "path.rename"
| "select.toWorkPath"
| "layer.vectorMask.add"
| "layer.vectorMask.edit"
| "layer.vectorMask.info"
| "paint.symmetryFromPath"
| "edit.defineCustomShape"
| "layer.combineShapes.unite"
| "layer.combineShapes.subtractFrontShape"
| "layer.combineShapes.intersectShapeAreas"
| "layer.combineShapes.excludeOverlappingShapes"
| "layer.combineShapes.mergeShapeComponents"
) {
return false;
}
params.to_ascii_lowercase().contains("path")
}
#[test]
fn whole_preferences_update_cannot_enable_automation_script_events() {
let mut headless = crate::headless::Headless::new();
let result = headless.command_run(
"prefs.set",
serde_json::json!({
"path": "",
"value": {
"scriptEvents": {
"enabled": true,
"bindings": [{
"event": "newDocument",
"steps": [["file.saveACopy", {"path": "/outside/canary.psd"}]]
}]
}
}
}),
);
assert!(result.is_err());
for (id, params) in [
("brush.presets.importAbr", serde_json::json!({"path": "/outside/set.abr"})),
("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"})),
("prefs.set", serde_json::json!({"path": "historyLog.filePath", "value": "/outside/log"})),
("prefs.set", serde_json::json!({"values": {"interface.language": "fr", "historyLog.filePath": "/outside/log"}})),
] {
assert!(headless.command_run(id, params).is_err(), "{id}");
}
headless.command_run("file.new", serde_json::json!({"width": 5, "height": 5})).unwrap();
assert!(headless.session.file_menu.event_log.is_empty());
}
}
+116
View File
@@ -214,3 +214,119 @@ async fn agent_completes_ten_scripted_tasks() {
c.cancel().await.unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
/// Click just after "Big", insert a word, then bold through the end of the line. Geometry comes
/// from `type.hitTest` / `type.caret` / `type.navigate`; the edit is the existing `type.edit`.
#[tokio::test(flavor = "multi_thread")]
async fn agent_edits_text_by_position() {
let dir = tmp("type-caret");
let c = connect(&dir).await;
tool(&c, "doc_new", json!({"width": 480, "height": 200, "background": "white"})).await;
let listed = tool(&c, "command_list", json!({"filter": "type."})).await;
let ids: Vec<&str> = listed.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
for id in ["type.hitTest", "type.caret", "type.navigate"] {
assert!(ids.contains(&id), "command_list missing {id}: {ids:?}");
}
run(&c, "type.create", json!({"x": 40, "y": 90, "text": "Big Sale", "size": 48})).await;
let before = inspect(&c).await["history"].as_array().unwrap().len();
// The caret after "Big" and the caret after the following space. A quarter of the way is
// the left half of that space, which hit-tests as the caret after "Big".
let after_big = run(&c, "type.caret", json!({"index": 3})).await;
let after_space = run(&c, "type.caret", json!({"index": 4})).await;
let seg = after_big["segment"].as_array().unwrap();
let (x3, y0, y1) = (seg[0][0].as_f64().unwrap(), seg[0][1].as_f64().unwrap(), seg[1][1].as_f64().unwrap());
let x4 = after_space["segment"][0][0].as_f64().unwrap();
assert!(x4 > x3 + 0.5, "space should have width, caret x {x3} -> {x4}");
let hit = run(&c, "type.hitTest", json!({"x": x3 + 0.25 * (x4 - x3), "y": (y0 + y1) / 2.0})).await;
assert_eq!(hit["index"].as_u64(), Some(3), "{hit}");
assert_eq!(hit["inside"], true, "{hit}");
let quiet = inspect(&c).await["history"].as_array().unwrap().len();
assert_eq!(quiet, before, "caret queries are not history steps");
run(&c, "type.edit", json!({"replace": {"start": hit["index"], "end": hit["index"], "text": " Summer"}})).await;
let info = run(&c, "type.info", json!({})).await;
assert_eq!(info["text"], "Big Summer Sale", "{info}");
let end = run(&c, "type.navigate", json!({"index": 0, "move": "lineEnd"})).await;
let n = info["text"].as_str().unwrap().chars().count() as u64;
assert_eq!(end["index"].as_u64(), Some(n), "{end}");
// "Summer" starts one character after the insertion caret (the space in " Summer").
let summer = hit["index"].as_u64().unwrap() + 1;
run(&c, "type.setStyle", json!({"range": [summer, end["index"]], "fauxBold": true})).await;
let info = run(&c, "type.info", json!({})).await;
assert_eq!(info["text"], "Big Summer Sale");
let bold = info["runs"].as_array().unwrap().iter().find(|r| r["style"]["faux_bold"] == true);
let bold = bold.unwrap_or_else(|| panic!("no bold run in {info}"));
assert_eq!(bold["start"].as_u64(), Some(summer), "{bold}");
assert_eq!(bold["end"].as_u64(), end["index"].as_u64(), "{bold}");
tool(&c, "doc_close", json!({})).await;
c.cancel().await.unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
async fn connect_headless(headless: photocraft_automation::Headless) -> Conn {
let (s, c) = tokio::io::duplex(1 << 20);
tokio::spawn(async move {
let backend = photocraft_automation::Backend::Headless(std::sync::Arc::new(std::sync::Mutex::new(headless)));
if let Ok(running) = PhotocraftMcp::with_backend(backend).serve(s).await {
let _ = running.waiting().await;
}
});
Client.serve(c).await.expect("client init")
}
#[tokio::test(flavor = "multi_thread")]
async fn agent_records_and_replays_an_action() {
let dir = tmp("actions");
let c = connect(&dir).await;
let listed = tool(&c, "command_list", json!({"filter": "actions."})).await;
let ids: Vec<&str> = listed.as_array().unwrap().iter().filter_map(|item| item.get("id").and_then(Value::as_str)).collect();
for id in ["actions.list", "actions.get", "actions.record", "actions.stop", "actions.play", "actions.delete"] {
assert!(ids.contains(&id), "{id} missing from {ids:?}");
}
run(&c, "file.new", json!({"width": 32, "height": 32, "background": "white"})).await;
run(&c, "actions.record", json!({"name": "Red"})).await;
run(&c, "layer.new.layer", json!({})).await;
run(&c, "select.rect", json!({"x": 0, "y": 0, "width": 8, "height": 8})).await;
run(&c, "edit.fill", json!({"color": "#ff0000"})).await;
let stopped = run(&c, "actions.stop", json!({})).await;
assert_eq!(stopped["steps"], 3, "{stopped}");
let got = run(&c, "actions.get", json!({"action": "Red"})).await;
assert_eq!(got["steps"][0][0], "layer.new.layer");
assert_eq!(got["steps"][1][0], "select.rect");
assert_eq!(got["steps"][2][0], "edit.fill");
run(&c, "file.new", json!({"width": 32, "height": 32, "background": "white"})).await;
let played = run(&c, "actions.play", json!({"action": "Red"})).await;
assert_eq!(played["ran"], 3, "{played}");
assert!(played.get("failed").is_none(), "{played}");
let px = pixel(&c, 2, 2).await;
assert!(close(&px, &[1.0, 0.0, 0.0, 1.0], 1e-4), "{px:?}");
c.cancel().await.unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn agent_play_refuses_a_recorded_file_open() {
let dir = tmp("actions-auth");
let workspace = AuthorizedWorkspace::new(Some(&dir), Some(&dir)).expect("test workspace");
let mut headless = photocraft_automation::Headless::with_workspace(workspace);
headless.session.actions.list.push(photocraft_engine::actions_cmds::Action {
name: "Open".into(),
steps: vec![("file.open".into(), json!({"path": "/etc/passwd"})), ("layer.new.layer".into(), json!({}))],
});
let c = connect_headless(headless).await;
let played = run(&c, "actions.play", json!({"action": "Open"})).await;
assert_eq!(played["ran"], 0, "{played}");
assert_eq!(played["failed"]["step"], 0, "{played}");
assert_eq!(played["failed"]["id"], "file.open", "{played}");
assert!(played["failed"]["error"].as_str().unwrap_or("").contains("disabled"), "{played}");
let session = tool(&c, "session_list", json!({})).await;
assert!(session["documents"].as_array().is_some_and(|d| d.is_empty()), "{session}");
c.cancel().await.unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
+255 -3
View File
@@ -169,6 +169,39 @@ async fn command_list_filters() {
client.cancel().await.unwrap();
}
/// The backticked examples in every `id` property description of a schema.
fn id_examples(schema: &Value, out: &mut Vec<String>) {
match schema {
Value::Object(m) => {
if let Some(d) = m.get("properties").and_then(|p| p["id"]["description"].as_str()) {
out.extend(d.split('`').skip(1).step_by(2).map(str::to_owned));
}
m.values().for_each(|v| id_examples(v, out));
}
Value::Array(a) => a.iter().for_each(|v| id_examples(v, out)),
_ => {}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn command_ids_in_tool_schemas_exist() {
// Agents copy these examples verbatim (#378).
let client = connect(PhotocraftMcp::headless()).await;
let all = json_of(&call(&client, "command_list", json!({})).await);
let ids: Vec<&str> = all.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
let tools = client.list_all_tools().await.unwrap();
for name in ["command_run", "command_batch"] {
let tool = tools.iter().find(|t| t.name == name).unwrap();
let mut examples = Vec::new();
id_examples(&Value::Object((*tool.input_schema).clone()), &mut examples);
assert!(!examples.is_empty(), "{name}: no example ids");
for id in examples {
assert!(ids.contains(&id.as_str()), "{name}: `{id}` is not a registered command");
}
}
client.cancel().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn errors_are_tool_errors_not_crashes() {
let client = connect(PhotocraftMcp::headless()).await;
@@ -254,6 +287,86 @@ async fn open_png_and_inspect() {
cleanup(&dir);
}
/// #518, #523: `doc_open` returns why an opened image is incomplete.
#[tokio::test(flavor = "multi_thread")]
async fn open_reports_decode_warnings() {
let dir = tmp("open-warnings");
let jpeg = write_image(&dir, "whole.jpg", photocraft_codecs::Format::Jpeg);
std::fs::write(dir.join("cut.jpg"), &jpeg[..jpeg.len() - 4]).unwrap();
// Two 1x1 frames.
let mut gif = b"GIF89a\x01\0\x01\0\x80\0\0\0\0\0\xFF\xFF\xFF".to_vec();
for _ in 0..2 {
gif.extend_from_slice(b"\x2C\0\0\0\0\x01\0\x01\0\0\x02\x02\x44\x01\0");
}
gif.push(0x3B);
std::fs::write(dir.join("anim.gif"), gif).unwrap();
let client = connect(headless_in(&dir)).await;
let warnings = async |path: &str| json_of(&call(&client, "doc_open", json!({"path": path})).await)["warnings"].clone();
assert_eq!(warnings("whole.jpg").await, json!([]));
assert_eq!(warnings("cut.jpg").await, json!(["JPEG data ends early (the file is truncated or damaged); part of the image is missing"]));
assert_eq!(warnings("anim.gif").await, json!(["only the first of 2 frames was imported"]));
client.cancel().await.unwrap();
cleanup(&dir);
}
fn write_image(dir: &std::path::Path, name: &str, format: photocraft_codecs::Format) -> Vec<u8> {
let img = photocraft_codecs::Image::from_u8(16, 8, photocraft_codecs::ChannelLayout::Rgb, (0..384).map(|i| (i * 7 % 251) as u8).collect()).unwrap();
let bytes = photocraft_codecs::encode(&img, format, &Default::default()).unwrap();
std::fs::write(dir.join(name), &bytes).unwrap();
bytes
}
/// A save without `path` writes back only to a layered file in its own format (#416).
#[tokio::test(flavor = "multi_thread")]
async fn save_without_path_never_flattens_over_the_opened_file() {
let dir = tmp("save-in-place");
let png = write_image(&dir, "seed.png", photocraft_codecs::Format::Png);
let jpg = write_image(&dir, "seed.jpg", photocraft_codecs::Format::Jpeg);
let client = connect(headless_in(&dir)).await;
let refused = |r: &CallToolResult| r.is_error == Some(true) && text(r).contains("pass `path`");
// A flat file, edited or not, is left unchanged.
json_of(&call(&client, "doc_open", json!({"path": "seed.png"})).await);
json_of(&call(&client, "command_run", json!({"id": "layer.newAdjustmentLayer.curves", "params": {"points": [[0, 0], [128, 170], [255, 255]]}})).await);
let r = call(&client, "doc_save", json!({})).await;
assert!(refused(&r), "{}", text(&r));
assert_eq!(std::fs::read(dir.join("seed.png")).unwrap(), png);
json_of(&call(&client, "doc_open", json!({"path": "seed.jpg"})).await);
let r = call(&client, "doc_save", json!({})).await;
assert!(refused(&r), "{}", text(&r));
assert_eq!(std::fs::read(dir.join("seed.jpg")).unwrap(), jpg);
// An explicit path, even the opened file's own, still writes and reports what was lost.
let r = json_of(&call(&client, "doc_save", json!({"path": "seed.jpg"})).await);
assert!(r["warnings"].to_string().contains("lossy"), "{r}");
// A layered file saves in place in its own format, but not converted over itself.
json_of(&call(&client, "doc_select", json!({"index": 0})).await);
json_of(&call(&client, "doc_save", json!({"path": "layered.psd"})).await);
json_of(&call(&client, "doc_open", json!({"path": "layered.psd"})).await);
let psd = std::fs::read(dir.join("layered.psd")).unwrap();
let r = call(&client, "doc_save", json!({"format": "png"})).await;
assert!(refused(&r), "{}", text(&r));
assert_eq!(std::fs::read(dir.join("layered.psd")).unwrap(), psd);
assert_eq!(json_of(&call(&client, "doc_save", json!({})).await)["path"], "layered.psd");
assert!(photocraft_io::is_psd(&std::fs::read(dir.join("layered.psd")).unwrap()));
// Once saved as .pcraft, a flat-born document saves in place there.
json_of(&call(&client, "doc_select", json!({"index": 0})).await);
json_of(&call(&client, "doc_save", json!({"path": "work.pcraft"})).await);
assert_eq!(json_of(&call(&client, "doc_save", json!({})).await)["path"], "work.pcraft");
assert_eq!(std::fs::read(dir.join("seed.png")).unwrap(), png);
// A template opens untitled, without its path.
std::fs::write(dir.join("card.psdt"), &psd).unwrap();
assert_eq!(json_of(&call(&client, "doc_open", json!({"path": "card.psdt"})).await)["name"], "Untitled-1");
let r = call(&client, "doc_save", json!({})).await;
assert!(refused(&r), "{}", text(&r));
assert_eq!(std::fs::read(dir.join("card.psdt")).unwrap(), psd);
client.cancel().await.unwrap();
cleanup(&dir);
}
#[tokio::test(flavor = "multi_thread")]
async fn filesystem_policy_rejects_absolute_and_escaping_paths_before_effects() {
let base = tmp("filesystem-policy");
@@ -293,6 +406,10 @@ async fn filesystem_policy_rejects_absolute_and_escaping_paths_before_effects()
// ---------------------------------------------------------------------------
async fn fake_app() -> (String, tokio::task::JoinHandle<Vec<Value>>) {
fake_app_with_screenshot(None).await
}
async fn fake_app_with_screenshot(screenshot_png: Option<Vec<u8>>) -> (String, tokio::task::JoinHandle<Vec<Value>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
let h = tokio::spawn(async move {
@@ -317,14 +434,17 @@ async fn fake_app() -> (String, tokio::task::JoinHandle<Vec<Value>>) {
json!({"id": id, "ok": true, "result": {"tool": "brush", "panels": ["layers"]}})
}
"engine.execute" => {
json!({"id": id, "ok": true, "result": {"ran": req["params"]["command"]}})
json!({"id": id, "ok": true, "result": {"ran": req["params"]["command"], "params": req["params"]["params"], "wait": req["params"]["wait"]}})
}
"engine.commands" => {
json!({"id": id, "ok": true, "result": [{"id": "file.new", "label": "New…", "enabled": true}]})
}
"ui.pointer" => json!({"id": id, "ok": true, "result": req["params"]}),
"ui.screenshot" => {
let img = photocraft_codecs::Image::from_u8(40, 20, photocraft_codecs::ChannelLayout::Rgba, vec![9; 3200]).unwrap();
let bytes = photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap();
let bytes = screenshot_png.clone().unwrap_or_else(|| {
let img = photocraft_codecs::Image::from_u8(40, 20, photocraft_codecs::ChannelLayout::Rgba, vec![9; 3200]).unwrap();
photocraft_codecs::encode(&img, photocraft_codecs::Format::Png, &Default::default()).unwrap()
});
let png = base64::engine::general_purpose::STANDARD.encode(bytes);
json!({"id": id, "ok": true, "result": {"mimeType": "image/png", "base64": png}})
}
@@ -349,6 +469,9 @@ async fn bridge_forwards_to_control_protocol() {
assert_eq!(ui["tool"], "brush");
let r = json_of(&call(&client, "command_run", json!({"id": "layer.new.layer", "params": {"name": "X"}})).await);
assert_eq!(r["ran"], "layer.new.layer");
let inspected = json_of(&call(&client, "doc_inspect", json!({"index": 1})).await);
assert_eq!(inspected["ran"], "document.inspect");
assert_eq!(inspected["params"], json!({"document": 1}));
let l = json_of(&call(&client, "command_list", json!({})).await);
assert_eq!(l[0]["id"], "file.new");
let shot = call(&client, "ui_screenshot", json!({"max_side": 20})).await;
@@ -365,6 +488,44 @@ async fn bridge_forwards_to_control_protocol() {
app.abort();
}
#[tokio::test(flavor = "multi_thread")]
async fn bridge_previews_downscale_before_enforcing_the_png_budget() {
use photocraft_automation::budgets::MAX_PNG_BYTES;
let (width, height) = (1400, 1000);
let mut pixels = vec![0; width * height * 4];
let mut state = 0x6d2b_79f5_u32;
for pixel in &mut pixels {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
*pixel = state as u8;
}
let image = photocraft_codecs::Image::from_u8(width as u32, height as u32, photocraft_codecs::ChannelLayout::Rgba, pixels).unwrap();
let source_png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap();
assert!(source_png.len() > MAX_PNG_BYTES, "fixture must exceed the PNG budget");
let (addr, app) = fake_app_with_screenshot(Some(source_png)).await;
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
for (tool, args) in [("ui_screenshot", json!({"max_side": 32})), ("doc_render_preview", json!({"max_side": 32}))] {
let reply = call(&client, tool, args).await;
assert_ne!(reply.is_error, Some(true), "{tool}: {}", text(&reply));
let img = reply.content.iter().find_map(|content| content.as_image()).expect("preview image");
let png = base64::engine::general_purpose::STANDARD.decode(&img.data).unwrap();
assert!(png.len() <= MAX_PNG_BYTES, "{tool} returned {} bytes", png.len());
let decoded = photocraft_codecs::decode(&png).unwrap();
assert_eq!(decoded.dimensions(), (32, 22), "{tool}");
}
let full_size = call(&client, "ui_screenshot", json!({})).await;
assert_eq!(full_size.is_error, Some(true));
assert!(text(&full_size).contains("automation PNG exceeds"), "{}", text(&full_size));
client.cancel().await.unwrap();
app.abort();
}
#[test]
fn bridge_rejects_non_loopback() {
assert!(PhotocraftMcp::bridge("10.0.0.5:7878", CONTROL_TOKEN).is_err());
@@ -418,6 +579,97 @@ async fn command_batch_runs_steps_in_order() {
client.cancel().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn command_batch_step_without_waiting_starts_a_background_job() {
let client = connect(PhotocraftMcp::headless()).await;
json_of(&call(&client, "doc_new", json!({"width": 600, "height": 400})).await);
let blur = json!({"id": "filter.blur.gaussianBlur", "params": {"radius": 40}, "wait": false});
let r = json_of(
&call(&client, "command_batch", json!({"steps": [{"id": "layer.new.layer"}, {"id": "edit.fill", "params": {"color": "#808080"}}, blur]})).await,
);
assert_eq!(r["completed"], 3, "{r}");
let started = &r["results"][2]["result"];
assert_eq!(started["pending"], true, "the step returned without waiting: {r}");
let job = started["job"].as_u64().expect("a job id");
// The job is listed and finishes like one started by command_run.
let t = std::time::Instant::now();
loop {
let l = json_of(&call(&client, "jobs_list", json!({})).await);
let state = l["jobs"].as_array().unwrap().iter().find(|j| j["id"] == job).map(|j| j["state"].clone());
assert!(state.is_some(), "job {job} not listed: {l}");
if state == Some(json!("done")) {
break;
}
assert!(t.elapsed().as_secs() < 60, "{l}");
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
// Without `wait` a step still returns the command's own result.
let r = json_of(&call(&client, "command_batch", json!({"steps": [{"id": "filter.blur.gaussianBlur", "params": {"radius": 2}}]})).await);
assert!(r["results"][0]["result"]["filter"].is_object(), "{r}");
client.cancel().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn bridge_command_batch_forwards_each_steps_wait() {
let (addr, app) = fake_app().await;
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
let r = json_of(&call(&client, "command_batch", json!({"steps": [{"id": "filter.blur.gaussianBlur", "wait": false}, {"id": "layer.new.layer"}]})).await);
assert_eq!(r["completed"], 2, "{r}");
assert_eq!(r["results"][0]["result"]["wait"], false, "{r}");
assert_eq!(r["results"][1]["result"]["wait"], true, "{r}");
client.cancel().await.unwrap();
app.abort();
}
/// #514: `ui_pointer` forwards `button` (a right-click opens the layer menu or the Brush Preset
/// picker) and rejects arguments it doesn't forward instead of dropping them.
#[tokio::test(flavor = "multi_thread")]
async fn bridge_ui_pointer_forwards_the_button_and_rejects_unknown_arguments() {
let (addr, app) = fake_app().await;
let client = connect(PhotocraftMcp::bridge(&addr, CONTROL_TOKEN).unwrap()).await;
let events = json!([{"kind": "down", "x": 5, "y": 6}, {"kind": "up", "x": 5, "y": 6}]);
let sent = json_of(&call(&client, "ui_pointer", json!({"events": events, "button": "right", "modifiers": {"command": true}})).await);
assert_eq!(sent, json!({"events": events, "button": "right", "modifiers": {"command": true}}));
let sent = json_of(&call(&client, "ui_pointer", json!({"events": events})).await);
assert_eq!(sent, json!({"events": events}), "unset fields are not sent");
for bad in [json!({"events": events, "buton": "right"}), json!({"events": events, "space": true})] {
let Value::Object(args) = bad.clone() else { unreachable!() };
let r = client.call_tool(CallToolRequestParams::new("ui_pointer").with_arguments(args)).await;
assert!(!r.as_ref().is_ok_and(|r| r.is_error != Some(true)), "{bad} accepted: {r:?}");
}
let tools = client.list_all_tools().await.unwrap();
let schema = &tools.iter().find(|t| t.name == "ui_pointer").unwrap().input_schema;
assert_eq!(schema.get("additionalProperties"), Some(&json!(false)), "{schema:?}");
assert!(schema.get("properties").and_then(|p| p.get("button")).is_some(), "{schema:?}");
client.cancel().await.unwrap();
app.abort();
}
/// #368: agents can open the clipboard as a document of its own.
#[tokio::test(flavor = "multi_thread")]
async fn new_from_clipboard_opens_the_copy_as_a_document() {
let client = connect(PhotocraftMcp::headless()).await;
json_of(&call(&client, "doc_new", json!({"width": 40, "height": 30})).await);
let r = json_of(
&call(
&client,
"command_batch",
json!({"steps": [
{"id": "select.rect", "params": {"x": 5, "y": 5, "width": 12, "height": 7}},
{"id": "edit.copy"},
{"id": "file.newFromClipboard"}
]}),
)
.await,
);
assert_eq!(r["completed"], 3, "{r}");
let doc = json_of(&call(&client, "doc_inspect", json!({})).await);
assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(12), Some(7)), "{doc}");
let sess = json_of(&call(&client, "session_list", json!({})).await);
assert_eq!(sess["documents"].as_array().unwrap().len(), 2);
client.cancel().await.unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn command_batch_rejects_too_many_steps() {
let client = connect(PhotocraftMcp::headless()).await;
+3
View File
@@ -22,6 +22,9 @@ t.eval(&[1.0, 0.0, 0.0], &mut out); // exact single colou
* `Lut3d::from_transform(&t, 33).to_rgba16f_bytes()`: display transform as a 3D texture.
* `transform::cached(src, dst, opts)`: process-wide transform cache.
* `Profile::to_bytes()`: original bytes of parsed profiles (byte-exact), or an ICC v4.3 encoding.
* `Profile::same_colors(&other)`: the same colour space whatever the encoding (every sample of
a device grid within ΔE 0.5 through both), e.g. Photoshop's sRGB IEC61966-2.1 and the built-in
sRGB. `content_hash()` compares bytes and suits cache keys only.
### Evaluation
+39 -8
View File
@@ -34,6 +34,15 @@ fn err<T>(msg: impl Into<String>) -> Result<T, LutError> {
Err(LutError(msg.into()))
}
fn checked_data_len(size: usize) -> Result<(usize, usize), LutError> {
if !(2..=MAX_SIZE).contains(&size) {
return err(format!("unsupported LUT size {size}"));
}
let entries = size.checked_pow(3).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
let data_len = entries.checked_mul(3).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
Ok((entries, data_len))
}
impl LutFile {
/// The identity table of edge `size`.
pub fn identity(size: usize) -> Self {
@@ -56,11 +65,9 @@ impl LutFile {
}
fn check(self) -> Result<Self, LutError> {
if self.size < 2 || self.size > MAX_SIZE {
return err(format!("unsupported LUT size {}", self.size));
}
if self.data.len() != self.size.pow(3) * 3 {
return err(format!("expected {} entries, found {}", self.size.pow(3), self.data.len() / 3));
let (entries, data_len) = checked_data_len(self.size)?;
if self.data.len() != data_len {
return err(format!("expected {entries} entries, found {}", self.data.len() / 3));
}
if self.data.iter().any(|v| !v.is_finite()) {
return err("non-finite LUT value");
@@ -128,7 +135,11 @@ pub fn parse_cube(text: &str) -> Result<LutFile, LutError> {
if size3 > 0 {
return LutFile { title, size: size3, data: rows }.check();
}
if size1 >= 2 && rows.len() == size1 * 3 {
if size1 >= 2 {
let expected_rows = size1.checked_mul(3).ok_or_else(|| LutError(format!("unsupported 1D LUT size {size1}")))?;
if rows.len() != expected_rows {
return err("no LUT_3D_SIZE in .cube file");
}
let curve = |ch: usize, v: f32| {
let x = v.clamp(0.0, 1.0) * (size1 - 1) as f32;
let i = (x.floor() as usize).min(size1 - 2);
@@ -184,12 +195,13 @@ pub fn parse_look(text: &str) -> Result<LutFile, LutError> {
Some(text[start..end].trim().trim_matches('"').trim().to_string())
};
let size: usize = tag("size").and_then(|s| s.parse().ok()).ok_or_else(|| LutError("no <size> in .look".into()))?;
let (entries, _) = checked_data_len(size)?;
let hex: Vec<u8> = tag("data").ok_or_else(|| LutError("no <data> in .look".into()))?.bytes().filter(u8::is_ascii_hexdigit).collect();
let nib = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8;
let bytes: Vec<u8> = hex.as_chunks::<2>().0.iter().map(|p| nib(p[0]) << 4 | nib(p[1])).collect();
let floats: Vec<f32> = bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).collect();
let n3 = size.pow(3);
let data = if floats.len() == n3 * 4 { floats.as_chunks::<4>().0.iter().flat_map(|c| [c[0], c[1], c[2]]).collect() } else { floats };
let rgba_entries = entries.checked_mul(4).ok_or_else(|| LutError(format!("unsupported LUT size {size}")))?;
let data = if floats.len() == rgba_entries { floats.as_chunks::<4>().0.iter().flat_map(|c| [c[0], c[1], c[2]]).collect() } else { floats };
LutFile { title: tag("title").unwrap_or_default(), size, data }.check()
}
@@ -292,6 +304,19 @@ mod tests {
assert_eq!(l.size, 33);
let last = &l.data[l.data.len() - 3..];
assert!((last[0] - 0.5).abs() < 1e-6 && (last[1] - 1.0).abs() < 1e-6);
let mut larger = String::from("LUT_1D_SIZE 256\n");
for i in 0..256 {
let value = i as f32 / 255.0;
larger.push_str(&format!("{value} {value} {value}\n"));
}
assert_eq!(parse_cube(&larger).unwrap().size, 33);
}
#[test]
fn cube_1d_size_multiplication_overflow_is_rejected() {
let text = format!("LUT_1D_SIZE {}\n", usize::MAX);
assert!(parse_cube(&text).is_err());
}
#[test]
@@ -317,6 +342,12 @@ mod tests {
assert_eq!(parse("a.look", xml.as_bytes()).unwrap().data, id.data);
}
#[test]
fn look_3d_size_exponent_overflow_is_rejected() {
let text = format!("<look><size>{}</size><data>00</data></look>", usize::MAX);
assert!(parse_look(&text).is_err());
}
#[test]
fn malformed_rejected() {
assert!(parse_cube("LUT_3D_SIZE 2\n0 0 0\n").is_err());
+42
View File
@@ -147,6 +147,11 @@ pub struct Lut {
pub stages: Vec<Stage>,
}
/// Largest colour difference (CIE ΔE*ab) for which [`Profile::same_colors`] treats two profiles
/// as one colour space: invisible, yet 20× what re-encoding a profile costs (sRGB as v2 tables
/// against v4 parametric curves differs by 0.025). Moving sRGB's red primary 2% costs 1.0.
pub const SAME_COLORS_MAX_DELTA_E: f64 = 0.5;
/// A parsed ICC profile.
#[derive(Clone, Debug, PartialEq)]
pub struct Profile {
@@ -216,6 +221,43 @@ impl Profile {
})
}
/// Whether `self` and `other` describe the same colours: same colour space, and every sample
/// of a device-value grid has the same colour through both (relative colorimetric, within
/// [`SAME_COLORS_MAX_DELTA_E`]). Different encodings of one space are the same colours: the
/// sRGB IEC61966-2.1 Photoshop embeds (v2, 1024-entry tables) and the built-in sRGB (v4,
/// parametric curves) differ in every byte. Colours are compared rather than round-tripped
/// because CMYK → PCS → CMYK is not an identity even through a single profile.
pub fn same_colors(&self, other: &Profile) -> bool {
if self.content_hash() == other.content_hash() {
return true;
}
let n = self.channels();
if self.color_space != other.color_space || n == 0 || n > 4 {
return false;
}
let lab = crate::builtin::Builtin::LabD50.profile();
let to_lab = |p: &Profile| crate::transform::Transform::new(p, lab, Intent::RelativeColorimetric, false);
let (Ok(a), Ok(b)) = (to_lab(self), to_lab(other)) else {
return false;
};
// 17 steps per channel catch a TRC that differs only in its toe; 9 keep CMYK at 6561 samples.
let steps: usize = if n <= 3 { 17 } else { 9 };
// Lab D50 is the PCS, normalised as in ICC v4.
let decode = |o: [f32; 3]| [f64::from(o[0]) * 100.0, f64::from(o[1]) * 255.0 - 128.0, f64::from(o[2]) * 255.0 - 128.0];
let mut input = [0f32; 4];
(0..steps.pow(n as u32)).all(|i| {
let mut k = i;
for v in input.iter_mut().take(n) {
*v = (k % steps) as f32 / (steps - 1) as f32;
k /= steps;
}
let (mut la, mut lb) = ([0f32; 3], [0f32; 3]);
a.eval(&input[..n], &mut la);
b.eval(&input[..n], &mut lb);
math::delta_e76(decode(la), decode(lb)) <= SAME_COLORS_MAX_DELTA_E
})
}
/// Media white point used for ICC-absolute colorimetry: `wtpt`, except for v2 display
/// profiles whose `wtpt` records the (unadapted) display white; those are treated as D50.
pub fn media_white(&self) -> [f64; 3] {
+49
View File
@@ -590,3 +590,52 @@ fn gray_profile_with_reversed_lut_type_reencodes() {
odd.description = "re-encoded".into();
let _ = Profile::parse(&odd.with_encoded_bytes().to_bytes()).unwrap();
}
/// sRGB the way Photoshop embeds it: a v2 profile whose TRCs are 1024-entry 16-bit tables.
fn srgb_as_v2_tables() -> Profile {
let mut p = srgb().clone();
let table = |c: &photocraft_cms::Curve| {
photocraft_cms::Curve::Table((0..1024).map(|i| ((c.eval64(i as f64 / 1023.0) * 65535.0).round() / 65535.0) as f32).collect())
};
let trc = p.trc.clone().unwrap();
p.trc = Some([table(&trc[0]), table(&trc[1]), table(&trc[2])]);
p.version = (2, 0x10);
p.with_encoded_bytes()
}
#[test]
fn same_colors_ignores_the_encoding() {
let v2 = Profile::parse(&srgb_as_v2_tables().to_bytes()).unwrap();
assert_ne!(v2.content_hash(), srgb().content_hash(), "different bytes");
assert!(v2.same_colors(srgb()) && srgb().same_colors(&v2));
for b in Builtin::ALL {
assert!(b.profile().same_colors(b.profile()), "{b:?}");
// Re-encoded (fresh bytes, same model): CMYK too, where a round trip is not an identity.
let again = Profile::parse(&b.profile().clone().with_encoded_bytes().to_bytes()).unwrap();
assert!(again.same_colors(b.profile()), "{b:?} re-encoded");
}
}
#[test]
fn same_colors_tells_different_spaces_apart() {
for other in [Builtin::DisplayP3, Builtin::LinearSrgb, Builtin::AdobeRgbCompat, Builtin::Rec2020, Builtin::CoatedCmyk, Builtin::GrayGamma22] {
assert!(!srgb().same_colors(other.profile()), "{other:?}");
}
assert!(!Builtin::GrayGamma22.profile().same_colors(Builtin::SGray.profile()));
// Scaling the red primary's XYZ by 2% makes a different space too.
let mut moved = srgb().clone();
let m = moved.matrix.as_mut().unwrap();
for row in m.iter_mut() {
row[0] *= 1.02;
}
assert!(!moved.with_encoded_bytes().same_colors(srgb()));
}
/// The sRGB IEC61966-2.1 profile Windows ships (the one Photoshop embeds), when present.
#[test]
fn windows_srgb_is_the_builtin_srgb() {
let Ok(bytes) = std::fs::read("C:/Windows/System32/spool/drivers/color/sRGB Color Space Profile.icm") else { return };
let p = Profile::parse(&bytes).unwrap();
assert_ne!(p.content_hash(), srgb().content_hash());
assert!(p.same_colors(srgb()));
}
+11
View File
@@ -7,10 +7,16 @@ license.workspace = true
rust-version.workspace = true
[features]
# Real-file corpus tests (opt-in; `cargo xtask test-corpus`). Fail when the corpus is missing.
corpus = []
default = []
# AVIF *encode* via ravif (pure Rust). Decoding AVIF needs dav1d (C), so AVIF
# stays read-unsupported and therefore asymmetric; it is off by default.
avif = ["image/avif"]
# HEIC/HEIF decode via the optional photocraft-heif crate (heic-rs, pure Rust). Off by default so
# distributors choose it at build time; official builds and CI enable it (`--features heif` on the
# apps). Without it HEIF files are still recognised and opening one is a clear "unsupported" error.
heif = ["dep:photocraft-heif"]
[dependencies]
thiserror.workspace = true
@@ -24,6 +30,11 @@ image-webp = "0.2"
zune-jpeg = "0.5"
zune-core = "0.5"
jpeg-encoder = "0.6"
photocraft-heif = { path = "../heif", optional = true }
# Orientation is applied a band of rows per task on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon.workspace = true
[dev-dependencies]
proptest.workspace = true
+32 -5
View File
@@ -15,7 +15,8 @@ It follows the "avoid GIMP's hole" rules in `plan/architecture.md` §1.1:
* **Honest.** `caps(format)` says what each format holds, and
`fidelity_warnings(&image, format)` lists what an export will lose before you write it. Both
functions use the same encode plan as `encode`, so a warning appears exactly when the file
changes.
changes. On the way in, `Image::warnings` lists what the decoded image doesn't show: frames or
pages left out, or data that ended early.
```rust
use photocraft_codecs::*;
@@ -44,6 +45,7 @@ let out = encode(&img, Format::Tiff, &EncodeOptions::default())?;
| OpenEXR | yes | yes | F16, F32 | Y, YA, RGB, RGBA | yes | no | no | no | no | no | no | `exr` |
| Radiance HDR | yes | yes | F32 | RGB | no | no | no | no | no | no | yes (RGBE) | `image` |
| 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` |
"Native" means the data is stored and read back without conversion. Anything else is converted by
the encode plan, and `fidelity_warnings` reports the conversion when it loses information:
@@ -64,7 +66,17 @@ the same.
with `CodecError::Unsupported`: they are sensor data, not flat images. `photocraft-raw` decodes
and develops them, and `photocraft-io` routes them there.
* **AVIF (the only asymmetric format).** Encoding uses `ravif`, which is pure Rust. Decoding
* **HEIF/HEIC (read-only, feature `heif`).** Decoding lives in the optional `photocraft-heif` crate
behind the non-default `heif` feature (official PhotoCraft builds enable it; distributors choose).
Without it, HEIF is still detected and decoding returns `CodecError::Unsupported` ("HEIC/HEIF
support isn't included in this build"). iPhone and Mac photos decode with `heic-rs` (pure Rust, no `unsafe`):
single pictures and grid-tiled photos, 8 to 12 bits, auxiliary alpha, ICC, EXIF and XMP. The
container's `irot`/`imir`/`clap` are applied and EXIF Orientation is rewritten to 1 (HEIF's EXIF
tag only repeats the container's, and applying both would turn the photo twice). Grayscale
decodes to RGB. Image sequences, overlays and identity derivations, multilayer HEVC and some
4:2:2/4:4:4 streams return `CodecError::Unsupported` or `Malformed`, never wrong pixels. Writing
needs an HEVC encoder and every mature one is C, so HEIF is listed in `ASYMMETRIC_EXCEPTIONS`.
* **AVIF.** Encoding uses `ravif`, which is pure Rust. Decoding
needs `dav1d`, which is C. AVIF is therefore read-unsupported, and write support is gated
behind the non-default `avif` feature. In a default build it is neither readable nor writable,
so the symmetric guarantee holds. It is listed in `ASYMMETRIC_EXCEPTIONS`.
@@ -73,12 +85,27 @@ the same.
files.
* **Animation and multi-page files** (APNG, animated GIF/WebP, multi-page TIFF): only the first
frame or page is decoded, and a single frame is written. `FormatCaps::animation` marks
containers that can hold more frames.
containers that can hold more frames. The decoded image then carries a
`DecodeWarning::MoreFrames` / `MorePages` in `Image::warnings`, with the total when the file
states it (frames and TIFF directories are counted without decoding them; reduced-resolution
TIFF directories and transparency masks are not pages).
* **EXIF in TIFF** is stored as a sub-IFD rather than a blob, so it is not preserved yet.
`caps.exif = false` for TIFF, and a warning is raised.
* **EXIF orientation** is preserved as data but not applied to the pixels.
* **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the IFD0 tag in TIFF) is applied on
decode, like Photoshop: the pixels come back upright and the EXIF/XMP orientation is rewritten
to 1 (`DecodeOptions::keep_orientation` opts out). Encoders always write Orientation = 1
(`upright_exif`, `upright_xmp`, which change only the tag's value, kept in its own type), so upright pixels
are never rotated twice. Parsing is strict: an IFD0 inside the 8-byte header or cut off before
its next-IFD pointer, an Orientation that is not exactly one SHORT or LONG, or an out-of-range value
reads as 1; with duplicate entries the first wins. `Limits` are checked again on the upright
size (5–8 swap width and height), and the rotated buffer is allocated fallibly.
`Image::oriented` turns any layout and depth in parallel bands (about 10 ms for 24 MP RGB8 in
release).
* **JPEG**
* 8-bit only. Neither decoder backend supports 12-bit.
* A file cut off inside its image data decodes leniently (a baseline JPEG's missing rows come
out grey) but never silently: the image carries `DecodeWarning::Truncated`. A file that ends
before any scan data is an error.
* CMYK is always written 4:4:4 (subsampled CMYK is not portable) as Adobe-inverted CMYK with an
APP14 marker.
* Text is not written because there is no COM-segment support.
@@ -102,7 +129,7 @@ the same.
## Limits (decompression bombs)
`DecodeOptions { limits: Limits { max_width, max_height, max_pixels, max_alloc } }` guards
`DecodeOptions { limits: Limits { max_width, max_height, max_pixels, max_alloc }, .. }` guards
decoding. Header dimensions are checked before the pixel buffer is allocated, and the budget is
also passed to the underlying decoders. A violation returns `CodecError::LimitExceeded`. The
defaults are 262144 px per side, 2^30 pixels and 8 GiB (2 GiB on 32-bit targets such as wasm).
+10 -1
View File
@@ -14,7 +14,9 @@ cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
photocraft-codecs = { path = ".." }
photocraft-codecs = { path = "..", features = ["heif"] }
# Called directly too: the codec turns its panics into errors (see decode_heif.rs).
heic-rs = "=0.1.1"
[profile.release]
debug = 1
@@ -53,3 +55,10 @@ path = "fuzz_targets/decode_exr.rs"
test = false
doc = false
bench = false
[[bin]]
name = "decode_heif"
path = "fuzz_targets/decode_heif.rs"
test = false
doc = false
bench = false
@@ -8,6 +8,7 @@ use photocraft_codecs::{decode_with, encode, DecodeOptions, EncodeOptions, Limit
fuzz_target!(|data: &[u8]| {
let opts = DecodeOptions {
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
..Default::default()
};
if let Ok(img) = decode_with(data, &opts)
&& let Some(f) = photocraft_codecs::detect(data)
@@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
fuzz_target!(|data: &[u8]| {
let opts = DecodeOptions {
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
..Default::default()
};
let _ = decode_as_with(Format::OpenExr, data, &opts);
});
@@ -0,0 +1,15 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
// heic-rs is young and parses untrusted HEIF/HEVC: run with `-timeout=10` so a hang is reported,
// not just a panic. The codec turns a heic-rs panic into an error, so heic-rs is also called
// directly, where its panics still reach the fuzzer. Both orientation paths are covered.
fuzz_target!(|data: &[u8]| {
let keep_orientation = data.first().is_some_and(|b| b & 1 == 1);
let raw = heic_rs::DecodeOptions::default().with_max_pixels(Some(1 << 22)).with_transforms(!keep_orientation).with_alpha(true);
let _ = heic_rs::decode(data, &raw);
let limits = Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 };
let _ = decode_as_with(Format::Heif, data, &DecodeOptions { limits, keep_orientation });
});
@@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
fuzz_target!(|data: &[u8]| {
let opts = DecodeOptions {
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
..Default::default()
};
let _ = decode_as_with(Format::Jpeg, data, &opts);
});
@@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
fuzz_target!(|data: &[u8]| {
let opts = DecodeOptions {
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
..Default::default()
};
let _ = decode_as_with(Format::Png, data, &opts);
});
@@ -6,6 +6,7 @@ use photocraft_codecs::{decode_as_with, DecodeOptions, Format, Limits};
fuzz_target!(|data: &[u8]| {
let opts = DecodeOptions {
limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 256 << 20 },
..Default::default()
};
let _ = decode_as_with(Format::Tiff, data, &opts);
});
+568
View File
@@ -0,0 +1,568 @@
//! Deep OpenEXR (`deepscanline` / `deeptile`): a variable-length list of samples per pixel,
//! each with its own colour, alpha and depth. The `exr` crate rejects deep headers, so the
//! chunk stream is walked directly from the bytes, per the OpenEXR file-layout specification:
//! a deep chunk carries its coordinates, the packed size of the sample-count table, the
//! packed and unpacked sizes of the sample data, then the compressed count table (cumulative
//! within each row of the block) and the compressed sample data (row by row, each row channel
//! by channel). The layout was checked against files written by the OpenEXR library. Deep data permits only NONE, RLE, ZIPS and ZIP
//! compression; we support the first three (one scan line per block, which keeps the count
//! table unambiguous) and decline ZIP with `unsupported`.
//!
//! [`decode_deep`] returns the structured samples ([`DeepImage`]); [`flatten`] composites
//! them into a flat `Image` — samples sorted by Z, front-to-back `over` with premultiplied
//! colour, un-premultiplied into straight alpha (HDR values are kept, not clamped) — because the flat document model has no
//! per-pixel depth.
use std::io::{Cursor, Read, Seek, SeekFrom};
use exr::meta::attribute::{LevelMode, SampleType as ExrSample};
use exr::meta::{BlockDescription, MetaData};
use exr::prelude::Compression;
use half::f16;
use crate::Format;
use crate::error::CodecError;
use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, SampleType};
use crate::options::Limits;
const F: Format = Format::OpenExr;
fn err(e: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, e)
}
fn unsup(reason: impl Into<String>) -> CodecError {
CodecError::unsupported(F, reason)
}
// --- checked little-endian reads -------------------------------------------------------------
// 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}")))?;
Ok(v)
}
fn i32(cur: &mut Cursor<&[u8]>) -> Result<i32, CodecError> {
let b = read_exact(cur, 4, "an i32")?;
Ok(i32::from_le_bytes([b.first().copied().unwrap_or(0), b.get(1).copied().unwrap_or(0), b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0)]))
}
fn u64(cur: &mut Cursor<&[u8]>) -> Result<u64, CodecError> {
let b = read_exact(cur, 8, "a u64")?;
let a: [u8; 8] = b.try_into().unwrap_or([0; 8]);
Ok(u64::from_le_bytes(a))
}
fn seek_to(cur: &mut Cursor<&[u8]>, pos: u64) -> Result<(), CodecError> {
if pos > cur.get_ref().len() as u64 {
return Err(err(format!("chunk offset {pos} points past the end of the file")));
}
cur.seek(SeekFrom::Start(pos)).map_err(err)?;
Ok(())
}
// --- decompression ----------------------------------------------------------------------------
/// Undo the ZIP predictor: each byte was stored as its difference to the previous byte plus
/// 128 (so a zero difference is 0x80), modulo 256. Applies to the sample-count table and the
/// sample data alike.
fn differences_to_samples(buf: &mut [u8]) {
if let Some(&first) = buf.first() {
let mut prev = i16::from(first);
for b in buf.iter_mut().skip(1) {
let s = (prev + i16::from(*b) - 128) as u8;
prev = i16::from(s);
*b = s;
}
}
}
/// 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]) {
let half = buf.len().div_ceil(2);
let mut out = vec![0u8; 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());
if let (Some(a), Some(b), Some(pair)) = (a, b, out.get_mut(k..k + 2)) {
if let Some(byte) = pair.first_mut() {
*byte = a;
}
if let Some(byte) = pair.get_mut(1) {
*byte = b;
}
}
k += 2;
}
if buf.len() % 2 == 1 && half > 0 {
let last = buf.get(half - 1).copied();
if let (Some(last), Some(slot)) = (last, out.get_mut(k)) {
*slot = last;
}
}
buf.copy_from_slice(&out);
}
/// 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 i = 0;
while i < src.len() {
let count = src[i] as i8;
i += 1;
let n = if count < 0 { (-(count as i32)) as usize } else { count as usize + 1 };
if out.len().saturating_add(n) > expected {
return Err(err(format!("{what}: RLE data expands past {expected} bytes")));
}
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")))?;
if run.len() != n {
return Err(err(format!("{what}: RLE run points past the compressed data")));
}
out.extend_from_slice(run);
i = end;
} else {
let b = src.get(i).copied().ok_or_else(|| err(format!("{what}: RLE run points past the compressed data")))?;
i += 1;
out.resize(out.len() + n, b);
}
}
if out.len() != expected {
return Err(err(format!("{what}: RLE data expands to {} bytes, expected {expected}", out.len())));
}
Ok(out)
}
/// Decompress `what` to exactly `expected` bytes. Like flat blocks, a payload that already
/// 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());
}
match compression {
Compression::Uncompressed => Err(err(format!("{what}: expected {expected} uncompressed bytes, found {}", packed.len()))),
Compression::RLE => {
// 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);
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")))?;
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);
Ok(out)
}
other => Err(unsup(format!("deep data with {other:?} compression (NONE, RLE and ZIPS are supported)"))),
}
}
// --- the deep chunk stream ---------------------------------------------------------------------
/// One parsed chunk: where its pixels go and its cumulative sample counts, plus where to
/// find the compressed sample data in the second pass.
struct DeepChunk {
/// Global pixel indices of the block's pixels, in block order (row-major within the block).
pixels: Vec<usize>,
/// Pixels per row of the block (the data window width for a scan line, the clipped tile
/// width for a tile).
row_w: usize,
/// Cumulative sample count table of the block: `len == pixels.len() + 1`.
prefix: Vec<u32>,
/// File offset of the chunk start.
pos: u64,
/// Size of the compressed sample data in the file.
packed_data: u64,
/// The decompressed sample-data size the file declares (validated against the table).
unpacked_data: u64,
}
fn sample_bytes(t: ExrSample) -> usize {
match t {
ExrSample::U32 | ExrSample::F32 => 4,
ExrSample::F16 => 2,
}
}
fn our_sample_type(t: ExrSample) -> SampleType {
match t {
ExrSample::F16 => SampleType::F16,
ExrSample::U32 | ExrSample::F32 => SampleType::F32,
}
}
/// Read the deep samples of the first deep part. `meta` must come from an unvalidated
/// [`MetaData::read_from_buffered`] of the same bytes (deep headers fail validation).
pub(crate) fn decode_deep(meta: &MetaData, bytes: &[u8], limits: &Limits) -> Result<DeepImage, CodecError> {
let header_index = meta.headers.iter().position(|h| h.deep).ok_or_else(|| unsup("the file has no deep part"))?;
let header = meta.headers.get(header_index).ok_or_else(|| unsup("the file has no deep part"))?;
let compression = header.compression;
if !matches!(compression, Compression::Uncompressed | Compression::RLE | Compression::ZIP1) {
return Err(unsup(format!("deep data with {compression:?} compression (NONE, RLE and ZIPS are supported)")));
}
let dw = header.data_window();
let (w, h) = (dw.size.0, dw.size.1);
if w == 0 || h == 0 {
return Err(err("the data window is empty"));
}
if w > u32::MAX as usize || h > u32::MAX as usize {
return Err(err("the data window is too large"));
}
let y0 = dw.position.1;
let npixels = w.checked_mul(h).ok_or_else(|| err("the data window is too large"))?;
limits.check_bytes(w as u32, h as u32, (header.channels.list.len().max(1) as u64) * 4)?;
let exr_types: Vec<ExrSample> = header.channels.list.iter().map(|c| c.sample_type).collect();
let bytes_per_sample: usize = exr_types.iter().map(|t| sample_bytes(*t)).sum();
let multipart = meta.requirements.has_multiple_layers;
// The offset tables follow the headers: `chunk_count` u64 per header, in header order.
// Our part's table starts after the tables of all earlier parts.
let mut table_pos = header_end(bytes)?;
for h_ in meta.headers.iter().take(header_index) {
table_pos = table_pos.saturating_add((h_.chunk_count.min(1 << 24) as u64).saturating_mul(8));
}
let mut cur = Cursor::new(bytes);
seek_to(&mut cur, table_pos)?;
let mut offsets = Vec::with_capacity(header.chunk_count.min(1 << 20));
for _ in 0..header.chunk_count {
offsets.push(u64(&mut cur)?);
}
let tables_end = cur.position();
// Block geometry.
let (tiles_x, tile_w, tile_h, slots) = match &header.blocks {
BlockDescription::ScanLines => (0usize, w, 1usize, h), // one line per block for NONE/RLE/ZIPS
BlockDescription::Tiles(t) => {
if t.level_mode != LevelMode::Singular {
return Err(unsup("deep mip-map or rip-map tiles (single-level tiles are supported)"));
}
let (tw, th) = (t.tile_size.0.max(1), t.tile_size.1.max(1));
let tx = w.saturating_add(tw - 1) / tw;
let ty = h.saturating_add(th - 1) / th;
(tx, tw, th, tx.checked_mul(ty).ok_or_else(|| err("tile count overflows"))?)
}
};
let mut counts = vec![0u32; npixels];
let mut seen = vec![false; slots];
let mut chunks: Vec<DeepChunk> = Vec::with_capacity(offsets.len());
for &off in &offsets {
if off < tables_end || off >= bytes.len() as u64 {
return Err(err(format!("chunk offset {off} is outside the chunk area of the file")));
}
seek_to(&mut cur, off)?;
if multipart {
let part = i32(&mut cur)?;
if part < 0 || part as usize != header_index {
return Err(err(format!("chunk declares part {part}, expected {header_index}")));
}
}
// Block coordinates → the block's global pixel range and its slot.
let (block_pixels, slot, row_w): (Vec<usize>, usize, usize) = match &header.blocks {
BlockDescription::ScanLines => {
let y = i32(&mut cur)?;
let row = match y.checked_sub(y0) {
Some(d) if d >= 0 && (d as usize) < h => d as usize,
_ => return Err(err(format!("scan line block y={y} is outside the data window"))),
};
((0..w).map(|x| row * w + x).collect(), row, w)
}
BlockDescription::Tiles(_) => {
let (tx, ty, lx, ly) = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?);
if (lx, ly) != (0, 0) {
return Err(err(format!("deep tile at level ({lx},{ly}); only level 0 exists in a single-level file")));
}
if tx < 0 || ty < 0 || (tx as usize) >= tiles_x || (ty as usize) * tile_h >= h {
return Err(err(format!("tile ({tx},{ty}) is outside the data window")));
}
let (bx, by) = ((tx as usize) * tile_w, (ty as usize) * tile_h);
let rows = (by + tile_h).min(h);
let cols = (bx + tile_w).min(w);
let mut px = Vec::with_capacity((rows - by) * (cols - bx));
for yy in by..rows {
for xx in bx..cols {
px.push(yy.saturating_mul(w).saturating_add(xx));
}
}
let slot = (ty as usize).saturating_mul(tiles_x) + tx as usize;
(px, slot, cols - bx)
}
};
if seen.get(slot).copied().unwrap_or(true) {
return Err(err(format!("two chunks cover block {slot}")));
}
if let Some(s) = seen.get_mut(slot) {
*s = true;
}
let packed_table = u64(&mut cur)?;
let packed_data = u64(&mut cur)?;
let unpacked_data = u64(&mut cur)?;
let remaining = (bytes.len() as u64).saturating_sub(cur.position());
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 expected_table = block_pixels.len().checked_mul(4).ok_or_else(|| err("count table overflows"))?;
// 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"))?;
// 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.
let mut prefix = Vec::with_capacity(block_pixels.len() + 1);
prefix.push(0u32);
let (mut row_base, mut prev) = (0u32, 0u32);
for (i, p) in block_pixels.iter().enumerate() {
let b: [u8; 4] = raw.get(i * 4..i * 4 + 4).and_then(|s| s.try_into().ok()).ok_or_else(|| err("the count table is short"))?;
let v = u32::from_le_bytes(b);
if row_w > 0 && i % row_w == 0 {
row_base = row_base.checked_add(prev).ok_or_else(|| err("the block's sample count overflows"))?;
prev = 0;
}
if v < prev {
return Err(err("the sample-count table decreases (malformed cumulative counts)"));
}
if let Some(c) = counts.get_mut(*p) {
*c = v - prev;
}
prev = v;
prefix.push(row_base.checked_add(v).ok_or_else(|| err("the block's sample count overflows"))?);
}
let total = prefix.last().copied().unwrap_or(0) as u64;
let expected_data = total.checked_mul(bytes_per_sample as u64).ok_or_else(|| err("the sample data size overflows"))?;
if expected_data != unpacked_data {
return Err(err(format!("the chunk declares {unpacked_data} bytes of sample data, the count table implies {expected_data}")));
}
chunks.push(DeepChunk { pixels: block_pixels, row_w, prefix, pos: off, packed_data, unpacked_data });
}
if seen.iter().any(|s| !s) {
let missing = seen.iter().filter(|s| !**s).count();
return Err(err(format!("{missing} of {slots} chunks are missing from the offset table")));
}
// Global cumulative counts in raster order.
let mut deep_counts = Vec::with_capacity(npixels + 1);
deep_counts.push(0u64);
let mut acc = 0u64;
for &c in &counts {
acc = acc.checked_add(c as u64).ok_or_else(|| err("the total sample count overflows"))?;
deep_counts.push(acc);
}
let total_samples = acc;
let sample_bytes_total = total_samples.checked_mul(exr_types.len() as u64 * 4).ok_or_else(|| err("the sample buffer size overflows"))?;
if sample_bytes_total > limits.max_alloc {
return Err(CodecError::LimitExceeded(format!("deep samples need {sample_bytes_total} bytes, exceeding max_alloc {}", limits.max_alloc)));
}
let mut channels: Vec<DeepChannel> = header
.channels
.list
.iter()
.map(|c| DeepChannel { name: c.name.to_string(), sample: our_sample_type(c.sample_type), samples: vec![0f32; total_samples as usize] })
.collect();
// Second pass: the compressed sample data, scattered per pixel (a block's channel data
// is contiguous in the file but the pixels' samples interleave with other blocks').
for chunk in &chunks {
seek_to(&mut cur, chunk.pos)?;
if multipart {
let _part = i32(&mut cur)?;
}
match &header.blocks {
BlockDescription::ScanLines => {
let _y = i32(&mut cur)?;
}
BlockDescription::Tiles(_) => {
let _coords = (i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?, i32(&mut cur)?);
}
}
let packed_table = u64(&mut cur)?;
let _packed_data = u64(&mut cur)?;
let _unpacked_data = u64(&mut cur)?;
// 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")?;
// 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;
let row_w = chunk.row_w.max(1);
for (r, row) in chunk.pixels.chunks(row_w).enumerate() {
let i0 = r * row_w;
let row_from = chunk.prefix.get(i0).copied().unwrap_or(0) as usize;
let row_to = chunk.prefix.get(i0 + row.len()).copied().unwrap_or(0) as usize;
let row_total = row_to.saturating_sub(row_from);
for (ci, t) in exr_types.iter().enumerate() {
let sb = sample_bytes(*t);
let ch_bytes = row_total.checked_mul(sb).ok_or_else(|| err("channel size overflows"))?;
let end = in_off.checked_add(ch_bytes).ok_or_else(|| err("channel size overflows"))?;
let src = raw.get(in_off..end).ok_or_else(|| err("the sample data is short"))?;
in_off = end;
let out = channels.get_mut(ci).ok_or_else(|| err("channel index out of range"))?;
for (j, p) in row.iter().enumerate() {
let from = (chunk.prefix.get(i0 + j).copied().unwrap_or(0) as usize).saturating_sub(row_from);
let n = (chunk.prefix.get(i0 + j + 1).copied().unwrap_or(0) as usize).saturating_sub(row_from).saturating_sub(from);
let to = deep_counts.get(*p).copied().unwrap_or(0) as usize;
for k in 0..n {
let base = (from + k) * sb;
let v = match t {
ExrSample::F16 => src.get(base..base + 2).map(|b| f16::from_le_bytes([b[0], b[1]]).to_f32()),
ExrSample::F32 => src.get(base..base + 4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])),
ExrSample::U32 => src.get(base..base + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f32),
};
let (Some(v), Some(slot)) = (v, out.samples.get_mut(to + k)) else {
return Err(err("the sample data is short"));
};
*slot = v;
}
}
}
}
}
Ok(DeepImage { width: w as u32, height: h as u32, channels, counts: deep_counts })
}
/// Where the headers end and the offset tables begin. [`MetaData::read_from_buffered`]
/// consumes exactly the version field and the headers, so re-reading with a fresh cursor
/// and taking its end position yields it.
fn header_end(bytes: &[u8]) -> Result<u64, CodecError> {
let mut cur = Cursor::new(bytes);
MetaData::read_from_buffered(&mut cur, false).map_err(err)?;
Ok(cur.position())
}
/// Composite the deep samples into a flat image: per pixel, samples sorted by Z (stable; the
/// file is assumed messy), front-to-back `over` with premultiplied colour, un-premultiplied
/// into straight alpha at the end. Without any alpha channel the front-most sample wins,
/// opaque. Z, ZBack and other deep channels influence the flat result only through the
/// ordering; volume samples (with ZBack) are approximated as point samples.
pub(crate) fn flatten(deep: &DeepImage) -> Result<Image, CodecError> {
let npx = deep.width as usize * deep.height as usize;
let has = |n: &str| deep.channel(n).is_some();
let rgb = has("R") && has("G") && has("B");
let gray = !rgb && (has("Y") || deep.channels.len() == 1);
let layout = if rgb || gray {
if has("A") || has("AR") || has("AG") || has("AB") {
if rgb { ChannelLayout::Rgba } else { ChannelLayout::GrayA }
} else if rgb {
ChannelLayout::Rgb
} else {
ChannelLayout::Gray
}
} else {
return Err(CodecError::unsupported(F, "no R/G/B or Y channels in the deep part"));
};
let nc = layout.channels();
let samples = |n: &str| -> &[f32] { deep.channel(n).map(|c| c.samples.as_slice()).unwrap_or(&[]) };
let (r, g, b) = (samples("R"), samples("G"), samples("B"));
let (ar, ag, ab) = (samples("AR"), samples("AG"), samples("AB"));
let (a, z) = (samples("A"), samples("Z"));
let y = samples("Y");
let lum = if y.is_empty() { deep.channels.first().map(|c| c.samples.as_slice()).unwrap_or(&[]) } else { y };
let mut out = vec![0f32; npx * nc];
let mut order: Vec<u32> = Vec::new();
let mut max_samples = 0u32;
for p in 0..npx {
let range = deep.sample_range(p);
let (s0, n) = (range.start as usize, (range.end - range.start) as usize);
max_samples = max_samples.max(u32::try_from(n).unwrap_or(u32::MAX));
if n == 0 {
continue; // no samples: transparent black
}
order.clear();
order.extend(0u32..n as u32);
if !z.is_empty() {
// Stable sort by Z; a NaN depth (invalid) sorts last, deterministically.
order.sort_by(|&i, &j| {
let zi = z.get(s0 + i as usize).copied().unwrap_or(f32::NAN);
let zj = z.get(s0 + j as usize).copied().unwrap_or(f32::NAN);
zi.total_cmp(&zj)
});
}
let at = |ch: &[f32], i: u32| ch.get(s0 + i as usize).copied().unwrap_or(0.0);
// Per-colour-channel alpha: AR/AG/AB when present, else A, else opaque.
let alpha = |i: u32| -> [f32; 3] {
let s = at(a, i);
[
if !a.is_empty() && ar.is_empty() {
s
} else if !ar.is_empty() {
at(ar, i)
} else {
1.0
},
if !a.is_empty() && ag.is_empty() {
s
} else if !ag.is_empty() {
at(ag, i)
} else {
1.0
},
if !a.is_empty() && ab.is_empty() {
s
} else if !ab.is_empty() {
at(ab, i)
} else {
1.0
},
]
};
let mut cacc = [0f32; 3]; // premultiplied colour
let mut aacc = [0f32; 3]; // accumulated alpha
for &i in &order {
let ai = alpha(i);
let src = if rgb { [at(r, i), at(g, i), at(b, i)] } else { [at(lum, i); 3] };
let mut covered = true;
for k in 0..3 {
let t = (1.0 - aacc[k]).max(0.0);
cacc[k] += src[k] * t;
aacc[k] += t * ai[k];
covered &= aacc[k] >= 1.0;
}
if covered {
break; // everything behind is hidden
}
}
let a_out = if a.is_empty() && ar.is_empty() && ag.is_empty() && ab.is_empty() { 1.0 } else { (aacc[0] + aacc[1] + aacc[2]) / 3.0 };
let a_clamped = a_out.clamp(0.0, 1.0);
// Un-premultiply into straight alpha (the deep colours are premultiplied).
let straight = |c: f32| if a_clamped > 1e-6 { c / a_clamped } else { 0.0 };
let base = p * nc;
for (k, v) in [straight(cacc[0]), straight(cacc[1]), straight(cacc[2]), a_clamped].into_iter().enumerate() {
if let Some(slot) = out.get_mut(base + k) {
*slot = v;
}
}
if !rgb {
// Luminance: keep only channel 0, and the alpha behind it.
if let Some(slot) = out.get_mut(base) {
*slot = straight(cacc[0]);
}
}
}
let mut img = Image::from_f32(deep.width, deep.height, layout, &out)?;
img.warnings.push(DecodeWarning::DeepFlattened { format: F, max_samples_per_pixel: max_samples });
Ok(img)
}
+24 -2
View File
@@ -21,6 +21,15 @@ fn err(e: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, e)
}
/// An `exr`-crate error, with its `NotSupported` kind kept distinct from malformed data
/// (the same split the TIFF codec makes), so unsupported features say so.
fn map_exr(e: exr::error::Error) -> CodecError {
match e {
exr::error::Error::NotSupported(message) => CodecError::unsupported(F, format!("not supported: {message}")),
e => err(e),
}
}
/// Strip an optional `layer.` prefix from a channel name.
fn base_name(name: &str) -> &str {
name.rsplit('.').next().unwrap_or(name)
@@ -36,8 +45,21 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
limits.check_bytes(w, h, bpp)?;
}
let image =
read().no_deep_data().largest_resolution_level().all_channels().first_valid_layer().all_attributes().from_buffered(Cursor::new(bytes)).map_err(err)?;
// Deep parts are rejected by the `exr` crate's reader; walk their chunks ourselves and
// composite the samples into the flat image (a warning says what was lost).
if meta.requirements.has_deep_data {
let deep = super::deep_exr::decode_deep(&meta, bytes, limits)?;
return super::deep_exr::flatten(&deep);
}
let image = read()
.no_deep_data()
.largest_resolution_level()
.all_channels()
.first_valid_layer()
.all_attributes()
.from_buffered(Cursor::new(bytes))
.map_err(map_exr)?;
let layer = &image.layer_data;
let (w, h) = (layer.size.0, layer.size.1);
let channels = &layer.channel_data.list;
+103
View File
@@ -0,0 +1,103 @@
//! HEIF / HEIC (the iPhone and Mac photo format), read-only: see [`crate::ASYMMETRIC_EXCEPTIONS`].
//!
//! Decoding lives in the optional `photocraft-heif` crate (heic-rs, a pure-Rust HEVC
//! still-picture decoder), enabled by this crate's `heif` feature. Without it, HEIF files are
//! still detected and opening one is an [`CodecError::Unsupported`] error, never a panic.
//!
//! HEIF records orientation in the container (`irot`/`imir`, plus a `clap` crop), not in EXIF,
//! so those are applied by the decoder and the EXIF Orientation is rewritten to 1. Its EXIF tag
//! only mirrors what the container already says, and applying it as well would turn the photo
//! twice.
use crate::Format;
use crate::error::CodecError;
use crate::image::Image;
use crate::options::Limits;
const F: Format = Format::Heif;
/// The reason given when this build has no HEIF decoder.
#[cfg(not(feature = "heif"))]
pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build of PhotoCraft";
#[cfg(not(feature = "heif"))]
pub(crate) fn decode(_bytes: &[u8], _limits: &Limits, _keep_orientation: bool) -> Result<Image, CodecError> {
Err(CodecError::unsupported(F, NOT_IN_BUILD))
}
#[cfg(feature = "heif")]
fn err(e: photocraft_heif::Error) -> CodecError {
match e {
photocraft_heif::Error::Unsupported(what) => CodecError::unsupported(F, what),
photocraft_heif::Error::Limit(what) => CodecError::LimitExceeded(what),
photocraft_heif::Error::Malformed(what) => CodecError::malformed(F, what),
}
}
#[cfg(feature = "heif")]
pub(crate) fn decode(bytes: &[u8], limits: &Limits, keep_orientation: bool) -> Result<Image, CodecError> {
use crate::image::{ChannelLayout, Metadata, SampleType};
// The container alone: the declared size is checked before any pixel is decoded.
let info = photocraft_heif::probe(bytes).map_err(err)?;
let layout = if info.has_alpha { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
let sample = if info.sixteen_bit { SampleType::U16 } else { SampleType::U8 };
let (w, h) = if keep_orientation { (info.coded_width, info.coded_height) } else { (info.width, info.height) };
limits.check(w, h, layout, sample)?;
let options = photocraft_heif::Options { max_pixels: limits.max_pixels, apply_transforms: !keep_orientation };
let decoded = photocraft_heif::decode(bytes, &options).map_err(err)?;
// The decoder re-reads the container; trust its output's shape, not the probe's.
let layout = if decoded.has_alpha { ChannelLayout::Rgba } else { ChannelLayout::Rgb };
let sample = if decoded.sixteen_bit { SampleType::U16 } else { SampleType::U8 };
let mut img = Image::from_raw(decoded.width, decoded.height, layout, sample, decoded.data)?;
img.icc = decoded.icc;
img.meta = Metadata {
exif: decoded.exif.map(|e| if keep_orientation { e } else { crate::orientation::upright_exif(&e).into_owned() }),
xmp: decoded.xmp.map(|x| if keep_orientation { x } else { crate::orientation::upright_xmp(&x).into_owned() }),
..Default::default()
};
Ok(img)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(feature = "heif"))]
#[test]
fn without_the_feature_heif_is_a_clear_unsupported_error() {
let header = b"\0\0\0\x18ftypheic\0\0\0\0mif1heic";
assert_eq!(crate::detect(header), Some(F), "still recognised");
let r = crate::decode(header);
assert!(matches!(&r, Err(CodecError::Unsupported { format: Format::Heif, reason }) if reason.contains("isn't included in this build")), "{r:?}");
assert!(!crate::caps(F).read);
}
#[cfg(feature = "heif")]
#[test]
fn with_the_feature_heif_is_readable_and_errors_map() {
assert!(crate::caps(F).read);
let r = crate::decode(b"\0\0\0\x18ftypmsf1\0\0\0\0msf1hevc");
assert!(matches!(&r, Err(CodecError::Unsupported { format: Format::Heif, reason }) if reason.contains("sequence")), "{r:?}");
assert!(matches!(crate::decode_as(F, b""), Err(CodecError::Malformed { .. } | CodecError::Unsupported { .. })));
}
/// Found by the `decode_heif` fuzz target: a malformed box makes heic-rs 0.1.1 slice out of range
/// (`boxes.rs:130`, "slice index starts at 24 but ends at 16"). It must be an error, not a crash.
#[cfg(feature = "heif")]
const HEIC_RS_BOX_PANIC: [u8; 72] = [
0x00, 0x00, 0x00, 0x24, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x69, 0x66, 0x31, 0x4d, 0x69, 0x50, 0x72, 0x6d,
0x69, 0x61, 0x66, 0x4d, 0x69, 0x48, 0x42, 0x72, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6d, 0x65, 0x74, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x10, 0x75, 0x75, 0x69, 0x64, 0x00, 0x00, 0x00, 0x00, 0x03, 0x08, 0x08, 0x08, 0x00, 0x03, 0x01, 0x03, 0x70, 0x00, 0xa8, 0x00,
];
#[cfg(feature = "heif")]
#[test]
fn a_heic_rs_panic_is_a_malformed_file_error() {
assert_eq!(crate::detect(&HEIC_RS_BOX_PANIC), Some(Format::Heif));
for opts in [crate::DecodeOptions::default(), crate::DecodeOptions { keep_orientation: true, ..Default::default() }] {
let r = crate::decode_with(&HEIC_RS_BOX_PANIC, &opts);
assert!(matches!(r, Err(CodecError::Malformed { format: Format::Heif, .. })), "{r:?}");
}
}
}
+129 -8
View File
@@ -10,8 +10,9 @@ use zune_core::options::DecoderOptions;
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
use crate::options::{EncodeOptions, Limits};
use crate::orientation::{upright_exif, upright_xmp};
const F: Format = Format::Jpeg;
const EXIF_HEADER: &[u8] = b"Exif\0\0";
@@ -100,11 +101,70 @@ pub(crate) fn scan_metadata(b: &[u8]) -> JpegMeta {
m
}
/// How a JPEG's data ends, seen from its marker structure.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DataEnd {
/// An end-of-image marker follows the scans (or the structure is too odd to tell).
Complete,
/// The file ends inside the image data, before the end-of-image marker.
Truncated,
/// The file ends before the first scan holds any data: nothing can be decoded.
Empty,
}
/// Walks the marker segments (skipped by their length) and the entropy-coded scan data
/// (skipped up to the next marker that isn't a stuffed `FF 00`, a restart or a fill byte),
/// so an embedded thumbnail's end marker or data after the end of the image don't count.
fn data_end(b: &[u8]) -> DataEnd {
// Where the bytes run out: inside the image data once a scan has held some.
let cut = |scan_data: bool| if scan_data { DataEnd::Truncated } else { DataEnd::Empty };
let mut i = 2; // past SOI
let mut scan_data = false;
loop {
let Some(&byte) = b.get(i) else { return cut(scan_data) };
if byte != 0xFF {
// Not where a marker should be: too odd to judge.
return DataEnd::Complete;
}
let Some(&code) = b.get(i + 1) else { return cut(scan_data) };
match code {
0xD9 => return DataEnd::Complete,
0xFF => i += 1,
0x01 | 0xD0..=0xD8 => i += 2,
_ => {
let Some(len) = b.get(i + 2..i + 4).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_be_bytes) else {
return cut(scan_data);
};
i = i.saturating_add(2 + usize::from(len));
if code == 0xDA {
let start = i;
loop {
let Some(p) = b.get(i..).and_then(|r| r.iter().position(|&v| v == 0xFF)) else {
return cut(scan_data || b.len() > start);
};
i += p;
match b.get(i + 1) {
Some(0x00 | 0xD0..=0xD7) => i += 2,
Some(0xFF) => i += 1,
_ => break,
}
}
scan_data |= i > start;
}
}
}
}
}
fn err(e: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, e)
}
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let end = data_end(bytes);
if end == DataEnd::Empty {
return Err(err("the file ends before any image data"));
}
let meta = scan_metadata(bytes);
if let Some((w, h, nc)) = meta.frame {
limits.check_bytes(w, h, u64::from(nc.max(1)))?;
@@ -148,7 +208,10 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
}
let mut img = Image::from_raw(w, h, layout, SampleType::U8, px)?;
img.icc = meta.icc;
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, text: Vec::new() };
img.meta = Metadata { exif: meta.exif, xmp: meta.xmp, dpi: meta.dpi, ..Default::default() };
if end == DataEnd::Truncated {
img.warnings.push(DecodeWarning::Truncated { format: F });
}
Ok(img)
}
@@ -185,14 +248,12 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
{
enc.set_density(jpeg_encoder::Density::Inch { x: x.round().min(65535.0) as u16, y: y.round().min(65535.0) as u16 });
}
if let Some(exif) = &img.meta.exif {
let mut seg = EXIF_HEADER.to_vec();
seg.extend_from_slice(exif);
// Metadata too large for its one segment is left out (and reported by the fidelity
// warnings) rather than failing the whole export.
if let Some(seg) = img.meta.exif.as_deref().and_then(exif_segment) {
enc.add_app_segment(1, &seg).map_err(e)?;
}
if let Some(xmp) = &img.meta.xmp {
let mut seg = XMP_HEADER.to_vec();
seg.extend_from_slice(xmp.as_bytes());
if let Some(seg) = img.meta.xmp.as_deref().and_then(xmp_segment) {
enc.add_app_segment(1, &seg).map_err(e)?;
}
}
@@ -204,3 +265,63 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
enc.encode(img.data(), w16, h16, ct).map_err(e)?;
Ok(out)
}
/// The most data one APP segment holds (its 16-bit length counts its own two bytes).
const MAX_SEGMENT: usize = 65533;
/// XMP properties (local names, any namespace prefix) that only describe the layered document:
/// every document ever placed in it, the text of every type layer. A flat image doesn't need them,
/// and they alone can outgrow a segment.
const LAYERED_ONLY_XMP: [&str; 2] = ["DocumentAncestors", "TextLayers"];
/// The EXIF APP1 segment, or `None` when it doesn't fit in one segment.
pub(crate) fn exif_segment(exif: &[u8]) -> Option<Vec<u8>> {
// The pixels are written as they are shown: never let a viewer rotate them again.
let seg = [EXIF_HEADER, upright_exif(exif).as_ref()].concat();
(seg.len() <= MAX_SEGMENT).then_some(seg)
}
/// The XMP APP1 segment. When the packet doesn't fit in one segment, the layered-document
/// properties are left out; `None` when it still doesn't fit.
pub(crate) fn xmp_segment(xmp: &str) -> Option<Vec<u8>> {
let xmp = upright_xmp(xmp);
let seg = |x: &str| [XMP_HEADER, x.as_bytes()].concat();
if XMP_HEADER.len() + xmp.len() <= MAX_SEGMENT {
return Some(seg(&xmp));
}
let trimmed = LAYERED_ONLY_XMP.iter().fold(xmp.into_owned(), |x, name| remove_element(&x, name));
(XMP_HEADER.len() + trimmed.len() <= MAX_SEGMENT).then(|| seg(&trimmed))
}
/// `xmp` without its `<prefix:name …>…</prefix:name>` and `<prefix:name …/>` elements. Unbalanced
/// markup is kept as is.
fn remove_element(xmp: &str, name: &str) -> String {
let local = format!(":{name}");
let mut out = String::with_capacity(xmp.len());
let mut rest = xmp;
while let Some(j) = rest.find(&local) {
let head = rest.get(..j).unwrap_or_default();
let prefix_len = head.bytes().rev().take_while(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')).count();
let lt = j - prefix_len;
let after = rest.get(j + local.len()..).unwrap_or_default();
// An opening tag of exactly this name (not a closing tag, an attribute or a longer name).
let opening = prefix_len > 0 && lt > 0 && rest.get(lt - 1..lt) == Some("<");
if !opening || !after.starts_with(['>', '/', ' ', '\t', '\r', '\n']) {
out.push_str(rest.get(..j + local.len()).unwrap_or_default());
rest = after;
continue;
}
let Some(gt) = after.find('>') else { break };
let end = if after.get(..gt).is_some_and(|s| s.ends_with('/')) {
gt + 1
} else {
let close = format!("</{}{local}>", rest.get(lt..j).unwrap_or_default());
let Some(c) = after.find(&close) else { break };
c + close.len()
};
out.push_str(rest.get(..lt - 1).unwrap_or_default());
rest = after.get(end..).unwrap_or_default();
}
out.push_str(rest);
out
}
+2
View File
@@ -1,4 +1,6 @@
pub(crate) mod deep_exr;
pub(crate) mod exr;
pub(crate) mod heif;
pub(crate) mod jpeg;
pub(crate) mod png;
pub(crate) mod pnm;
+9 -3
View File
@@ -6,7 +6,7 @@ use std::io::{Cursor, Write};
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
use crate::options::{EncodeOptions, Limits, PngCompression};
const F: Format = Format::Png;
@@ -27,6 +27,8 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
limits.check_bytes(w, h, 8)?;
}
let mut reader = decoder.read_info().map_err(map_png_err)?;
// APNG: the IDAT image is the first frame, or an extra default image when no fcTL precedes it.
let images = reader.info().animation_control.map_or(1, |a| u64::from(a.num_frames) + u64::from(reader.info().frame_control.is_none()));
let (color, depth) = reader.output_color_type();
let layout = match color {
png::ColorType::Grayscale => ChannelLayout::Gray,
@@ -82,6 +84,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
}
}
img.meta = meta;
if images > 1 {
img.warnings.push(DecodeWarning::MoreFrames { total: u32::try_from(images).ok() });
}
Ok(img)
}
@@ -131,7 +136,8 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
}
if opts.embed_metadata {
if let Some(exif) = &img.meta.exif {
info.exif_metadata = Some(exif.clone().into());
// The pixels are written as they are shown: never let a viewer rotate them again.
info.exif_metadata = Some(crate::orientation::upright_exif(exif).into_owned().into());
}
if let Some((x, y)) = img.meta.dpi
&& x > 0.0
@@ -164,7 +170,7 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
res.map_err(|e| CodecError::encode(F, e))?;
}
if let Some(xmp) = &img.meta.xmp {
encoder.add_itxt_chunk(XMP_KEYWORD.into(), xmp.clone()).map_err(|e| CodecError::encode(F, e))?;
encoder.add_itxt_chunk(XMP_KEYWORD.into(), crate::orientation::upright_xmp(xmp).into_owned()).map_err(|e| CodecError::encode(F, e))?;
}
}
let mut writer = encoder.write_header().map_err(|e| CodecError::encode(F, e))?;
+135 -18
View File
@@ -1,6 +1,14 @@
//! TIFF via the `tiff` crate: 8/16-bit integer and 32-bit float, gray/RGB/
//! CMYK with optional (unassociated) alpha, ICC (34675), XMP (700), DPI and
//! a few ASCII text tags. Only the first IFD is read.
//! a few ASCII text tags. Only the first IFD is read; further pages are counted and reported
//! as a [`DecodeWarning::MorePages`].
//!
//! Photoshop's private tags are carried as opaque bytes in [`Metadata`]: 34377 (image
//! resources) and 37724 (`ImageSourceData`, the layers of a layered TIFF). They are read with
//! a bounds-checked walk of the first directory ([`photoshop_tags`]) rather than through the
//! decoder, which would materialize a multi-megabyte layer block as one `Value` per byte, and
//! written back as BYTE / UNDEFINED tags. The encoder writes files in the native byte order
//! (see [`writes_little_endian`]), which the layer data must match.
use std::borrow::Cow;
use std::io::Cursor;
@@ -13,11 +21,92 @@ use tiff::tags::{PhotometricInterpretation, ResolutionUnit, SampleFormat, Tag, T
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
use crate::options::{EncodeOptions, Limits, TiffCompression};
const F: Format = Format::Tiff;
const TAG_XMP: u16 = 700;
/// Photoshop image resources (`8BIM` blocks).
const TAG_PHOTOSHOP: u16 = 34377;
/// Photoshop `ImageSourceData`: the layers of a layered TIFF.
const TAG_IMAGE_SOURCE_DATA: u16 = 37724;
/// `true` when [`encode`] writes little-endian (`II`) files: the `tiff` encoder writes the
/// running target's byte order, and every target PhotoCraft builds for is little-endian.
/// Byte-order-sensitive payloads (Photoshop layer data) must be produced to match.
pub fn writes_little_endian() -> bool {
cfg!(target_endian = "little")
}
/// `(34377 image resources, 37724 image source data)` payloads, each absent when the tag is.
pub type PhotoshopTags<'a> = (Option<&'a [u8]>, Option<&'a [u8]>);
/// The raw payloads of the Photoshop private tags in the first directory: `(34377 image
/// resources, 37724 image source data)`. Classic and BigTIFF, either byte order; only
/// header bytes and the two payloads are touched, and nothing is allocated. Anything malformed
/// reads as "absent".
pub fn photoshop_tags(b: &[u8]) -> PhotoshopTags<'_> {
fn walk(b: &[u8]) -> Option<PhotoshopTags<'_>> {
let le = match b.get(0..2)? {
b"II" => true,
b"MM" => false,
_ => return None,
};
let u16_at = |o: u64| -> Option<u16> {
let o = usize::try_from(o).ok()?;
let s = b.get(o..o.checked_add(2)?)?;
Some(if le { u16::from_le_bytes([s[0], s[1]]) } else { u16::from_be_bytes([s[0], s[1]]) })
};
let u32_at = |o: u64| -> Option<u32> {
let o = usize::try_from(o).ok()?;
let s: [u8; 4] = b.get(o..o.checked_add(4)?)?.try_into().ok()?;
Some(if le { u32::from_le_bytes(s) } else { u32::from_be_bytes(s) })
};
let u64_at = |o: u64| -> Option<u64> {
let o = usize::try_from(o).ok()?;
let s: [u8; 8] = b.get(o..o.checked_add(8)?)?.try_into().ok()?;
Some(if le { u64::from_le_bytes(s) } else { u64::from_be_bytes(s) })
};
let (big, ifd) = match u16_at(2)? {
42 => (false, u64::from(u32_at(4)?)),
43 => (true, u64_at(8)?),
_ => return None,
};
let n = if big { u64_at(ifd)? } else { u64::from(u16_at(ifd)?) }.min(4096);
let (entry, first, inline) = if big { (20u64, ifd.checked_add(8)?, 8u64) } else { (12, ifd.checked_add(2)?, 4) };
let mut resources = None;
let mut layers = None;
for i in 0..n {
let e = first.checked_add(i.checked_mul(entry)?)?;
let tag = u16_at(e)?;
if tag != TAG_PHOTOSHOP && tag != TAG_IMAGE_SOURCE_DATA {
continue;
}
// BYTE, ASCII, SBYTE or UNDEFINED: one byte per element.
if !matches!(u16_at(e + 2)?, 1 | 2 | 6 | 7) {
continue;
}
let count = if big { u64_at(e + 4)? } else { u64::from(u32_at(e + 4)?) };
let value_at = e + if big { 12 } else { 8 };
let start = if count <= inline {
value_at
} else if big {
u64_at(value_at)?
} else {
u64::from(u32_at(value_at)?)
};
let start = usize::try_from(start).ok()?;
let data = b.get(start..start.checked_add(usize::try_from(count).ok()?)?)?;
if tag == TAG_PHOTOSHOP {
resources = Some(data);
} else {
layers = Some(data);
}
}
Some((resources, layers))
}
walk(b).unwrap_or((None, None))
}
/// ASCII tags mapped to `Metadata::text` keys.
const TEXT_TAGS: &[(Tag, &str)] = &[
@@ -159,8 +248,6 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
};
let bytes_per_sample = u64::from(bits.max(8) / 8);
limits.check_bytes(w, h, layout.channels() as u64 * bytes_per_sample.max(1))?;
let white_is_zero = dec.find_tag_unsigned::<u16>(Tag::PhotometricInterpretation).ok().flatten() == Some(PhotometricInterpretation::WhiteIsZero.to_u16());
let result = dec.read_image().map_err(map_err)?;
let n = w as usize * h as usize * layout.channels();
let mut img = match result {
@@ -184,19 +271,6 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
if img.sample_count() != n {
return Err(err("sample count mismatch"));
}
if white_is_zero && layout.is_gray() {
let v: Vec<f32> = img
.to_normalized()
.chunks(layout.channels())
.flat_map(|p| {
let mut p = p.to_vec();
p[0] = 1.0 - p[0];
p
})
.collect();
img = Image::from_normalized(w, h, layout, img.sample_type(), &v)?;
}
img.icc = dec.find_tag(Tag::IccProfile).ok().flatten().and_then(value_bytes);
let mut meta = Metadata {
xmp: dec
@@ -229,10 +303,40 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
}
}
}
let (resources, layers) = photoshop_tags(bytes);
meta.photoshop_resources = resources.map(<[u8]>::to_vec);
meta.photoshop_layers = layers.map(<[u8]>::to_vec);
img.meta = meta;
img.warnings.extend(more_pages(&mut dec));
Ok(img)
}
/// Counts the pages after the first IFD without decoding their pixels, skipping
/// reduced-resolution copies (thumbnails) and transparency masks. The total is unknown when
/// a directory can't be read or there are more than `MAX_IFDS`.
fn more_pages(dec: &mut Decoder<Cursor<&[u8]>>) -> Option<DecodeWarning> {
const MAX_IFDS: u32 = 10_000;
let mut pages = 1u32;
let mut walked = 0;
let mut total = None;
loop {
if !dec.more_images() {
total = Some(pages);
break;
}
walked += 1;
if walked > MAX_IFDS || dec.next_image().is_err() {
break;
}
// NewSubfileType bit 0: a reduced-resolution image; bit 2: a transparency mask.
let kind = dec.find_tag_unsigned::<u32>(Tag::NewSubfileType).ok().flatten().unwrap_or(0);
if kind & 0b101 == 0 {
pages += 1;
}
}
(pages > 1).then_some(DecodeWarning::MorePages { total })
}
fn rational_f64(v: tiff::decoder::ifd::Value) -> Option<f64> {
use tiff::decoder::ifd::Value;
match v {
@@ -329,6 +433,8 @@ struct TagSet<'a> {
dpi: Option<(f32, f32)>,
text: Vec<(Tag, &'a str)>,
alpha: bool,
photoshop_resources: Option<&'a [u8]>,
photoshop_layers: Option<&'a [u8]>,
}
fn write_one<C>(enc: &mut TiffEncoder<&mut Cursor<Vec<u8>>>, w: u32, h: u32, data: &[C::Inner], tags: &TagSet<'_>) -> tiff::TiffResult<()>
@@ -356,6 +462,12 @@ where
for (tag, s) in &tags.text {
d.write_tag(*tag, *s)?;
}
if let Some(r) = tags.photoshop_resources {
d.write_tag(Tag::Unknown(TAG_PHOTOSHOP), r)?;
}
if let Some(l) = tags.photoshop_layers {
d.write_tag(Tag::Unknown(TAG_IMAGE_SOURCE_DATA), Undefined(l))?;
}
im.write_data(data)
}
@@ -383,12 +495,17 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
} else {
Vec::new()
};
// TIFF writes no Orientation tag (= 1): keep the XMP from contradicting it.
let xmp = if opts.embed_metadata { img.meta.xmp.as_deref().map(crate::orientation::upright_xmp) } else { None };
let tags = TagSet {
icc: if opts.embed_icc { img.icc.as_deref() } else { None },
xmp: if opts.embed_metadata { img.meta.xmp.as_deref().map(str::as_bytes) } else { None },
xmp: xmp.as_deref().map(str::as_bytes),
dpi: if opts.embed_metadata { img.meta.dpi.filter(|d| d.0 > 0.0 && d.1 > 0.0) } else { None },
text,
alpha: img.layout().has_alpha(),
// Document content, not metadata: written whenever present.
photoshop_resources: img.meta.photoshop_resources.as_deref().filter(|r| !r.is_empty()),
photoshop_layers: img.meta.photoshop_layers.as_deref().filter(|l| !l.is_empty()),
};
let mut cursor = Cursor::new(Vec::new());
+41 -1
View File
@@ -8,7 +8,7 @@ use image::{DynamicImage, ImageDecoder, ImageFormat};
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, Image, SampleType};
use crate::image::{ChannelLayout, DecodeWarning, Image, SampleType};
use crate::options::{EncodeOptions, Limits};
fn image_format(f: Format) -> Option<ImageFormat> {
@@ -49,9 +49,49 @@ pub(crate) fn decode(f: Format, bytes: &[u8], limits: &Limits) -> Result<Image,
let mut img = from_dynamic(f, dynimg)?;
img.icc = icc;
img.meta.exif = exif;
if f == Format::Gif {
img.warnings.extend(gif_more_frames(bytes));
}
Ok(img)
}
/// 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> {
// Size of the colour table a packed-fields byte announces.
let table = |flags: u8| if flags & 0x80 != 0 { 3usize << ((flags & 7) + 1) } else { 0 };
// Skips the data sub-blocks starting at `i` (each length-prefixed, ended by a zero length).
let skip = |mut i: usize| -> Option<usize> {
loop {
let n = usize::from(*b.get(i)?);
i = i.checked_add(1 + n)?;
if n == 0 {
return Some(i);
}
}
};
// Header (6 bytes), logical screen descriptor (7) and the global colour table.
let mut i = 13 + table(*b.get(10)?);
let mut frames = 0u32;
let total = loop {
let next = match b.get(i) {
Some(0x3B) => break Some(frames),
Some(0x21) => skip(i + 2),
Some(0x2C) => {
frames = frames.saturating_add(1);
// Descriptor (10 bytes, packed fields last), local colour table, LZW code size.
b.get(i + 9).and_then(|&flags| skip(i + 11 + table(flags)))
}
_ => None,
};
match next {
Some(n) => i = n,
None => break None,
}
};
(frames > 1).then_some(DecodeWarning::MoreFrames { total })
}
pub(crate) fn from_dynamic(f: Format, d: DynamicImage) -> Result<Image, CodecError> {
let (w, h) = (d.width(), d.height());
Ok(match d {
+7 -3
View File
@@ -6,7 +6,7 @@ use std::io::Cursor;
use crate::Format;
use crate::error::CodecError;
use crate::fidelity::Plan;
use crate::image::{ChannelLayout, Image, Metadata, SampleType};
use crate::image::{ChannelLayout, DecodeWarning, Image, Metadata, SampleType};
use crate::options::{EncodeOptions, Limits};
const F: Format = Format::WebP;
@@ -30,6 +30,9 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
let mut img = Image::from_u8(w, h, layout, buf)?;
img.icc = icc;
img.meta = Metadata { exif, xmp, ..Default::default() };
if dec.is_animated() && dec.num_frames() > 1 {
img.warnings.push(DecodeWarning::MoreFrames { total: Some(dec.num_frames()) });
}
Ok(img)
}
@@ -54,10 +57,11 @@ pub(crate) fn encode(src: &Image, plan: Plan, opts: &EncodeOptions) -> Result<Ve
}
if opts.embed_metadata {
if let Some(exif) = &img.meta.exif {
enc.set_exif_metadata(exif.clone());
// The pixels are written as they are shown: never let a viewer rotate them again.
enc.set_exif_metadata(crate::orientation::upright_exif(exif).into_owned());
}
if let Some(xmp) = &img.meta.xmp {
enc.set_xmp_metadata(xmp.as_bytes().to_vec());
enc.set_xmp_metadata(crate::orientation::upright_xmp(xmp).as_bytes().to_vec());
}
}
enc.encode(img.data(), img.width(), img.height(), ct).map_err(|e| CodecError::encode(F, e))?;
+15
View File
@@ -41,6 +41,12 @@ pub enum FidelityWarning {
XmpDropped,
DpiDropped,
TextDropped,
/// Metadata larger than the format can hold in one piece (`what`: "EXIF", "XMP") will be
/// dropped.
MetadataTooLarge {
what: &'static str,
bytes: usize,
},
/// Image exceeds the format's maximum size; encoding will fail.
DimensionsExceeded {
max_width: u32,
@@ -86,6 +92,7 @@ impl fmt::Display for FidelityWarning {
XmpDropped => write!(f, "XMP not supported; it will be dropped"),
DpiDropped => write!(f, "resolution (DPI) not supported; it will be dropped"),
TextDropped => write!(f, "text metadata not supported; it will be dropped"),
MetadataTooLarge { what, bytes } => write!(f, "{what} ({bytes} bytes) is too large for the format; it will be dropped"),
DimensionsExceeded { max_width, max_height } => {
write!(f, "image exceeds the format maximum of {max_width}x{max_height}")
}
@@ -219,6 +226,14 @@ pub fn fidelity_warnings_with(image: &Image, format: Format, opts: &EncodeOption
if m.xmp.is_some() && !c.xmp {
w.push(W::XmpDropped);
}
if format == Format::Jpeg {
if let Some(exif) = m.exif.as_deref().filter(|e| crate::codecs::jpeg::exif_segment(e).is_none()) {
w.push(W::MetadataTooLarge { what: "EXIF", bytes: exif.len() });
}
if let Some(xmp) = m.xmp.as_deref().filter(|x| crate::codecs::jpeg::xmp_segment(x).is_none()) {
w.push(W::MetadataTooLarge { what: "XMP", bytes: xmp.len() });
}
}
if m.dpi.is_some() && !c.dpi {
w.push(W::DpiDropped);
}
+45 -11
View File
@@ -21,6 +21,8 @@ pub enum Format {
Hdr,
/// Encode-only behind the `avif` feature; see [`ASYMMETRIC_EXCEPTIONS`].
Avif,
/// HEIF/HEIC (HEVC-coded): read-only; see [`ASYMMETRIC_EXCEPTIONS`].
Heif,
}
/// What a format can hold **and** what this crate reads/writes for it.
@@ -51,11 +53,20 @@ pub struct FormatCaps {
/// Formats that are enabled but intentionally not symmetric, with the reason.
/// The test-suite asserts that every other enabled format is read+write.
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[(
Format::Avif,
"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.",
)];
pub const ASYMMETRIC_EXCEPTIONS: &[(Format, &str)] = &[
(
Format::Avif,
"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::Heif,
"HEIC decode uses heic-rs (pure Rust, in the optional photocraft-heif crate behind the \
non-default `heif` feature, which official builds enable), so iPhone and Mac photos open; \
writing needs an HEVC encoder, and the mature ones (x265, libheif) are C, so write stays \
unsupported. Without the feature HEIF is detected but neither read nor written.",
),
];
use ChannelLayout as L;
use SampleType as S;
@@ -65,7 +76,7 @@ const ALL_LAYOUTS: &[ChannelLayout] = &[L::Gray, L::GrayA, L::Rgb, L::Rgba, L::C
impl Format {
/// Every format known to the crate (enabled or not).
pub const ALL: [Format; 13] = [
pub const ALL: [Format; 14] = [
Format::Png,
Format::Jpeg,
Format::Tiff,
@@ -79,6 +90,7 @@ impl Format {
Format::OpenExr,
Format::Hdr,
Format::Avif,
Format::Heif,
];
pub fn caps(self) -> FormatCaps {
@@ -106,6 +118,7 @@ impl Format {
Format::OpenExr => "OpenEXR",
Format::Hdr => "Radiance HDR",
Format::Avif => "AVIF",
Format::Heif => "HEIF",
}
}
@@ -126,6 +139,7 @@ impl Format {
Format::OpenExr => &["exr"],
Format::Hdr => &["hdr"],
Format::Avif => &["avif"],
Format::Heif => &["heic", "heif", "hif"],
}
}
@@ -144,6 +158,7 @@ impl Format {
Format::OpenExr => "image/x-exr",
Format::Hdr => "image/vnd.radiance",
Format::Avif => "image/avif",
Format::Heif => "image/heif",
}
}
@@ -190,6 +205,9 @@ pub fn caps(format: Format) -> FormatCaps {
Format::OpenExr => FormatCaps { depths: &[S::F16, S::F32], layouts: RGB_GRAY, ..base },
Format::Hdr => FormatCaps { depths: &[S::F32], layouts: &[L::Rgb], alpha: false, lossy: true, ..base },
Format::Avif => FormatCaps { read: false, write: cfg!(feature = "avif"), lossy: true, ..base },
Format::Heif => {
FormatCaps { read: cfg!(feature = "heif"), write: false, depths: &[S::U8, S::U16], icc: true, exif: true, xmp: true, lossy: true, ..base }
}
}
}
@@ -221,8 +239,13 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
if b.starts_with(b"#?RADIANCE") || b.starts_with(b"#?RGBE") {
return Some(Format::Hdr);
}
if b.len() >= 12 && &b[4..8] == b"ftyp" && is_avif_ftyp(b) {
return Some(Format::Avif);
if b.len() >= 12 && &b[4..8] == b"ftyp" {
if is_avif_ftyp(b) {
return Some(Format::Avif);
}
if is_heif_ftyp(b) {
return Some(Format::Heif);
}
}
if b.len() >= 14 && b.starts_with(b"BM") {
return Some(Format::Bmp);
@@ -239,11 +262,22 @@ pub fn detect(bytes: &[u8]) -> Option<Format> {
None
}
fn is_avif_ftyp(b: &[u8]) -> bool {
/// The `ftyp` brands: the major brand, then the compatible ones (the minor version between them is skipped).
fn ftyp_brands(b: &[u8]) -> impl Iterator<Item = &[u8; 4]> {
let size = u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize;
let end = size.clamp(12, b.len().min(64));
let brands = &b[8..end];
brands.as_chunks::<4>().0.iter().enumerate().any(|(i, c)| i != 1 && (c == b"avif" || c == b"avis"))
b[8..end].as_chunks::<4>().0.iter().enumerate().filter(|(i, _)| *i != 1).map(|(_, c)| c)
}
fn is_avif_ftyp(b: &[u8]) -> bool {
ftyp_brands(b).any(|c| c == b"avif" || c == b"avis")
}
/// HEVC-coded HEIF (`heic`, `heix`, ...) or a generic HEIF file (`mif1`, `msf1`, `miaf`) that is
/// not AVIF (checked first). Sequence-only files (`hevc`, `msf1`) are recognised so that opening
/// one says what is unsupported instead of "unrecognized format".
fn is_heif_ftyp(b: &[u8]) -> bool {
ftyp_brands(b).any(|c| matches!(c, b"heic" | b"heix" | b"heim" | b"heis" | b"hevc" | b"hevx" | b"mif1" | b"msf1" | b"miaf"))
}
fn looks_like_tga(b: &[u8]) -> bool {
+102 -3
View File
@@ -4,7 +4,10 @@
//! in **native endianness**. Integer samples are unsigned and normalized to
//! `[0, max]`; float samples are nominally `[0, 1]` but may exceed it (HDR).
use std::fmt;
use crate::error::CodecError;
use crate::format::Format;
use half::f16;
/// Channel layout of an [`Image`]. Alpha, when present, is always the last
@@ -118,11 +121,103 @@ pub struct Metadata {
pub dpi: Option<(f32, f32)>,
/// Free-form key/value text (PNG tEXt/iTXt, TIFF ASCII tags).
pub text: Vec<(String, String)>,
/// Photoshop image resources (`8BIM` resource blocks): TIFF tag 34377. Kept opaque here;
/// `photocraft-io` reads and writes them through `photocraft-psd`.
pub photoshop_resources: Option<Vec<u8>>,
/// Photoshop layer data (TIFF tag 37724 `ImageSourceData`): a layered TIFF's layers. Kept
/// opaque here; `photocraft-io` reads and writes it through `photocraft-psd`.
pub photoshop_layers: Option<Vec<u8>>,
}
impl Metadata {
pub fn is_empty(&self) -> bool {
self.exif.is_none() && self.xmp.is_none() && self.dpi.is_none() && self.text.is_empty()
self.exif.is_none()
&& self.xmp.is_none()
&& self.dpi.is_none()
&& self.text.is_empty()
&& self.photoshop_resources.is_none()
&& self.photoshop_layers.is_none()
}
}
/// Something a decoder noticed about the file that the decoded pixels don't show: the file
/// held more than was decoded, or less than it should.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecodeWarning {
/// Only the first frame of an animation was decoded; `total` counts every frame when known.
MoreFrames { total: Option<u32> },
/// Only the first page of a multi-page file was decoded; `total` counts every page when known.
MorePages { total: Option<u32> },
/// The image data ends early (truncated or damaged file); the decoder filled in the rest
/// (a baseline JPEG's missing rows come out grey).
Truncated { format: Format },
/// Deep image data (per-pixel sample lists) was composited into the flat image; the samples
/// themselves (Z and other deep channels) are not part of the flat result.
DeepFlattened { format: Format, max_samples_per_pixel: u32 },
}
impl fmt::Display for DecodeWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DecodeWarning::MoreFrames { total: Some(n) } => write!(f, "only the first of {n} frames was imported"),
DecodeWarning::MoreFrames { total: None } => write!(f, "only the first frame of the animation was imported"),
DecodeWarning::MorePages { total: Some(n) } => write!(f, "only the first of {n} pages was imported"),
DecodeWarning::MorePages { total: None } => write!(f, "only the first page of the file was imported"),
DecodeWarning::Truncated { format } => {
write!(f, "{} data ends early (the file is truncated or damaged); part of the image is missing", format.name())
}
DecodeWarning::DeepFlattened { format, max_samples_per_pixel } => write!(
f,
"{format} deep data was composited into a flat image (up to {max_samples_per_pixel} samples per pixel); per-pixel depth is not kept",
format = format.name()
),
}
}
}
/// One channel of a [`DeepImage`]: the samples of every pixel, one flat list in pixel order.
#[derive(Debug, Clone, PartialEq)]
pub struct DeepChannel {
/// The channel name as in the file (e.g. `R`, `G`, `B`, `A`, `Z`).
pub name: String,
/// The sample type as stored in the file; `samples` always holds the numeric value as
/// f32 (half and 32-bit floats exactly; 32-bit unsigned integers exactly up to 2^24).
pub sample: SampleType,
/// Every sample of the channel, in pixel order; the samples of pixel `i` are
/// `counts[i]..counts[i+1]` (see [`DeepImage::counts`]).
pub samples: Vec<f32>,
}
/// Deep image data (OpenEXR `deepscanline`/`deeptile`): a variable-length list of samples
/// per pixel, each with its own colour, alpha and depth. All channels share the same sample
/// layout, so a sample is one index into every channel.
#[derive(Debug, Clone, PartialEq)]
pub struct DeepImage {
pub width: u32,
pub height: u32,
/// The sample channels in file order.
pub channels: Vec<DeepChannel>,
/// Cumulative sample counts, `len == width as usize * height as usize + 1`: pixel `i`
/// holds the samples `counts[i]..counts[i+1]` of every channel. Monotonically
/// non-decreasing, starting and ending at the total sample count.
pub counts: Vec<u64>,
}
impl DeepImage {
/// A channel by its exact name.
pub fn channel(&self, name: &str) -> Option<&DeepChannel> {
self.channels.iter().find(|c| c.name == name)
}
/// The samples of one pixel, as indices into every channel's `samples`.
pub fn sample_range(&self, pixel: usize) -> std::ops::Range<u64> {
let end = self.counts.get(pixel + 1).copied().unwrap_or(0);
self.counts.get(pixel).copied().unwrap_or(end)..end
}
/// The total number of samples over all pixels.
pub fn total_samples(&self) -> u64 {
self.counts.last().copied().unwrap_or(0)
}
}
@@ -137,6 +232,9 @@ pub struct Image {
/// Embedded ICC profile (must describe `layout`'s colour model).
pub icc: Option<Vec<u8>>,
pub meta: Metadata,
/// What the decoder noticed about the file: empty for a complete, single image and for
/// images built in memory. Encoders ignore it.
pub warnings: Vec<DecodeWarning>,
}
fn byte_len(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Option<usize> {
@@ -147,7 +245,7 @@ impl Image {
/// A zero-filled image, or an error if the byte size overflows `usize`.
pub fn new(width: u32, height: u32, layout: ChannelLayout, sample: SampleType) -> Result<Self, CodecError> {
let len = byte_len(width, height, layout, sample).ok_or_else(|| CodecError::InvalidImage("image size overflows usize".into()))?;
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default() })
Ok(Image { width, height, layout, sample, data: vec![0; len], icc: None, meta: Metadata::default(), warnings: Vec::new() })
}
/// Wrap raw interleaved, native-endian bytes.
@@ -156,7 +254,7 @@ impl Image {
if data.len() != expected {
return Err(CodecError::InvalidImage(format!("buffer has {} bytes, expected {expected} for {width}x{height} {layout:?} {sample:?}", data.len())));
}
Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default() })
Ok(Image { width, height, layout, sample, data, icc: None, meta: Metadata::default(), warnings: Vec::new() })
}
pub fn from_u8(width: u32, height: u32, layout: ChannelLayout, data: Vec<u8>) -> Result<Self, CodecError> {
@@ -324,6 +422,7 @@ impl Image {
data,
icc: if layout.same_model(self.layout) { self.icc.clone() } else { None },
meta: self.meta.clone(),
warnings: self.warnings.clone(),
}
}
+36 -4
View File
@@ -19,17 +19,20 @@ mod fidelity;
mod format;
mod image;
mod options;
pub mod orientation;
pub mod web;
pub use crate::codecs::png::encode_indexed as encode_png_indexed;
pub use crate::codecs::tiff::{PhotoshopTags as TiffPhotoshopTags, photoshop_tags as tiff_photoshop_tags, writes_little_endian as tiff_writes_little_endian};
pub use crate::error::CodecError;
pub use crate::fidelity::{FidelityWarning, fidelity_warnings, fidelity_warnings_with};
pub use crate::format::{ASYMMETRIC_EXCEPTIONS, Format, FormatCaps, caps, detect, from_extension};
pub use crate::image::{ChannelLayout, Image, Metadata, SampleType};
pub use crate::image::{ChannelLayout, DecodeWarning, DeepChannel, DeepImage, Image, Metadata, SampleType};
pub use crate::options::{DecodeOptions, EncodeOptions, ExrCompression, Limits, PngCompression, TiffCompression};
pub use crate::orientation::{exif_orientation, upright_exif, upright_xmp};
pub use half::f16;
use crate::codecs::{exr, jpeg, png, pnm, tiff, via_image, webp};
use crate::codecs::{exr, heif, jpeg, png, pnm, tiff, via_image, webp};
/// Detect the format and decode with default [`Limits`].
pub fn decode(bytes: &[u8]) -> Result<Image, CodecError> {
@@ -49,7 +52,8 @@ pub fn decode_as(format: Format, bytes: &[u8]) -> Result<Image, CodecError> {
/// Decode as a specific format.
pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Result<Image, CodecError> {
if !caps(format).read {
// HEIF without the `heif` feature: its decoder stub says what is missing from this build.
if !caps(format).read && format != Format::Heif {
return Err(CodecError::unsupported(format, "decoding is not available for this format"));
}
let l = &opts.limits;
@@ -60,11 +64,38 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res
Format::WebP => webp::decode(bytes, l),
Format::Pnm => pnm::decode(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),
}?;
// Final guard for decoders whose header we could not pre-inspect.
l.check(img.width(), img.height(), img.layout(), img.sample_type())?;
Ok(img)
if opts.keep_orientation {
return Ok(img);
}
// Turn the pixels upright, like Photoshop: a TIFF records it in its own IFD0, the others
// in their EXIF block. The metadata is rewritten to Orientation = 1 on the way. HEIF keeps it
// in its container, and its decoder has already applied it.
let o = match format {
Format::Tiff => exif_orientation(bytes),
Format::Heif => 1,
_ => img.meta.exif.as_deref().map_or(1, exif_orientation),
};
// Orientations 5–8 swap width and height: with asymmetric limits a stored landscape
// that fit can turn into a portrait that does not, so check the upright size too.
if (5..=8).contains(&o) {
l.check(img.height(), img.width(), img.layout(), img.sample_type())?;
}
img.oriented(o)
}
/// Deep OpenEXR samples (`deepscanline`/`deeptile`): the structured per-pixel sample lists
/// (colour, alpha and Z per sample), for callers that composite deep data themselves —
/// [`decode_as_with`] instead flattens a deep file into a normal [`Image`] with a
/// [`DecodeWarning::DeepFlattened`]. `Err(Unsupported)` when the file has no deep part.
pub fn decode_deep_exr(bytes: &[u8], limits: &Limits) -> Result<DeepImage, CodecError> {
let mut cur = std::io::Cursor::new(bytes);
let meta = ::exr::meta::MetaData::read_from_buffered(&mut cur, false).map_err(|e| CodecError::malformed(Format::OpenExr, e))?;
codecs::deep_exr::decode_deep(&meta, bytes, limits)
}
/// Encode `image` as `format`. Conversions follow the same plan that
@@ -90,6 +121,7 @@ pub fn encode(image: &Image, format: Format, opts: &EncodeOptions) -> Result<Vec
Format::WebP => webp::encode(image, plan, opts),
Format::Pnm => pnm::encode(image, plan, opts),
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),
}
}
+4
View File
@@ -67,6 +67,10 @@ impl Limits {
#[derive(Debug, Clone, Default)]
pub struct DecodeOptions {
pub limits: Limits,
/// Keep the pixels as stored instead of applying the EXIF / TIFF
/// orientation (by default they are turned upright and the metadata's
/// orientation is rewritten to 1).
pub keep_orientation: bool,
}
/// PNG zlib effort.
+363
View File
@@ -0,0 +1,363 @@
//! EXIF / TIFF orientation (tag 274): reading it, applying it to the pixels,
//! and rewriting it to 1 ("top-left", upright) once the pixels are upright.
//!
//! Decoders apply the orientation by default (like Photoshop and every photo
//! viewer), so a document is always edited upright. Encoders then write
//! Orientation = 1, so a viewer never rotates the already-upright pixels a
//! second time.
//!
//! Orientation values (TIFF 6.0, EXIF 2.3): 1 = upright, 2 = mirrored, 3 =
//! rotated 180°, 4 = flipped vertically, 5 = transposed, 6 = needs a 90°
//! clockwise turn, 7 = transverse, 8 = needs a 90° counter-clockwise turn.
//! Anything malformed, truncated or out of range reads as 1: an IFD0 that
//! starts inside the 8-byte header or is cut off before its next-IFD pointer,
//! or an Orientation entry that is not exactly one SHORT or LONG (the spec says
//! SHORT, but some cameras and tools write LONG). When IFD0 holds
//! several Orientation entries the first one wins (like libexif).
use std::borrow::Cow;
use crate::error::CodecError;
use crate::image::Image;
/// The TIFF/EXIF Orientation tag.
const TAG_ORIENTATION: u16 = 274;
/// Field types accepted for the Orientation tag: SHORT (the spec) and LONG
/// (written by some cameras and tools).
const TYPE_SHORT: u16 = 3;
const TYPE_LONG: u16 = 4;
/// Bytes per IFD entry.
const ENTRY: usize = 12;
/// The TIFF header is 8 bytes, so no IFD can start before it.
const HEADER: usize = 8;
/// BigTIFF uses a 16-byte header, 20-byte entries, and 8-byte offsets.
const BIG_ENTRY: usize = 20;
const BIG_HEADER: usize = 16;
/// Byte order of a TIFF structure.
#[derive(Clone, Copy)]
enum Order {
Little,
Big,
}
impl Order {
fn u16(self, b: &[u8], at: usize) -> Option<u16> {
let s: [u8; 2] = b.get(at..at.checked_add(2)?)?.try_into().ok()?;
Some(match self {
Order::Little => u16::from_le_bytes(s),
Order::Big => u16::from_be_bytes(s),
})
}
fn u32(self, b: &[u8], at: usize) -> Option<u32> {
let s: [u8; 4] = b.get(at..at.checked_add(4)?)?.try_into().ok()?;
Some(match self {
Order::Little => u32::from_le_bytes(s),
Order::Big => u32::from_be_bytes(s),
})
}
fn u64(self, b: &[u8], at: usize) -> Option<u64> {
let s: [u8; 8] = b.get(at..at.checked_add(8)?)?.try_into().ok()?;
Some(match self {
Order::Little => u64::from_le_bytes(s),
Order::Big => u64::from_be_bytes(s),
})
}
}
/// The TIFF payload of an EXIF block (a JPEG-style `Exif\0\0` prefix is skipped).
fn tiff_body(b: &[u8]) -> &[u8] {
b.strip_prefix(b"Exif\0\0").unwrap_or(b)
}
/// IFD0 of a TIFF structure: byte order, entry layout, and entry offsets.
///
/// The full table and next-IFD pointer must be present, and IFD0 must start
/// after the corresponding TIFF or BigTIFF header.
fn ifd0_entries(b: &[u8]) -> Option<(Order, usize, usize, usize, bool)> {
let (order, big_tiff) = match b.get(0..4)? {
[b'I', b'I', 42, 0] => (Order::Little, false),
[b'M', b'M', 0, 42] => (Order::Big, false),
[b'I', b'I', 43, 0] => (Order::Little, true),
[b'M', b'M', 0, 43] => (Order::Big, true),
_ => return None,
};
let (ifd, count_bytes, entry_bytes, next_ifd_bytes, header) = if big_tiff {
if order.u16(b, 4)? != 8 || order.u16(b, 6)? != 0 {
return None;
}
(usize::try_from(order.u64(b, 8)?).ok()?, 8usize, BIG_ENTRY, 8usize, BIG_HEADER)
} else {
(usize::try_from(order.u32(b, 4)?).ok()?, 2usize, ENTRY, 4usize, HEADER)
};
if ifd < header {
return None;
}
let count = if big_tiff { usize::try_from(order.u64(b, ifd)?).ok()? } else { usize::from(order.u16(b, ifd)?) };
let first = ifd.checked_add(count_bytes)?;
let next_ifd = first.checked_add(count.checked_mul(entry_bytes)?)?;
if next_ifd.checked_add(next_ifd_bytes)? > b.len() {
return None;
}
// In bounds: every entry ends at or before `next_ifd`.
Some((order, first, count, entry_bytes, big_tiff))
}
/// A well-formed Orientation entry's value: where it sits and its field type.
#[derive(Clone, Copy)]
struct Value {
at: usize,
ty: u16,
}
impl Value {
/// The stored value (a SHORT or LONG, left-justified in the 4-byte field).
fn read(self, order: Order, b: &[u8]) -> Option<u32> {
match self.ty {
TYPE_SHORT => order.u16(b, self.at).map(u32::from),
_ => order.u32(b, self.at),
}
}
/// 1 in this entry's own type, width and byte order.
fn one(self, order: Order) -> Vec<u8> {
match (self.ty, order) {
(TYPE_SHORT, Order::Little) => 1u16.to_le_bytes().to_vec(),
(TYPE_SHORT, Order::Big) => 1u16.to_be_bytes().to_vec(),
(_, Order::Little) => 1u32.to_le_bytes().to_vec(),
(_, Order::Big) => 1u32.to_be_bytes().to_vec(),
}
}
}
/// The value of the entry at `e` if it is a well-formed Orientation entry:
/// exactly one SHORT or LONG.
fn orientation_value(order: Order, b: &[u8], e: usize, big_tiff: bool) -> Option<Value> {
let count = if big_tiff { order.u64(b, e.checked_add(4)?)? } else { u64::from(order.u32(b, e.checked_add(4)?)?) };
if order.u16(b, e)? != TAG_ORIENTATION || count != 1 {
return None;
}
let ty = order.u16(b, e.checked_add(2)?)?;
let value_offset = if big_tiff { 12 } else { 8 };
matches!(ty, TYPE_SHORT | TYPE_LONG).then_some(Value { at: e.checked_add(value_offset)?, ty })
}
/// Locates the Orientation value in IFD0.
///
/// The **first** Orientation entry wins (like libexif and Photoshop); if that
/// one is malformed (count ≠ 1, or neither SHORT nor LONG) the tag is ignored,
/// even when a later duplicate is well formed.
fn find_entry(b: &[u8]) -> Option<(Order, Value)> {
let (order, first, count, entry_bytes, big_tiff) = ifd0_entries(b)?;
let e = (0..count).filter_map(|i| first.checked_add(i.checked_mul(entry_bytes)?)).find(|&e| order.u16(b, e) == Some(TAG_ORIENTATION))?;
Some((order, orientation_value(order, b, e, big_tiff)?))
}
/// The orientation (1–8) recorded in a TIFF-structured block: an EXIF payload
/// (with or without the `Exif\0\0` prefix) or a whole TIFF file. Missing,
/// malformed or out-of-range values give 1.
pub fn exif_orientation(exif: &[u8]) -> u16 {
let b = tiff_body(exif);
find_entry(b).and_then(|(order, v)| v.read(order, b)).and_then(|v| u16::try_from(v).ok()).filter(|v| (1..=8).contains(v)).unwrap_or(1)
}
/// `exif` with its Orientation rewritten to 1, borrowed when there is nothing
/// to change (no well-formed tag, already 1, or unparseable).
///
/// Only the value bytes of each well-formed Orientation entry change, written
/// as 1 in the entry's existing type and width (SHORT or LONG); every other
/// byte (other tags, their offset-based values, sub-IFDs) is kept as is.
/// Well-formed duplicates are all set to 1, so a reader that lets a later
/// duplicate win cannot rotate the upright pixels either.
pub fn upright_exif(exif: &[u8]) -> Cow<'_, [u8]> {
let body = tiff_body(exif);
let prefix = exif.len() - body.len();
let Some((order, first, count, entry_bytes, big_tiff)) = ifd0_entries(body) else {
return Cow::Borrowed(exif);
};
let values: Vec<Value> =
(0..count).filter_map(|i| first.checked_add(i.checked_mul(entry_bytes)?)).filter_map(|e| orientation_value(order, body, e, big_tiff)).collect();
if values.iter().all(|v| v.read(order, body) == Some(1)) {
return Cow::Borrowed(exif);
}
let mut out = exif.to_vec();
for v in values {
let one = v.one(order);
let slot = prefix.checked_add(v.at).and_then(|at| out.get_mut(at..at.checked_add(one.len())?));
if let Some(slot) = slot {
slot.copy_from_slice(&one);
}
}
Cow::Owned(out)
}
/// `xmp` with any `tiff:Orientation` (attribute or element form) set to 1,
/// borrowed when there is nothing to change.
pub fn upright_xmp(xmp: &str) -> Cow<'_, str> {
const KEY: &str = "tiff:Orientation";
if !xmp.contains(KEY) {
return Cow::Borrowed(xmp);
}
let mut out = String::with_capacity(xmp.len());
let mut rest = xmp;
let mut changed = false;
while let Some(i) = rest.find(KEY) {
let (head, tail) = rest.split_at(i + KEY.len());
out.push_str(head);
rest = tail;
// `tiff:Orientation="6"`, `tiff:Orientation='6'` or `<tiff:Orientation>6<`.
let open = rest.char_indices().take_while(|&(_, c)| c.is_ascii_whitespace() || matches!(c, '=' | '"' | '\'' | '>')).last();
let Some((at, c)) = open else { continue };
if !matches!(c, '"' | '\'' | '>') {
continue;
}
let start = at + 1;
let digits = rest.get(start..).map_or(0, |s| s.bytes().take_while(u8::is_ascii_digit).count());
if digits == 0 || rest.get(start..start + digits) == Some("1") {
continue;
}
out.push_str(rest.get(..start).unwrap_or_default());
out.push('1');
rest = rest.get(start + digits..).unwrap_or_default();
changed = true;
}
out.push_str(rest);
if changed { Cow::Owned(out) } else { Cow::Borrowed(xmp) }
}
/// Source pixel index for output pixel `(0, y2)` and the step per output column, for
/// orientation `o` of a `w`×`h` source (values 2–8; see the module docs).
fn walk(o: u16, w: isize, h: isize, y2: isize) -> (isize, isize) {
match o {
2 => (y2 * w + w - 1, -1),
3 => ((h - 1 - y2) * w + w - 1, -1),
4 => ((h - 1 - y2) * w, 1),
5 => (y2, w),
6 => ((h - 1) * w + y2, -w),
7 => ((h - 1) * w + (w - 1 - y2), -w),
8 => (w - 1 - y2, w),
_ => (y2 * w, 1),
}
}
/// Output columns per block: a block's source pixels (for a 90° turn, `BLOCK`
/// rows × the band height) stay in cache while it is written.
const BLOCK: usize = 64;
/// Output rows per parallel band.
const BAND: usize = 64;
/// Writes the oriented pixels of one band of output rows, `N` bytes per pixel.
fn band<const N: usize>(src: &[[u8; N]], dst: &mut [[u8; N]], o: u16, (w, h): (usize, usize), ow: usize, y0: usize) {
let (wi, hi) = (w as isize, h as isize);
let rows = dst.len() / ow.max(1);
for x0 in (0..ow).step_by(BLOCK) {
let x1 = (x0 + BLOCK).min(ow);
for r in 0..rows {
let (base, step) = walk(o, wi, hi, (y0 + r) as isize);
let Some(out) = dst.get_mut(r * ow + x0..r * ow + x1) else { return };
let mut i = base + x0 as isize * step;
for px in out {
if let Some(s) = usize::try_from(i).ok().and_then(|i| src.get(i)) {
*px = *s;
}
i += step;
}
}
}
}
/// Orients `src` (`N` bytes per pixel) into a new buffer, a band of rows per task.
fn orient_n<const N: usize>(src: &[u8], o: u16, (w, h): (usize, usize), ow: usize) -> Result<Vec<u8>, CodecError> {
let mut out = alloc_zeroed(src.len())?;
let (s, _) = src.as_chunks::<N>();
let (d, _) = out.as_chunks_mut::<N>();
let per = (BAND * ow).max(1);
#[cfg(not(target_arch = "wasm32"))]
{
use rayon::prelude::*;
d.par_chunks_mut(per).enumerate().for_each(|(b, chunk)| band(s, chunk, o, (w, h), ow, b * BAND));
}
#[cfg(target_arch = "wasm32")]
for (b, chunk) in d.chunks_mut(per).enumerate() {
band(s, chunk, o, (w, h), ow, b * BAND);
}
Ok(out)
}
/// A zeroed buffer of `n` bytes, or an error (not an abort) when memory runs out.
fn alloc_zeroed(n: usize) -> Result<Vec<u8>, CodecError> {
let mut out = Vec::new();
out.try_reserve_exact(n).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {n} bytes to orient the image")))?;
out.resize(n, 0);
Ok(out)
}
/// Pixel-format-agnostic orientation: any byte size per pixel.
fn orient_bytes(src: &[u8], bpp: usize, o: u16, (w, h): (usize, usize), ow: usize) -> Result<Vec<u8>, CodecError> {
match bpp {
1 => orient_n::<1>(src, o, (w, h), ow),
2 => orient_n::<2>(src, o, (w, h), ow),
3 => orient_n::<3>(src, o, (w, h), ow),
4 => orient_n::<4>(src, o, (w, h), ow),
5 => orient_n::<5>(src, o, (w, h), ow),
6 => orient_n::<6>(src, o, (w, h), ow),
8 => orient_n::<8>(src, o, (w, h), ow),
10 => orient_n::<10>(src, o, (w, h), ow),
12 => orient_n::<12>(src, o, (w, h), ow),
16 => orient_n::<16>(src, o, (w, h), ow),
_ => orient_n::<20>(src, o, (w, h), ow),
}
}
impl Image {
/// The image turned upright for orientation `o` (1–8; anything else is
/// treated as 1 and returns the image unchanged). Width and height swap
/// for 5–8, as does the DPI, and the EXIF and XMP orientation are
/// rewritten to 1 so the result is not rotated again on display or export.
pub fn oriented(self, o: u16) -> Result<Image, CodecError> {
if !(2..=8).contains(&o) {
return Ok(self);
}
let (w, h) = (self.width() as usize, self.height() as usize);
let swap = o >= 5;
let (ow, oh) = if swap { (h, w) } else { (w, h) };
let (layout, sample) = (self.layout(), self.sample_type());
let bpp = layout.channels() * sample.bytes();
// Every layout/sample pair is 1–20 bytes per pixel and has a fixed-size path.
if !matches!(bpp, 1..=6 | 8 | 10 | 12 | 16 | 20) || w == 0 || h == 0 {
return Err(CodecError::InvalidImage(format!("cannot orient {layout:?} {sample:?} {w}x{h}")));
}
let data = orient_bytes(self.data(), bpp, o, (w, h), ow)?;
let mut meta = self.meta.clone();
if let Some(e) = &mut meta.exif
&& let Cow::Owned(fixed) = upright_exif(e)
{
*e = fixed;
}
if let Some(x) = &mut meta.xmp
&& let Cow::Owned(fixed) = upright_xmp(x)
{
*x = fixed;
}
if swap {
meta.dpi = meta.dpi.map(|(x, y)| (y, x));
}
let mut out = Image::from_raw(ow as u32, oh as u32, layout, sample, data)?.with_icc(self.icc).with_meta(meta);
out.warnings = self.warnings;
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_impossible_allocation_is_an_error_not_an_abort() {
assert!(matches!(alloc_zeroed(usize::MAX), Err(CodecError::LimitExceeded(_))));
assert!(matches!(orient_n::<1>(&[], 6, (0, 0), 0), Ok(v) if v.is_empty()));
assert_eq!(alloc_zeroed(3).unwrap(), vec![0, 0, 0]);
}
}
Loaded 100 of 616 files, more files were not shown because too many files have changed in this diff. Show more