filter.cameraRaw deserialized its params verbatim, so an out-of-range
noiseLuminance reached the denoise stage and became a guided-filter
radius of round((1 + 3 * n/100) * pixelScale). A value of 1e9 turned
six box-mean passes into unbounded work: the command never finished.
noiseColor had the same defect through its blur sigma.
raw_params now clamps the four noise fields to the documented 0..100,
matching how the sibling lens commands clamp their ranges at the same
boundary. The in-range path is untouched.
Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
load_actions treats a missing actions.json as an empty list via missing_file(), which
matches the error text ("not found", "no such file", "os error 2"). When the store
directory itself doesn't exist yet, as on a first launch, Windows reports os error 3
("The system cannot find the path specified."), which none of those match. So opening
a fresh store warned "actions.json skipped", and two engine tests failed on Windows
(actions_persist_with_the_preset_store, group_with_sampled_8_and_16_bit_tips_round_trips).
DirBackend::read now maps io::ErrorKind::NotFound (os errors 2 and 3) to the same
"{name}: not found" wording the in-memory backend uses. New test: a store directory
that doesn't exist yet opens with no warnings (fails on Windows before the fix).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Three agent_tasks tests failed on Windows at remove_dir_all with os error 32. The
headless session's AuthorizedWorkspace opens its roots as cap_std::fs::Dir handles,
and the server task that owns it was still alive when the test deleted the folder:
c.cancel() closes the client, but the spawned server drops the session
asynchronously. Linux deletes directories with open handles, Windows doesn't.
connect/connect_headless now return the server task, and shutdown() cancels the
client and awaits the server, so the handles are gone before the folder is removed.
Test-only change.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
#825 made ExportOptions::default() write a flat TIFF (headless saves stay flat
unless they ask for layers). corpus_round_trips_through_layered_tiff exported with
the defaults, so every corpus file came back flat and the round trip failed (670
failures). Test-only change: request tiff_layers: true.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Main's CI was effectively unverified: with cancel-in-progress on every ref, each merge
cancelled the run of the one before. Between 09:56 and 20:34 UTC on 2026-10-07, 80 of 83
push runs on main were cancelled, and a formatting break from two PRs that were each green
alone (#663 + #700) went unnoticed until PRs updated to main started failing.
- ci.yml: cancel-in-progress only for pull_request. Main runs finish; a merge that lands
meanwhile waits and runs next.
- ci.yml: a `platforms` job picks the test matrix. main, manual runs and PRs labelled
`full-ci` get Linux, macOS and Windows. Other PRs get Linux only, unless they touch
code whose behaviour differs by platform (apps/, ui-egui, text, gpu, tablet, heif,
build.rs, Cargo.lock, ci.yml).
- ci.yml: the second Linux test run with craft-fonts covers only the crates that use the
fonts (photocraft-text, photocraft-ui-egui), not the whole workspace again.
- freebsd.yml, windows-arm64.yml: nightly on main instead of on every push and every
Cargo.toml change; PRs touching their own packaging still run them.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Add the Magnetic Lasso tool
A selection border that snaps to edges as the pointer moves, in the
Lasso (L) flyout after the Polygonal Lasso.
- photocraft-algo `magnetic`: live-wire edge tracing (Mortensen &
Barrett 1995): Sobel strength, non-maximum suppression and gradient
direction link costs, a shortest path inside a corridor of the
detection width around the pointer's path, sub-pixel edge centres.
Pixels are read lazily in 128 px blocks.
- Engine: `EdgeSource` (the active pixel layer, else the composite) and
`select.magneticLasso`: rough points in, a border along the edges out;
`trace: false` selects a given outline, which is how the tool commits.
- UI: click to start, move to trace (button up or down), points fasten
by themselves (Frequency), click fastens, Alt-click straight, Alt-drag
freehand, Backspace/Delete removes points, click the first point,
double-click or Enter closes, Esc cancels, [ ] change the width.
Options bar: Width, Contrast, Frequency, tablet pressure. `ui.inspect`
reports the border in progress.
- The Lasso and Polygonal Lasso now apply the options bar's Feather;
`select.lasso` rejects a feather outside 0..1000.
- Icon adapted from Noun Project's "Magnetic Lasso Tool" by Lil' Seal
(CC BY 3.0, licence file and ATTRIBUTION.md row).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* it: translate the Magnetic Lasso strings (Italian landed after this PR was written)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Update mod.rs
* Update system.rs
* Update live_tests.rs
* Update localization.md
* Italian UI translation (it)
* Register Italian fully and bring the catalog up to date
- LANGUAGES grows to 13 entries (the array length was still 12), with the native
name Italiano.
- The system-locale test expects the code `it`, not `it-IT`.
- Italian joins the i18n coverage report, ATTRIBUTION.md and LICENSE-translations.txt.
- Translate the strings main gained since the catalog was written, and fix two
comments copied from the Spanish catalog.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* presets: move the stray BTreeMap import to the top of the module
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <echelon@gmail.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* ⌘⌥⌃-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) <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
`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)
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 <luna161@users.noreply.github.com>
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 <luna161@users.noreply.github.com>
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 <bfenty@users.noreply.github.com>
* 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 <noreply@anthropic.com>
* Camera Raw PSD: use as_chunks for curve pairs (clippy 1.99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <bfenty@users.noreply.github.com>
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 <bfenty@users.noreply.github.com>
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 <bfenty@users.noreply.github.com>
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 <bfenty@users.noreply.github.com>
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.
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.
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 <luna161@users.noreply.github.com>
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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* Korean labels for Relight's Ambient and Warmth (i18n coverage test red on main after #735)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>