* 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 <noreply@anthropic.com>
* Histogram tests: as_chunks for triangle indices (clippy 1.99)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Regenerate docs/scorecard.md after rebasing onto main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* i18n tests: close the Mixer Brush test before the Camera Raw one (rebase)
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>
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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* PSD FEid: an empty item list still writes one section, so it reads back
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: EthanSuquet <292885762+EthanSuquet@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* Regenerate docs/scorecard.md
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>
* 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 <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
* Fix bit-depth labels and tooltips
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
* Show active ICC profile in status bar (#321)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
* Fix active profile status review feedback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
* 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 <noreply@qwen.ai>
* style(io): fix PSD artboard lock review comments
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
---------
Co-authored-by: Qwen 3.8 Flash <noreply@qwen.ai>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
* 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 <noreply@anthropic.com>
* Regenerate docs/scorecard.md
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>
* 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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
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 <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
* Polish Mixer Brush UI controls
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
* Regenerate docs/scorecard.md
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>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
* 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 <noreply@anthropic.com>
---------
Co-authored-by: JJ <jj@dot.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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 <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>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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
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.
* 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.
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
---------
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Copilot-Session: e5e4c3e6-e406-4010-b05d-5042f9f4e4fc
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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.
* 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 <noreply@anthropic.com>
---------
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* .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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* `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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* Gradients: reword the opacity-merge doc comment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
* 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
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 <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f77709e9-6475-4953-ab57-b78347b4499d
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <contributor@users.noreply.github.com>