PhotoCraft reads GIMP's .xcf (and .xcf.gz) files, read only, through a new
reader in photocraft-io written from the public XCF specification: versions
0 to 26, uncompressed, RLE and zlib tiles, 8/16/32-bit integer and
16/32/64-bit float precisions (linear precisions get a linear profile),
pixel layers at any position, groups by item path with pass-through,
layer masks applied or disabled, opacity, visibility, locks, colour tags,
the 2.10 and legacy blend modes mapped to Photoshop's (modes without an
equivalent open as Normal with a note), the ICC profile, resolution,
guides, the comment, extra channels and the saved selection; indexed
images open as RGB. Text, vector and link layers open as their pixels;
layer effects, paths and floating selections are reported.
Fixtures are written by GIMP 3.2 itself (scripts/xcf_fixtures.py), each
with GIMP's merged rendering as a PNG. In gamma-encoded blend and
composite spaces our composite matches GIMP within 0.5/255; GIMP's
linear-light default, its Clip to backdrop and its clamp-after-alpha
for unbounded modes are documented and measured in docs/xcf.md.
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
- Replace whole-region Vec<f32> read_region/write_region in CutParts::new
with tile-parallel cut_piece + try_fill_surface/try_clear_surface,
sharing interior Arc<Tile> allocations in O(1) and activating the
solid_tile hole-fill fast path on Background and mask targets.
- Cache piece_bounds on CutParts so CutParts::place avoids scanning tile
content bounds on every drag frame.
- Parallelize composite_over across top tiles with Rayon and share fully
opaque top tiles by Arc<Tile> clone in O(1), preserving COW tile
identity for GPU canvas uploads.
- Parallelize Surface::translated across target tiles with Rayon and
prune cleared tiles in-place inside edit_tiles.
- Skip zero-offset floating preview generation on pointer-down in
move_ui::floating_doc.
- Exclude SnapKind::Selection when moving selected pixels with the Move
tool so small initial drags do not snap back to (0, 0).
- Keep navigator_plain active during Move/Brush/Patch drags on a GPU
canvas so navigator_gpu is not evicted into a synchronous full-document
CPU thumbnail composite on the first drag frame.
- Parallelize row normalization in compose::render_reduced_in_bands and
add a direct read_rgba_into fast path for unmasked Normal layers over
an empty backdrop.
#7125 keeps existing type's pixel size across Image Size, so the #6491 test's 12 pt layer, made before the change to 144 ppi, became 6 pt. The test now creates its layer at 144 ppi; its assertions are unchanged.
#7813's performance.gpu_adapter is read from the prefs file before the window opens (gpu_startup::read_gpu_adapter), which the prefs audit's heuristic doesn't see, so it counts as unread.
view_cmds runs before menus.rs, so wrapping these ids replaced main's folder pickers with a text-field dialog and a file picker; file_dialog's render_video test covers the folder route.
With a naming template, Format Same still took the raw extension (IMG_0001_001.dng: unknown file format). HEIC, SVG, Cineon, PCX and XPM open but have no writer, so they save as PSD too; DDS has a writer and keeps its format.
GetAsyncKeyState(VK_RBUTTON) reads the physical button system-wide: with swapped buttons a primary click read as right-down, and a right button held over another window counted too.
Integration: #4816's fix-up made `block_signature(sig, version)` crate-visible for the
layerless Indexed/Bitmap writer (`multichannel_map::layerless_file`); #7948 adds the block
key so PSB long-length blocks get `8B64`. Kept #7948's function as `pub(crate)` and pass
the key from the layerless writer too, so an Indexed or Bitmap PSB gets the same fix.
Saving an Indexed Color document in its own PSD mode lost data the RGB
file used to keep (the five open CodeRabbit findings, all reproduced):
- an opaque pixel whose colour matched the transparent entry (opaque
black next to a black transparent entry, common in PNG-8 files) was
written as that entry, so it turned transparent; a table whose only
entry is the transparent one now keeps the RGB file;
- PhotoCraft reopened the file with resource 1047 on the table only, so
transparent pixels came back opaque (Photoshop stores transparent
pixels of an Indexed file as the 1047 entry, checked on a GIF it
opened and saved as PSD);
- the ICC profile was dropped (Photoshop writes 1039 for Indexed); only
an RGB profile is written, as it describes the table;
- alpha channels and the Quick Mask were dropped without a warning; the
importer doesn't read extra channels of these modes back, so such
documents keep the RGB / Grayscale file;
- without a transparent entry, empty areas became the nearest colour to
transparent black instead of white;
- a PSB-sourced 8B64 global block stayed 8B64 in a PSD (Multichannel
too), which Photoshop refuses.
Tests: one per point in psd_structure.rs; each fails without this commit.
Co-authored-by: Gert Pellin <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With WGPU_BACKEND set, a device or compositor that could not be created still skipped the
tests (`.ok()?`); like a missing adapter, it now fails them. Without WGPU_BACKEND the tests
skip as before.
Claude-Session: https://claude.ai/code/session_01Aab8aPCQgoN1sYrWPk8Kr6
The base run comes from the file and is cloned once per character run, so
it now has to fit the template node budget, and each font is looked up
once per name. Also names the reopened-layer tuple in psd_kerning for
clippy::type_complexity.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The reader takes a `quiltWarp` of at most 64 patches along a side, but the
writer wrote any split warp and framed `Trnf` by its control box. A Warp
Grid of 64 plus one more split (65 patches) was refused on reopen while its
corners still framed the warped box, so the layer came back stretched and
shifted ([2,0,0,2,120,210] -> [2.4,0,0,2.4,100,200]). Such a warp is now
written as no warp, with a message, framed by the source rect, as before
#6913, and a stale imported `quiltWarp` is dropped.
Co-authored-by: Thang Nguyen <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Photoshop reads the character style from Txt2 and quit on the bare run
(`/0 0 /11 mode`) that new objects got. New and edited objects now get one
style run per character run, built on one of the object's own runs with
every key kept, and font, size, faux styles, leading, scales, tracking,
fill colour, OpenType keys and auto-kern mode set from the layer. A font
the block's font list lacks is appended to it. Edited objects therefore no
longer keep the old text's features by position.
build_tysh no longer writes TextIndex 0 for a layer without one: the
export took it as a kept number, so a Type tool layer landed on object 0
(a deleted layer's, or the one of a layer above it). set_text_index puts a
missing TextIndex right before EngineData, where Photoshop writes it.
pad_frames pads to at most MAX_TEXT_INDEX + 1 frames.
edited_text_keeps_the_files_txt2_object (#7220) now expects style runs
that follow the layer's runs and size.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
`batch_runs` cut a run at the pass where the render captured its prefix checkpoint, so a cached
render grouped plain layers differently from an uncached one. On D3D12 with FXC a layer's bits
batched and drawn alone differ by about 1 ULP, so reuse stopped being exact there (#5286):
prefix_follows_an_edit_up_the_stack_and_stays_exact and
chunks_keep_their_own_checkpoint_after_a_partial_refresh failed on an RTX 5070 Ti.
The runs now depend on the plan alone and are computed once per render. `run_starts` marks the
passes a run starts at, and the prefix cache captures and resumes only at checkpoints there, so a
resumed render draws the passes above in the same runs as an uncached one. It may resume up to
three layers lower than before, one more batched pass.
The cache tests give their layers a reveal-all mask so each draws alone and keeps a checkpoint;
prefix_follows also runs on batched layers and checks that a capturing render batches like an
uncached one, which fails on the old executor on any backend.
Claude-Session: https://claude.ai/code/session_01Aab8aPCQgoN1sYrWPk8Kr6
wgpu's Instance::default() ignores WGPU_BACKEND and WGPU_DX12_COMPILER, so the parity tests
always took the default adapter: Vulkan on a Windows machine that has a driver for it, which hid a
D3D12-only difference. They now read both, and a backend asked for that has no adapter or can't
render Rgba32Float fails the tests instead of skipping them.
Claude-Session: https://claude.ai/code/session_01Aab8aPCQgoN1sYrWPk8Kr6
DDS textures can now be saved, not just opened: uncompressed (BGRA, BGRX, L8, A8L8; lossless) or BC1/BC2/BC3 (DXT1/3/5) from a clean-room block encoder, with an optional box-filtered mip chain, in the legacy header. Save As and Save a Copy ask with DDS Options; scripts pass `ddsCompression`/`ddsMipmaps` (file commands, control `app.save`, RPC `doc.save`, MCP `doc_save`/`doc_export`), and the CLI takes `--dds-compression`/`--dds-mipmaps`. Targa and DDS answers travel together as `FormatChoices` through the Save As flow.
Review follow-ups:
- File › Save behind DDS or Targa Options records a plain Save with its answer, and replaying `file.save` applies it (#3531).
- The block encoder finds the principal axis from every channel, so a channel that varies on its own can't hide the others' variation.
- DDS joins the shared round-trip suite; the header is built before the output buffer is allocated.
- Enter on a focused Cancel in DDS, Targa or TIFF Options cancels instead of saving.
Main made the Properties Feather slider's track cubic (#6727), so a
1% drag no longer reaches 10 px; drag a fifth of the way (8 px) as
main's own mask test does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].
Co-authored-by: Claude Opus 5.5 <[email protected]>
Reading `quiltWarp` left the save path behind, so two warps didn't survive
save and reopen:
- A split warp was written as `warpNone` while `Trnf` was the image of its
control-point box, so the layer came back stretched and shifted.
- A template's `quiltWarp` was kept when `warp` was rewritten, and reading
prefers it, so a warp changed to Arc, to none or to another mesh came back
as the imported split.
`SoLd` now writes a split warp as Photoshop does: a `quiltWarp`
(`deformNumCols` x `deformNumRows` control points, the patch boundaries in
`quiltSliceX` / `quiltSliceY`) before a `warpNone` `warp`. An imported
`quiltWarp` stays as stored while the layer's warp is unchanged, keeps its
slices (Photoshop's units and padding) when the mesh is edited on the same
grid, and is rewritten or dropped otherwise. `PlLd`, which has no split
warp, stores none, with its corners framed by the source rect.
A new object kept the template's style runs, and PhotoCraft's reader takes
font features from Txt2, so a new type layer came back with the template
layer's features (salt, ss01). Its style runs are now generated like the
stub's; only the template's structure is copied.
New type layers were numbered past the kept TextIndex values only, so after
deleting the layer with the highest index a new layer landed on its object
and took its style. They are now numbered past the file's Txt2 objects too.
txt2_extras_survive_a_resave_of_unchanged_text now gives its layer the TySh
index a file's layer has; without one the layer is new and no longer lands
on object 0.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
On wasm32 a near-1 GiB .abr arrives as base64 and is copied several times
before decoding; with a 1 GiB sample budget the import could pass 4 GiB and
abort the tab instead of failing cleanly. Pick the decoder budget by pointer
width (like MAX_DECODED_BYTES) and derive the engine's file cap from it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Reuse tile scratch and tone tables across rendering work. Skip fully masked
adjustments and identity float curves. Previous-path tests preserve output
across sample depths, masks and clipping.
The 0.11.0 release's FreeBSD job failed: copy_xattrs used rustix's
fsetxattr/flistxattr/fgetxattr, which rustix offers only on Linux,
Android and Apple systems. The real copy is now gated to those, and
other Unix systems get a no-op copy_xattrs, as Windows has. The xattr
tests are gated the same way; the Unix mode test now also checks that
no temporary file is left behind.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
new_object copied the template's rendered strikes, whose frame strike
(/99 /F) still named the template's frame in /10. It now names the new
object's frame, like the view's /0.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
On Windows with Windows Ink (WM_POINTER), winit drops penFlags (PEN_FLAG_BARREL) and reports stylus contact as WindowEvent::Touch, which egui-winit maps exclusively to PointerButton::Primary. Additionally, Windows Ink does not update GetAsyncKeyState(VK_RBUTTON) when pressing the pen barrel button. Consequently, Alt + Barrel Button drag was recognized as Alt + Left Drag (triggering the eyedropper and painting strokes instead of brush resize), and right-clicks with the stylus were treated as left-clicks.
This commit resolves the issue by:
1. Adding a native Windows message hook (WH_GETMESSAGE) in crates/tablet/src/windows.rs calling GetPointerPenInfo to intercept PEN_FLAG_BARREL (0x00000001) and POINTER_FLAG_SECONDBUTTON.
2. Exposing
ight_button_down in Services combining the pointer hook and GetAsyncKeyState(VK_RBUTTON).
3. Updating canvas.rs and paint_mouse.rs to prevent strokes_live and alt_eyedropper from hijacking the stylus gesture when the barrel button is held, and correctly routing resize events.
4. Adding tests asserting that Alt + stylus barrel drag resizes diameter horizontally and adjusts hardness vertically without triggering eyedropper or painting.
Text indices were assigned in one bottom-up pass, so a new layer took the
next free number before the layers above it claimed the numbers their TySh
keeps. Each of those was then renumbered onto its neighbour's Txt2 object
and took its style (70 of 74 layers on the #7698 file after adding a layer
at index 5). Kept numbers are now claimed first and new layers get numbers
past them.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
A failed proxy develop on the worker set the status error, and nothing took
it down once a later develop succeeded. The dialog now remembers the message
and the preview revision it failed on, and clears the status when a newer
frame is published, unless something else has reported since.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
poll_render clears the pending develop before it reports the worker's
error, so previewPending turned false with no new frame and settle()
returned as if one had landed. It now fails with the status message
when the dialog reported an error. With the worker forced to fail, all
21 scope tests passed before this change; now the one that settles
after a worker develop fails with "the Camera Raw preview worker
failed".
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The dialog's own controls develop the preview on a worker (#5964), so after
the scope tone drags its last frame could land after the test recorded
previewRevision and bump it during the hover check that must not re-render.
A settle() helper waits until previewPending is false; the fixture and the
drag test call it before taking a baseline.
With a 20 ms delay added to the worker, the old test failed 9 of 10 runs
with the reviewer's assertion; with settle() all 21 tests passed 10 of 10.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Camera Raw dialog developed its preview proxy on the UI thread at the
start of every frame after a control changed. With Shadows or Highlights
set that develop takes 190-250 ms on a 24 MP document (858x572 proxy), so
each frame of a slider drag blocked for that long.
The dialog's own controls now hand the develop, histogram and display
conversion to a worker. One runs at a time; settings changed meanwhile are
developed when it finishes, and the preview shows the latest finished
frame. The control channel and the first frame still render on the UI
thread so a reply describes its own settings, and such a render drops any
worker still running with older settings. The wasm build keeps rendering
synchronously. describe() reports previewPending.
Release build, 6000x4000 document, 20 Highlights/Shadows edits:
worst UI frame 237 ms before, 35.5 ms after.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Orientation buttons stored the turned settings from file.print.page
and then asked the driver again (driver_page) for the paper. If that
second query failed, the dialog kept the new settings with the old paper.
take_turned_page now takes both, and the orientation, from the one
answer; an answer without both leaves the dialog as it was and shows why.
The no-driver test used "00ff", which fails when the settings are read,
before any printer is asked, so it never tested the failed-driver path.
It now uses valid settings (a minimal DEVMODEW) and asserts the error
comes from the printer, not from reading the settings; with "00ff" it
fails. a_turn_takes_settings_and_paper_together covers the success path
and partial answers.
Reported by CodeRabbit in review.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- A requested adapter that couldn't be used, followed by a software window
adapter, lost its System Info note to the software-graphics message. Append
instead of replacing.
- PHOTOCRAFT_GPU_ADAPTER only forces its own process when startup acts on it
(Linux, outside CPU mode); in CPU mode it is ignored, so the launch may
still go to the running window.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The Preferences row was Linux-only, but PHOTOCRAFT_GPU_ADAPTER and
performance.gpuAdapter were honoured on every platform, so Windows and macOS
could pick a non-default adapter without being tested for it. Ignore the
request off Linux (and in CPU mode, as before), and don't make a launch
standalone for a variable that is ignored there.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
- cargo fmt on the adapter selection and its launch code.
- PHOTOCRAFT_GPU_ADAPTER is capped at 256 characters like the saved preference.
- Put the multi-GPU paragraph in docs/development.md on its own instead of
appending to the --safe-gpu paragraph, so the diff only adds lines.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
On Windows the Print dialog takes the paper and its orientation from the
printer driver, and had no way to change the orientation short of the
driver's own Print Settings… dialog. Photoshop has Layout buttons for it.
A print laid out landscape on portrait driver settings (from a script,
the CLI or another app) stopped with "the driver paper or orientation
differs from the print layout".
- The dialog has an Orientation row (Portrait, Landscape) under Print
Settings…. It turns the driver settings through file.print.page and
takes the paper from the driver again, so the paper and preview turn.
- file.print.page takes "orientation": "portrait|landscape" and returns
the turned settings as printerSettings, with the page they give.
- print_windows turns the settings (the given ones or the printer's
defaults) to the layout's orientation when the driver has the layout's
paper the other way round; another paper is still refused.
Turning sets dmOrientation and its dmFields bit in the DEVMODE and keeps
every other byte, the driver's private settings included; the driver
validates the result as it does settings from Print Settings…. No new
UI strings: "Orientation", "Portrait" and "Landscape" are in every
catalog.
Tests: the turned settings (orientation set, all else kept, with and
without private driver bytes), when a paper counts as turned, an unknown
orientation refused before any printer is asked, the dialog's reading of
the driver paper and its error path, and, run by hand on Windows, a
real "Microsoft Print to PDF" driver turning for a landscape layout.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The layered TIFF writer let the PSD exporter pick PSB from its size estimate, which counts every
layer plane uncompressed. A document with many canvas-sized layers passes 2 GB there while its
real layer data is a small fraction of it (a 3000×3441 mockup with 180 layers: estimate ~2.57 GB,
real data ~111 MB), so tag 37724 got the PSB-style header ("Adobe Photoshop Document Data
V0002", 8-byte lengths). Photoshop does not read that in a TIFF: it opens only the composite, as
one locked Background.
PsdExportOptions gains `size_limit_psb` (default true, .psd saves unchanged). The layered TIFF
turns it off and writes PSD-style data ("Document Data Block"), falling back to PSB only past
30000 px, when forced, or when a length really overflows 32 bits.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
A turned crop rotated the whole canvas, then cropped to the frame. With
Delete Cropped Pixels on, every layer, mask and channel is now trimmed to
the frame's source (plus the interpolation kernel's reach) before the
turn, so the rotation costs the frame's size: a 512x442 frame turned 3°
on a 3859x2594 two-layer document goes from 541 ms to 42 ms.
Rotate Canvas also fills the Background with shared tiles instead of a
canvas-sized float buffer.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
In a Flatpak on Wayland, arboard can't open at all: the sandbox hides the
data-control protocol from it, and --socket=fallback-x11 gives no X server
while a compositor runs. Paste then reported "the clipboard holds no image"
even when it held one. Now the clipboard service reports that it had no
clipboard to read, and the paste shows a notice that explains it and gives
the XWayland command for this install.
Also adds my contributors/people.toml entry.
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J
Co-authored-by: Claude Opus 5.5 <[email protected]>
With Ctrl (Cmd on the Mac) held over a guide, Photoshop's tools that turn Ctrl into
the Move tool show the guide cursor and drag the guide. Three gaps remained:
- The Frame and Artboard tools showed the guide cursor under Ctrl but took the
press themselves (a new frame or board), so the guide never moved. Their pointer
handlers checked the selected tool instead of the effective one.
- The Gradient tool's live mode took a Ctrl press sent with the event (automation,
`ui.pointer` with `command: true`) instead of handing it to the Move tool.
- With Window > Modifier Keys' latched Ctrl, the press took the guide but the
pointer kept the tool's own cursor: `command_guide_at`, the decision the cursor
and the guide context menu use, knew only the selection tools.
`command_guide_at` now covers every `hold_keys::cmd_moves` tool, and not during a
stroke or type editing. Frame, Artboard and live Gradient step aside for a Ctrl
press when they have no drag of their own under way. Tools that keep Ctrl for
themselves (Pen, Hand, Zoom, Crop, type, shapes, slices) and Lock Guides are
unchanged.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
One value of the wrong type in prefs.json (say "recentFileCount": "ten")
made the whole file fail to deserialize, so every preference fell back
to its default for the session, and since the file was never rewritten
it happened again on every launch.
load_prefs_json now reads leniently when the file doesn't deserialize as
a whole: it starts from the defaults and lays the file's values over
them one at a time, descending into section objects and map entries,
keeping every value that still reads and leaving out only the ones that
don't. Colour settings get the same treatment instead of resetting as a
whole. It returns the left-out values (keys and original value); text
that isn't a JSON object still fails as before.
On launch the app shows a notice naming the reset paths and saves once:
the three-way merge base gets the unreadable values, so the merge
replaces (or removes) exactly those in the stored file and keeps
everything else, including keys this version doesn't know.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Load Actions applied each rename as its own pass over every action in the set, so chained
renames ("A" -> "A 2", "A 2" -> "A 2 2") retargeted a step twice, and with replace:false it
also rewrote steps of actions already in the set. Now one original->new map is applied once
to the newly loaded actions' Play and Conditional (then/else) steps and to function keys.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
decode_into computed out.len() + expected unchecked. With a non-empty
buffer and an expected length near usize::MAX it panicked in debug builds
and, in release, wrapped and returned Ok having decoded nothing. Compute
the target with checked_add and report LimitExceeded, and test each packet
as len > target - out.len() so the bound checks cannot overflow either.
The only callers are decode and decode_rle in this module, and the crate
README documents packbits::{encode, decode} as the public API, so
decode_into becomes pub(crate).
Co-authored-by: Claude Opus 5.5 <[email protected]>
Pressing Enter while the frame is still being dragged commits the crop; commit_crop now ends the drag, so the release no longer restores a frame in the old document's coordinates as a pending crop.
Co-authored-by: Claude Opus 5.5 <[email protected]>
image.revealAll ran image.crop, whose step is labelled "Crop". It now
runs it inside analysis_cmds::compound with the label "Reveal All".
Co-authored-by: Claude Opus 5.5 <[email protected]>
Save as PDF was only a free text field, so saving a PDF meant typing a
full path by hand. Browse… opens the standard Save dialog (pick_save),
suggesting the document's name as a PDF, and puts the chosen path in the
field. file_ui::body now gets the dialog id so the answer can be written
back into the Print dialog on a later frame.
Refs storytold/photocraft#5328
Co-authored-by: Claude Opus 5.5 <[email protected]>
`to_press` and `control_point` read a point in the press-time view through
`remap`, which rounds it through f32 and the view even when the view hasn't
moved. A `ui.pointer` down + up at the same point then came back slightly
apart: the frame moved by a hair and a box Undo step was pushed that undid
nothing visible. Skip the remap while the view is still the press view.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* perf(codecs): Avoid redundant PNG filter passes
Keep the previous filter path as an exact byte oracle and measure the filter kernel independently of compression.
* docs(perf): measure PNG filtering on real images
* perf(codecs): stop PNG candidates at the winning cost
* Drop the local benchmark report
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]>
* Home screen: fit the card and stack the buttons in a narrow window
The Home screen's card was a fixed 460 points wide, centred on the canvas, with two
190-point buttons side by side. A tiling window manager can give PhotoCraft a canvas
narrower than that (Hyprland on Omarchy gave a 728-point window, below the 760-point
minimum), so the New document and Open buttons were clipped at the edges.
The card is now as wide as the canvas when the canvas is narrower than 460 points. The
buttons are sized from their labels (so longer translations still fit) and are stacked
when two of them don't fit side by side; the card grows by one row when they stack.
Adds start_screen_buttons_stay_inside_a_narrow_canvas, which fails before this change
(New document starts at x = -1 in a 390-point canvas).
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
* contributors: add TaylorBurke
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Save Actions wrote these four steps under the four-char codes BoxB, SrfB,
Fltn and BlnW, which Photoshop does not run: executeAction fails with "the
command is not currently available" (measured in Photoshop on a document
with a filled pixel layer; Fltn resolves to the flatness property, not the
flatten event). The names boxblur, surfaceBlur and blackAndWhite and the
code FltI run.
- Box Blur, Surface Blur and Black & White now export by name
(boxblur, surfaceBlur, blackAndWhite); Flatten Image exports as FltI.
The descriptors are unchanged.
- Import accepts the names, FltI and flattenImage, and still accepts the
old codes (a LEGACY_CODES table consulted only for four-char codes), so
actions PhotoCraft saved before keep loading.
- Tests: each step exports the new id and imports back to the same command
and params under the new id, the name and the legacy code; a set mixing
Photoshop-shaped and legacy steps loads and re-saves with the new ids;
near-miss ids (case, padding, empty, NULs) stay Photoshop steps; broken
descriptors don't panic.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Explain the per-document memory limit and automatic GPU budget caps.
* Add catalog entries for the Memory Usage explanation (#6546).
The new preference strings need a row in every complete-menu language
for the i18n tests.
---------
Co-authored-by: Brandon Thomas <[email protected]>
- PHOTOCRAFT_GPU_ADAPTER now counts as a per-process launch option, so a
second launch with it set doesn't forward to the running window and drop it.
- Gate RenderState::available_adapters to native targets (it doesn't exist on
wasm, which broke the web build).
- Share the adapter name matcher (engine prefs) between startup and
Preferences, so a saved fragment like "nvidia" isn't flagged as missing when
it matches a listed adapter.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
* fix(engine): record Apply Layer Comp by the comp's name, not its document-local id (#7181)
* test(engine): a recorded Apply Layer Comp applies the comp of that name in another document (#7181)
---------
Co-authored-by: Brandon Thomas <[email protected]>
One ToolOptions::sample_all_layers was shared by twelve tools, so ticking
it for the Magic Wand or Paint Bucket also turned it on for Blur, Sharpen
and Smudge. A dab of those on an annotation layer then pasted an opaque
copy of the composite there, which sat above the picture and hid every
later blur of it (the sharp round patch in #5524).
ToolOptions::sample_all is now a SampleAllLayers with one flag per tool,
all off by default: Magic Wand, Quick and Object Selection (and Object
Finder), Paint Bucket, Magic Eraser, Spot Healing, Patch, Content-Aware
Move, Remove, Blur, Sharpen and Smudge. Each options bar and command
reads its own tool's flag, and Reset Tool resets only that tool's. The
new key means older preferences drop the shared flag, so every tool
starts with it off.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Session::edit has reported a new layer's bounds as damage since #3847, so
Duplicate Layer, Duplicate Layers and Paste no longer need to keep the
document from before the edit and work the damage out again with
note_insert. Paste Into still does: it is three edits.
Co-authored-by: Claude Opus 5.5 <[email protected]>
W and H showing percent rejected a typed length ("40px", "2 in", "5cm"),
since the shared unit parser has no length to convert a percentage
against. The fields now carry the box's original side as the length 100 %
stands for, so a typed length becomes that share of it at the document's
resolution, and "50%" typed into a length view is half the original side.
The field keeps its chosen unit; the aspect link and the scale limits
apply as before. Other percent fields (opacity, fill) still reject lengths.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* GPU canvas: redraw mip levels of 128 px or less whole; AMD Vulkan corrupted them after partial updates (#6418)
On an RX 6900 XT with Vulkan (driver 26.8.1), a regenerate_mips pass that drew only the damaged
part of a small mip level corrupted texels outside its scissor, at the level's top-left; DX12 on
the same card was fine. A sweep of 15 canvas sizes x 8-bit/float x 6 dabs found 66 of 180 cases
corrupt on Vulkan, every corrupted level at most 64 px; sampling the levels in a shader shows the
same pixels, so it is the texture contents, not the readback.
mip_damage (regenerate_mips's damage arithmetic) now redraws levels of at most WHOLE_MIP_SIDE
(128 px) whole on every adapter: no new passes or textures, only a larger scissor on the
smallest levels. With it the sweep is clean on both backends.
Tests: small_mip_levels_are_redrawn_whole (the rule, no GPU) and
partial_updates_match_a_full_rebuild_at_every_mip_level (fails on the Radeon over Vulkan without
the change, passes with it, like canvas_mips_preserve_opaque_pixels_and_partial_updates).
* contributors: add 00xJS (GitHub profile name)
* Mip sweep test: hold the GPU lock like the other GPU tests (#6438 review)
---------
Co-authored-by: 00xJ0 <[email protected]>
* Hide the Hyphenate checkbox until layout actually hyphenates (#6710).
The flag still round-trips to PSD and ⌥⇧⌘H still toggles it, but the
Paragraph and Properties panels no longer advertise a no-op.
* Assert Hyphenate stays hidden until layout hyphenates (#6710).
The Paragraph properties and paragraph style options editor now have
tests that fail if the no-op control is shown again.
* Drop the duplicate #[test] on hyphenate_is_not_shown (#6710).
Clippy -D warnings failed on duplicate-macro-attributes and unused mut
on the harness that only queries the label.
* fix(linux): keep crash-recovery autosaves in $XDG_STATE_HOME, out of the config folder (#6612)
* test(linux): cover the XDG state recovery folder (#6612)
Migrate Presets deserialized the incoming tool presets all or nothing, so
one damaged entry dropped every good one, and it skipped the checks the
launch loader applies: blank or overlong names were imported and then lost
on restart, and a large file pushed the library past MAX_PRESETS. Read the
list with `presets::tools::load` and stop at MAX_PRESETS.
Co-authored-by: Claude Opus 5.5 <[email protected]>
While editing paragraph text, a click inside the box that missed the glyphs
(left of a line's start, or below the last line) committed the edit. Like
Photoshop, it now places the caret at the nearest position; a click outside
the box still commits.
Co-authored-by: Claude Opus 5.5 <[email protected]>
transform_doc_marks moved guide positions but not their colour entries,
which are keyed by exact position: after a crop the coloured guide fell
back to the preference colour and a guide that landed on its old place
took the colour. Map the entries with the same expressions as the
guides (swap orientation on quarter turns, clear on arbitrary angles).
Co-authored-by: Claude Opus 5.5 <[email protected]>
* perf(photomerge): build multi-band bands in bounded batches (#7758)
panorama::multiband built every source's Laplacian and weight pyramids
at once and kept them all until accumulation, so Photomerge's peak
memory grew with the image count (~5 GB for four 24 MP sources). Build
the bands in batches under a 2 GiB estimate (at least one image) and
add each batch before building the next. Each pixel still adds the
bands in image order, so the output is bit-identical.
Planes now move into the pyramids instead of being cloned, and the
coverage and push-pull masks are freed once used: with one image built
at a time those clones were serial passes. seam_blend shifts its ROIs
in place instead of cloning every source for the blend.
Peak during the blend: 4x24 MP 5.04 -> 2.81 GB, 8x12 MP 5.50 -> 2.61 GB,
at +5% time (2x24 MP unchanged). New example bench_multiband measures it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* perf(photomerge): let up to four 24 MP sources build their bands together
A 2 GiB band budget built every 24 MP source alone, which made the usual
2-4 image Photomerge 12-36% slower on a 32-thread desktop. The native
budget is now 5 GiB, so those still build side by side and only larger
stacks are batched. wasm32 has no threads, so it adds each band as soon
as it is built.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Photoshop's Action Options pair the function key with Shift and Command
checkboxes. The Actions row menu offered plain F-keys only, and .atn import
dropped any key with a modifier. The row menu gets Shift and Command (Ctrl off
the Mac) toggles, and .atn load and save keep the modifiers.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
select_tool_preset only merged the preset's tool options, so a crop
preset's ratio was set without refitting the frame; the options bar's
dropdown already refits. It now compares the ratio before and after and
calls crop_ui::apply_ratio when it changed, as the dropdown does.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* fix(io): warn when a Color Lookup opens without its usable table
A Color Lookup whose table is shorter than its size-cubed grid (or
missing altogether) silently rendered as the identity. Native .pcraft
loads and native-path automation opens carried no import warnings, so
the loss was silent. The new io::open_warnings reports it naming the
layer, and both load seams surface it.
Also add a Photoshop-layout clrL regression test: a version-1 block
carrying a CUBE descriptor round-trips through PSB and reopens as a
working Color Lookup that recolors a red backdrop (#1763).
* cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
With the backend on auto, Linux installed no adapter selector, so wgpu took
the first discrete GPU the driver listed. On a machine with an eGPU, a
laptop dGPU and an iGPU that was not the one the user wanted.
- performance.gpuAdapter: a case-insensitive piece of the adapter's name
(several words must all appear); empty is automatic.
- PHOTOCRAFT_GPU_ADAPTER overrides it for one launch.
- Preferences > Performance > Advanced > GPU adapter lists the adapters the
instance found; a saved adapter that is absent stays listed, marked.
- A missing adapter, or one that can't present to the window, falls back to
the automatic choice with a note in System Info. CPU mode ignores it.
- Shown on Linux only; tested on Linux only. Windows and macOS need their own
work (the startup code honours the setting there, but it is untested).
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
A Smart Blur step saved to .atn wrote its quality as `Hgh `/`Mdm `/`Low `,
codes Photoshop ignores (it plays them back with its fallback, low
quality), and Photoshop's own `SmBQ` values did not import as a quality.
The quality table now writes `SBQL`/`SBQM`/`SBQH` (enum type `SmBQ`) and
reads both those and the old `Hgh `/`Mdm `/`Low `, so actions PhotoCraft
wrote before still load. Unknown codes stay kept-verbatim Photoshop steps.
Measured in Photoshop by rendering: Smart Blur applied through Action
Manager with each code; SBQH and SBQM each give a distinct image, while
`Hgh `, `Mdm `, `Low `, a nonsense code and SBQL give the identical
(fallback) image. The smart filter Photoshop saves uses the same SmBQ
values (applied through Action Manager to a smart object, saved, filterFX
read back).
Tests: every quality round-trips through .atn as SBQ*; Photoshop-shaped
SmrB steps and the legacy codes import as the right quality; unknown,
wrong-type, NaN and missing values never panic; Smart Blur joins the
export/import round-trip table.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
* Add Liquify's Show Backdrop Use and Mode (#7609)
Show Backdrop only had an Opacity: it always drew the other layers'
composite behind the image, faded. Photoshop's Liquify also has Use (All
Layers, or one layer) and Mode (In Front, Behind, Blend). Use and Mode
are now in the View Options and the control channel (backdropUse,
backdropMode), and each mode layers the backdrop and the image as
Photoshop's does: In Front lays the backdrop over the image at the
Opacity, Behind the image over the backdrop, Blend fades the image to
100 - Opacity under the backdrop. All Layers is the document as it is,
as in Photoshop.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Liquify backdrop: show a hidden Use layer, forget the layer between documents
A layer picked in Use, or one in a hidden group, now shows: the
one-layer backdrop makes it and its groups visible, as Photoshop does.
The remembered settings keep the backdrop Mode and Opacity but no longer
a layer id, which belongs to one document: Liquify opens on All Layers,
as Photoshop's does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Liquify backdrop: a control-channel Use naming no layer is All Layers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Liquify backdrop: normalize an unknown Use even when another setting fails
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
A Gradient Overlay (and gradient strokes, linked patterns, linked bevel
textures) is laid out over the layer's bounds. Painting grows those
bounds, but the stroke commands reported only the stroke as damage, and
the canvas's committed-stroke path repainted only what the live stroke
had not shown. The canvas therefore kept the old gradient around earlier
strokes: one gradient per stroke instead of one over the whole layer.
Session dispatch now grows a command's reported damage by
photocraft_compose::relayout_damage(before, after): the whole composite
of any visible layer whose effects span its frame, when that frame moved.
The canvas adds the same area to a committed live stroke. Strokes inside
the existing bounds and frame-independent effects keep tight damage.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
`bake` rendered each layer through `render_layer`, which has no document context (default Global
Light, no patterns, the render rect as canvas). It now uses the new doc-aware `render_layer_in`.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* Color Settings: the working-space menus list the installed profiles of their space (#6323)
Like Convert to Profile and Proof Setup, Edit › Color Settings' RGB, CMYK and Gray working-space
menus now list the profiles installed in the system after the built-ins, by description, so a
press condition (GRACoL, SWOP, FOGRA39) can be the CMYK working space as in Photoshop. Each menu
lists only profiles of its colour space; RGB lists display and colour-space profiles, not
scanner or printer ones. The engine already took .icc paths for these settings.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* rustfmt: format after merging main (CI Format step)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
The Histogram panel binned a 384 px area-averaged thumbnail, so the cell
holding a black/white edge came out mid-grey: a spike near 128 and
slightly wrong Mean/Std Dev. Documents up to PROXY_THUMBNAIL_PIXELS are
now composited in bands and every pixel is counted (Cache Level 1, as
Photoshop's uncached histogram); larger ones count a nearest-neighbour
proxy composite, which only holds values the layers have, and report its
cache level. The Pixels row shows the pixels actually counted.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* Include tool presets in export and import so crop presets are portable.
* Pass presets_io_body to the harness without a redundant closure (#6560).
CI clippy -D warnings failed on clippy::redundant_closure in the
presets export/import regression.
* Layers: ⌘-click on the Background thumbnail is a plain click, as in Photoshop
⌘-clicking a layer thumbnail loads its transparency as a selection (⇧ adds,
⌥ subtracts, ⇧⌥ intersects) without changing the selected layer. Measured on
Photoshop 25.4: the Background, which has no transparency, is the exception —
the click loads nothing and selects the row like a plain click, where
PhotoCraft selected the whole canvas. A hidden layer still loads.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Layers: the Background thumbnail's ⌘-click selects the Background alone
Review: the fallback to the row handler kept ⌘ set, so `select_mode` toggled
the Background into (or out of) the selected set instead of the plain click's
replace. The Background thumbnail's ⌘-click now selects it alone; the test
asserts the selected set, including when the Background was already part of a
multi-selection.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
---------
Co-authored-by: Claude Fable 5.1 <[email protected]>
Character (type.setStyle), Warp Text, Anti-Alias, Orientation, OpenType
and Load Default Type Styles all run through with_text_targets, which
never checked Lock All, so they changed a locked type layer and added a
history step. Photoshop refuses all of them ("the layer is locked").
Check every target with check_text_unlocked before the edit, as
type.edit already does.
Co-authored-by: Claude Opus 5.5 <[email protected]>
The options bar read the box's X scale as never negative and dropped
the sign of the Y scale, so Flip Horizontal read W 100 %, H 100 %, 180°
(typing 0° then flipped the layer vertically) and Flip Vertical read
the same as an untouched box. A mirrored box now reads as a negative W
or H, whichever keeps the angle nearer 0, as in Photoshop (Flip
Horizontal: W -100 % at 0°, Flip Vertical: H -100 % at 0°). Typed W/H
divide by a sign-preserving guard instead of max(1e-9).
Co-authored-by: Claude Opus 5.5 <[email protected]>
Imported actions stopped at Shadows/Highlights ("adaptCorrect has no
PhotoCraft equivalent yet"), although Image > Adjustments > Shadows/Highlights
exists. The step now maps to it both ways: shadowMode/highlightMode (amount,
width, radius), black/white clip, midtone contrast and colour correction,
each read as its char ID or string ID.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
When actions.save was called while recording was active, it exported only
already-committed action steps, silently omitting any pending steps shown
in the Actions panel.
Call flush_recording at the beginning of actions.save to commit pending
journal entries to the action and advance the recording pointer, ensuring
all displayed recorded steps are saved while allowing ongoing recording to
continue uninterrupted.
Fixes#7193
Co-authored-by: Brandon Thomas <[email protected]>
* PNG: keep a stereo file's sTER pairing on save (#6063)
Read the sTER chunk (PNG Extensions 1.4.0 §3.6) when a PNG opens, store it
on the document's FileEncoding, and write it back when saving, so a stereo
PNG still says it is a stereo pair. Covers the ordinary and the palette
PNG encoders, including interlaced output.
* io tests: pass the new sTER argument to encode_png_indexed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: highperfocused <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
constant_rgb_noise_color's None propagated out of fill_from_desc for every
noise gradient that is not provably constant - Photoshop's default Noise
gradient included - so the layer imported as pixels and the export dropped
its GdFl (RASTER_STALE_KEYS): an open-and-save data loss. The exact colour
now applies only when provable; everything else keeps the placeholder ramp,
the fill_cache pixels and the raw GdFl block, which the re-save writes back
byte for byte. Round-trip test included (fails on the old head: the layer
came back as pixels without a GdFl).
`slices::resolve()` only clipped each stored slice to the canvas, so a slice under a later
one was numbered and exported in full and Save for Web wrote the overlap twice (the HTML
path cut pieces itself, named `<name>_01`, `_02`). As Photoshop does, resolve() now cuts a
covered slice into its visible pieces: the first piece in reading order keeps the name,
every further piece is `<name>-NN` with its own slice number, and auto slices count the
pieces in their numbering (measured in Photoshop: sliceA, <doc>_02, sliceA-03, sliceB,
<doc>_05).
- Save for Web exports resolve()'s pieces as they are, with or without HTML; a no-image
cell's text goes into its first piece only.
- Slice Options and slice.list show the slice's own name for every piece; slice.set accepts
a slice that ends up wholly under later ones (number null) instead of calling it off the
canvas.
- Slice Select drags the whole slice, not the piece that was hit.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The stub filter applied only to the kept object, so an edited layer whose
slot held a stub from an earlier save went through edited_object and kept
the bare stub. The slot is now filtered before both lookups.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
`layer.layerStyle.stroke {}` built a red 3 px outside stroke, while the
dialog's factory default (defaultFor / Reset to Default) was black 3 px
outside. Photoshop's Reset to Default on the Stroke page is 1 px,
Inside, Normal, 100 %, black; both tables now give that, and a test
checks that every effect kind's bare build equals its dialog default.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A type layer the file's Txt2 has no object for (one added in PhotoCraft)
was written as a bare stub with no view, no paragraph runs and no frame
in /0 /8 /0. Photopea and Photoshop refused the file once the objects
outnumbered the frames.
build_txt2 now gives such a layer an object shaped like one of the
file's own (refitted with edited_object from #7220), with its view
naming its own frame, and pads the frame list to one per object. A stub
written by an earlier save is replaced the same way, so re-saving an
affected file repairs it.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Perspective Crop set no sampler map, so the edit only dropped the
points now off the smaller canvas and left the rest at their old
coordinates, reading unrelated pixels. As in Photoshop, each point now
follows its pixel through the crop's homography (fractional positions
kept), and the points outside the quad are deleted and counted for the
status message.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The options-bar ratio fields apply every keystroke, and each ratio was
fitted inside the frame the previous keystroke left: typing 16 over 4 in
a 400 x 300 frame went 1:3 (100 x 300), then 16:3 inside that
(100 x 18.75) instead of 400 x 75. The bar now keeps the frame from when
the W/H edit (typing, arrows or scrubbing) started and fits each new
ratio from it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A pass-through group that is the base of a clipping group renders isolated while it holds a
visible adjustment (`clip_base_adjusts`), so showing, hiding, adding or removing a clipped
adjustment inside it can switch the group's other children (a Multiply layer beside the base)
between isolated and pass-through rendering. `change_bounds_in` now takes the list holding the
parent group and reports that group's change bounds in this case; `layers_damage`, `step_damage`
and `one_sided_bounds` pass it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A Straighten line whose release went to Space's temporary Hand stayed
set, so the next plain drag fed its moves and release to the old line:
the frame turned instead of moving and the crop gesture stayed stuck.
A new press now drops any leftover line first.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Since #5726 the smoothed brush pulls its pressure, tilt, rotation and wheel
towards the pointer like its position, but Stroke Catch-Up (`lagging`) and
Catch-Up On Stroke End (`finish`) still compared x/y only. A pen that changed
only its pressure in place got no catch-up time and no end point, so the
stroke ended at an in-between pressure (1.0 -> 0.0 ended at 0.64).
Both checks now also compare the pose (rotation on the circle).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
While either crop-box stack has a step, intercept takes both Undo and
Redo, but is_enabled reported only the stack it would pop and otherwise
fell back to the document's can-undo: Undo looked enabled, popped
nothing and left the document's history unreachable. Grey the empty
direction instead, as the Free Transform box does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Crop, Trim, Canvas Size, Image Size, Flip and Rotate Canvas mapped only the
Color Sampler points; the painting symmetry kept its old document
coordinates, so strokes mirrored about a different line (and a path axis
left its own moved path). Session::edit now maps the symmetry through the
same edit's geometry map: preset centre and angle (a flip mirrors it), or
the sampled path segments. Flips and rotations pass their edge-coordinate
map separately from the samplers' pixel-centre map.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Crop tool's W x H x Resolution mode resampled with the chosen filter
(Bicubic by default) even on an Indexed Color document, so the result held
blends outside the colour table. Like Image Size (#6019), it now falls back
to Nearest Neighbor there.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Justification needs a paragraph box: Photoshop greys the four justify buttons
for point text, which lays out left whatever its alignment. PhotoCraft offered
them, and choosing one did nothing visible.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Parallel bands each collected their finished tiles until the band's last
one was done, so with work stealing many bands could hold most of their
tiles at once, close to a second copy of the output. Each tile is now put
into its band behind a lock as soon as it is rendered; only the tiles in
flight are held, as before the bands ran in parallel.
The compositor draws a clipped adjustment that has no unclipped layer
beneath it as an ordinary adjustment, but change_bounds counted it as
contained in a pass-through group. Hiding the layer clipped to such a
group (which ends its isolation) or the group itself then recomposited
only the group's pixels and left the rest of the canvas stale.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Only CMYK and Lab tiles are split into bands; every other format's one part
went through concat, an extra allocation and copy per tile on the UI thread
(8-bit RGBA, 64 tiles: 2.7 -> 3.3 ms). Now it passes straight through.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The installed-font scan read only /System/Library/Fonts, /Library/Fonts and
~/Library/Fonts (plus PingFang). Fonts macOS offers from anywhere else, such
as those a font manager, Adobe Fonts or Office activated, never reached the
font menus although other apps showed them.
load_system_fonts now also registers every file Core Text offers. The list
comes from fontique's Core Text backend, enabled for macOS only, used as a
scratch collection whose families are discarded: only its file paths are read
and registered in our own collection, so generic-family defaults and the merge
of installed faces into the bundled families are unchanged. The reserved UI
fonts under /System/Library/PrivateFrameworks are skipped (fontique cannot
draw their outlines).
On a stock macOS install the folder scan already listed all 183 Core Text
families; the gap is fonts Core Text knows from elsewhere. With Hack activated
from a scratch folder via CTFontManagerRegisterFontsForURL, the list had 313
families before and 314 after (Hack added, none removed), type.fonts lists it
and a type layer renders in it. The scan stays 37-57 ms.
Tests: font_files_of_lists_each_font_file_once (all platforms);
coretext_lists_the_system_font_files and every_family_coretext_offers_is_listed
(macOS). The last fails without the call when a font is activated elsewhere
("lacks: Hack") and passes with it.
Cargo.lock gains fontique's optional per-platform dependencies for the
`system` feature; `cargo tree` shows the feature only for aarch64-apple-darwin.
Still open: Windows fonts outside the two folders (#3336) and the web build.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
A stripped Windows image can have Windows.Data.Pdf.dll present but not
loadable; that is the case `require_windows_pdf` reports, so the test now
checks that the result follows the loader instead of assuming the DLL loads.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The merges from main dropped three parts of 86f6199b:
- `winsafe` from crates/engine/Cargo.toml (Cargo.lock still listed it, so every
`--locked` CI step failed). It now sits in the engine's existing `cfg(windows)`
dependency table next to `same-file`.
- the `require_windows_pdf()` call. Main moved `craft_print::submit` into
`spool_job`, so the check now runs there, right before the Windows spooler
route; that is the only call that reaches winprint's `WinPdfPrinter`
(Windows.Data.Pdf.dll). The native RGB route, IPP, the app spooler and
Save as PDF don't need the DLL and are unchanged.
- the Windows load test and the no-op test for other platforms.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
main's 589941a5 (#6600) refuses to replace a read-only file before anything
is written. This branch's test wrote to a 0o444 file and expected the save to
succeed, so the merge of the two failed on Linux:
cannot save .../doc.psd: the file is read-only; the original file was
not changed
No bug on either side: the two changes disagreed about what a read-only save
should do, and main's answer is the deliberate one. The test now asserts the
refusal, that the original is untouched, and that the attribute and mode survive
— which is what it set out to check. The writable cases alongside it still cover
the metadata copy itself.
The windows-latest failure was the roll-up gate reporting this same failure, not
a second one.
* docs: add user docs for PhotoCraft
Adds docs/user/ (published at getartcraft.com/docs/photocraft), the docs
check and publish workflows (copied from craft-launcher), and a User docs
rule in AGENTS.md. Checker: 0 error(s), 0 warning(s).
Source notes per page:
- index.md: README (intro, status, Crafting Apps table), website
crafting-apps.ts photocraft entry, links.rs (Discord, issues), menus.rs
(Help > Report an Issue).
- getting-started/index.md: menus.rs and menu_catalog.rs labels, panels.rs
(Layers, Properties, History), dialogs.rs (Create button).
- getting-started/install.md: README Downloads and Get started sections,
gh release view v0.7.0 asset list, craft-launcher catalog.rs (lists
PhotoCraft).
- guides/open-and-save-files.md: apps/photocraft/src/services.rs (open and
save filters), README Formats and PSD sections, docs/file-format-parity.md.
- guides/adjust-colors-with-adjustment-layers.md: menu_catalog.rs, panels.rs
(footer button, adjustment list), lib.rs (Properties shows new layer).
- guides/select-and-mask.md: menu_catalog.rs Select and Layer Mask menus,
state.rs tool names, retouch_ui.rs (Select Subject button and message).
- guides/add-and-style-text.md: state.rs (Horizontal Type Tool, T),
type_tool.rs, menu_catalog.rs Layer Style and Type menus, dialogs.rs.
- guides/export-an-image.md: export_dialog.rs (labels, formats, options).
- troubleshooting/taskbar-turns-black.md: issues 3550, 3864, 3430, 3144,
PR 3308 (git: not an ancestor of v0.7.0), prefs_ui.rs (System title bar).
- troubleshooting/text-moves-when-editing.md: issues 3795, 1317, PR 3819
(open).
- troubleshooting/get-help.md: menus.rs, links.rs, gpu_status.rs (System
Info content).
- faq/*: README (status, license badge, offline, formats), LICENSE-MIT and
LICENSE-APACHE, docs/file-format-parity.md.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(user): apply final review fixes
Part 1: verified = latest release tag policy in docs/user/AGENTS.md; released-pages sentence in root AGENTS.md. Part 4 items 1-9: remove x86 row and TGA resolution, formats, new photocraft-does-not-start page, mask label, taskbar workaround, wording, Affinity read-only, index.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(user): use the standard section title
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(user): describe PhotoCraft 0.7.0 startup behaviour
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(user): tighten PhotoCraft startup wording
Drop the unsourced "slower" claim for CPU mode and give the exact
Preferences path on Windows and macOS.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: pin the docs checker to a commit and its SHA-256
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: drop the docs workflows until the checker is on artcraft-services main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: re-verify against 0.8.2
- taskbar and PSD text pages: both fixes ship in 0.8.2; tell users to
update and drop the "not in a release yet" wording and workarounds
- does not start: 0.8.2 fixes the Intel Iris Pro/Monterey Device(Lost)
case; OpenCore Legacy Patcher Macs still have no fix
- install: Windows 10 or later, 32-bit Windows builds, RISC-V deb/rpm,
Wayland paste and XWayland notice
- open and save: Krita and Paint.NET filters, Krita/PCX/FITS formats,
save progress, JPEG/WebP encoding kept on Save
- export: TGA Resolution, multi-layer Export As and Quick Export
- text: Shift-click starts new text over existing type
- CLAUDE.md is now a regular file pointing to AGENTS.md
- verified: 0.8.2 on every page
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Drop the root AGENTS.md docs rule; keep the PR to docs/user/
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs(user): drop the stale missing-tools count from the Photoshop FAQ
Re-verified the page against v0.9.0.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Fabio Lussi <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(filters): Lens Blur reads its depth from an alpha channel, listed in Depth Map › Source as in Photoshop (#7400)
* test(filters): Lens Blur depth from an alpha channel by name or index, its errors, and the Source menu (#7400)
Copy refused fill and adjustment layers ("the active layer has no
pixels"). With no pixel selection they are now taken whole, like smart
objects, type, shape and group layers, and Paste recreates them in this
or another document. An adjustment layer's clip spans the canvas, so
paste placement and the OS-clipboard mirror stay consistent.
Scorecard UI-217-23 note, gaps.md G9 and target-app-parity.md updated;
the row stays partial (a single pixel layer still copies pixels only).
Co-authored-by: Claude Opus 5.5 <[email protected]>
Edit > Preferences > Enhanced Controls > Enable Gestures (enhancedControls.touchGestures)
was saved but read by nothing, so it was hidden from the dialog. Now, with it off, two
fingers on a touch screen no longer zoom the canvas or the Navigator, nor turn the view.
The mouse and trackpad behave as before, including a trackpad pinch or Rotate event that
arrives while fingers rest on the screen.
- wheel_nav: pinch_factor() drops egui's multi-touch zoom when gestures are off and keeps
the trackpad's own Event::Zoom (Windows' folded Ctrl + wheel pinch included);
read_with() takes the preference.
- rotate_view: trackpad_delta() only falls back to the multi-touch rotation while gestures
are on; Rotate events always count.
- canvas, Navigator: pass the preference through.
- prefs: touchGestures leaves HIDDEN_UNTIL_IMPLEMENTED, so the dialog shows it.
- Tests: synthetic two-finger pinch and twist, on and off, and the trackpad events with
gestures off.
- docs/scorecard.md regenerated: settings that do nothing 26 -> 25 of 168.
* Curves: Cmd/Ctrl-click on the image adds a point at the pixel's tone (part of #7325)
With Image > Adjustments > Curves open, a Cmd-click (Ctrl-click off the Mac) on
the image adds a point to the channel shown in the dialog, at the pixel's tone
and at the curve's current output there; Shift adds one point on each of Red,
Green and Blue. Only the press adds, an armed eyedropper still wins, and the
control channel's ui.pointer does the same with command: true.
* Curves: Cmd/Ctrl-click adds the point with the Hand tool too; Space and middle button still pan
The Hand tool took the Cmd-click press for a pan, so no point was added; the
press now counts before the Hand. On the control channel, ui.pointer with
space or the middle button pans as the mouse does instead of adding a point.
* Curves: the Cmd/Ctrl-click point's comment says where the curve can still change
* Type: right-click on committed text opens the type layer menu (#7324)
With the Type tool, a right-click only opened a menu while text was being
edited; over committed type nothing opened. The menu was keyed on the edit
session alone (`editing_text`).
Over committed type, a Type tool (not a Type Mask tool) now opens
TYPE_LAYER_MENU: the type edit menu's layer rows, without the edit rows
(Undo, Cut, Copy, Paste, Select All), which would act on the document, and
without Faux Bold/Italic, which act on the characters being edited. Type
outside the selected layers becomes the target first, as a click with the
Type tool makes it. The control channel's right button takes the same path.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Type layer menu: the clicked type becomes the active layer in a multiselection (#7324)
The structural rows (Rasterize Type, Convert to Shape, Create Work Path, the
paragraph/point conversions) act on the active layer. When the right-clicked
type was already selected but another selected type layer was active, they
acted on that other layer. Make the clicked type the active layer while
keeping the selection (layer_multi_cmds::set_selection), so the formatting
rows still act on every selected type layer. Found by CodeRabbit on #7430.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
No textual conflicts, but the merge did not compile. main's 589941a5 added a
second AtomicFs test impl using copy_permissions, while this branch renames that
trait method to copy_metadata. Git merged both cleanly because they sit in
different parts of the file, giving a tree whose trait and impl disagree:
E0407, E0046 and E0599 in photocraft-format's lib test. That one error is what
failed all four CI jobs.
main's new impl is renamed to copy_metadata to match.
Verified with --features heif, which is what CI builds with and what I had
previously failed to pass locally: clippy --workspace --all-targets --locked
--features heif -- -D warnings is clean, and the workspace tests pass.
* fix(print): the last print's settings are kept in the preferences, so Print opens with them after a relaunch (#7410)
* test(print): the last print's settings survive a relaunch for Print and Print One Copy (#7410)
When the crop quad's horizon crossed the layer and most of the layer
(or its centre) lay beyond it, warp picked the source's majority side
and skipped every output pixel, so the crop came out blank. A clip
wholly on one side of the inverse horizon only ever samples that side,
so draw it; a straddling clip keeps the majority rule (#4863).
Co-authored-by: Claude Opus 5.5 <[email protected]>
The target size was read with as_u64 only, so 100.0 (json.dumps of a
float, many agent clients) was refused as not a whole number, while the
crop rectangle and image.imageSize accept it. Fractional, negative,
zero and out-of-range values are still refused.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(shortcuts): plain , and ; bindings follow the typed character on AZERTY
The positional match for , and . (so Shift+Cmd+. works on Nordic/German layouts)
applied to every shortcut. On AZERTY, ; sits on the US comma key, so ; fired the
command bound to , (brush hardness up/down did the same thing). Only shortcuts
with Cmd now follow the key position.
* contributors: credit Byxis by their GitHub profile name
The synchronous filter-preview path (web, and sessions with background
jobs off) flattened each changed preview, uploaded it and recorded it,
then flattened it again for publish_filter_preview, which uploaded and
recorded it a second time. Flatten once, as the background worker does,
and let publish_filter_preview do the upload and the perf record.
This removes one full composite and one texture upload per changed
preview, and the frame's refresh time no longer counts the preview twice.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Found while testing crates/format/Cargo.toml:19 (serde_json features), used for the manifest in crates/format/src/manifest.rs and store.rs
Co-authored-by: Claude Haiku 5.5 <[email protected]>
* fix(actions): stop resumed playback when a dialog's worker fails
* test(actions): reject suffix after failing background dialog filter
* fix(actions): propagate the failed worker result through dialog confirmation
* fix(dialogs): return action worker failures to callers
* Collapse the nested if in actions::resume (clippy::collapsible_if)
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]>
* fix(remove): cancel waiting strokes when their target changes
* fix(remove): store pending target identity with waiting strokes
* test(remove): cover queued strokes across layer and channel switches
* Tests: format retouch_ui.rs and drop the unreachable! arm clippy denies
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]>
* fix(fade): detect uniform mask changes and blend full canvas
* test(fade): cover uniform mask inversion and Fade across bit depths
* style(fade): keep default-pixel comparison readable
* Format edit_menu_cmds.rs with rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Typing or pasting over a selection now takes the style of the first selected character instead of the one before it, so retyping a smaller second line no longer takes the first line's size from the line break. Fixes#7413.
* file.saveACopy refuses the document's own file without "overwrite" (#7437)
Save a Copy through the engine (control channel, MCP, serve, headless
action playback) silently replaced the open document's own file when
given its path, so a lossy re-encode could not be undone. It now returns
BadParams when `path` is the active document's file (same text, ./ or ..
spelling, hard link, case on Windows via same_file) unless the params
pass "overwrite": true, and nothing is written. The desktop dialog writes
through the shell after the OS confirms a replace, so it is unchanged.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* same_file: compare file identity on Windows, so a hard link is the same file
On Windows `same_file` compared canonical path strings, so a hard link to
the input (another name for the same file) passed the new convert guard
without --in-place. It now compares the volume serial number and file index
(the `same-file` crate, already in the dependency tree; std's file_index is
unstable), as unix compares dev and inode, falling back to the
case-insensitive canonical path when a file won't open. The engine's
same_file test now checks hard links on every platform. (Review on #7454.)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* saveACopy own-file test: hard link on every platform, doc state unchanged after a refusal
Each refused copy (same path, ./ spelling, hard link) now also checks the
document keeps its path, revision and unsaved state. The hard link is no
longer unix-only: same_file compares file identity on Windows too. (Review
on #7456.)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(type): ↑/↓ on the focused font field step through the families after a pick, as in Photoshop (#7407)
* test(type): the font field keeps the focus after a pick and ↑/↓ step the families (#7407)
* fix(type): font menu rows keep their size when hovered or selected, so the list no longer jumps (#7406)
* test(type): hovering a font row moves no row and keeps the menu width (#7406)
an_authenticated_connection_outlives_the_authentication_deadline started
its 200 ms deadline after it spawned the thread that writes the first
line. When the OS started that thread late, the first read failed with
TimedOut at crates/automation/src/security.rs:447. On a loaded machine
the photocraft-automation lib tests failed 5 of 30 runs this way.
The test thread now writes the first line before the deadline starts,
and the limit is 1 s. The sender thread writes the second line after
the test thread has called authenticated() and 300 ms after the
deadline, so the second read still waits past the deadline. The test
still fails when authenticated() is not called. The product's
AUTH_DEADLINE is unchanged.
the_frame_loop_keeps_running_during_a_slow_job_and_esc_cancels_it
stepped frames until 400 ms of wall time and 10 frames had passed, and
then looked for the job's status message, "Filtering tiles", in the
progress dialog. The job's worker reports that message as its first
statement, on a thread of its own. The dialog appears after 250 ms
whether or not the worker has run, and it reads "Working…" until the
message arrives. On a loaded machine the worker can start after the
400 ms window, and the test then fails with "the job's status
message".
The test now steps at least 10 frames and until the dialog shows
"Filtering tiles", through a new helper, frames_until, which fails the
test when the condition does not hold within 60 s. It also checks that
the job is still running at that point. The helper has the same
signature as the frames_until in #3422.
* Preferences: the settings fill the resized dialog, buttons at the bottom (#7420)
#5170 made Preferences resizable, but its contents still sized themselves
from the app window (width capped at 1040, settings area at 680 tall), and
the dialog opened at a 960x900 default taller than that. So it opened with
empty space below the buttons, and enlarging it left the settings and the
buttons stuck in the top-left corner.
The settings now take their width and height from the resizable area,
leaving room for the button row, and Preferences opens at 1040x800. The
settings area stays a few points short of the space it is given: egui grows
the area to fit its contents, so asking for more would make the dialog grow
every frame.
* Preferences: a rendered test drags the resize grip and checks the buttons stay in the corner (#7420)
Opens Preferences in a kittest harness, drags its grip wider and then smaller, and checks the button row stays in the frame's bottom-right corner each time and that the dialog keeps its size afterwards. Fails with the sizing from before the fix.
* Preferences resize test: the buttons must also stay inside the frame
A button row past the frame's bottom or right edge gave a negative gap and passed; now each gap must be 0 to 24 pt.
Conflict in crates/engine/src/filters.rs: main made keep_alpha pub(crate) for the
plug-in runner (#6770); kept the PR's parallel, cancellable keep_alpha with that
visibility, and the plug-in caller now passes a never-cancelled check and propagates
the Result.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Resolved commands.rs: keep main's finite-checked color_param(cmd, ...)
and this branch's angle_param for the gradient fill angle; drop the
now-unused f32_or.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- LayerBlend: a plain layer is blended straight from its texture in one pass
instead of a Content pass to a scratch chunk and a Blend pass.
- fs_layerbatch: runs of up to four plain layers that only feed each other
are drawn as one pass, reading and writing the chunk once (the uniform
record grows to its 256-byte stride to hold the extra layers).
- Drop shadows and outer glows alone: the merge builds the layer from its
shape (F_LAYER_SHAPE), and nothing is copied back when the effect region
covers the canvas. Such a layer takes 3 passes instead of 6.
- fs_layerbatch_normal: runs of Normal layers use a shader with Normal's code
alone. Per-pass timestamps showed the batch spent 80% of its time on the
register cost of carrying every blend mode, not on the arithmetic; Normal
goes through the same function in every shader, so a layer gives the same
bits whichever pass draws it.
P60 / P61 with the GPU compositor (8 GB budget, M4 Pro): 111 / 113 ms ->
28.2 / 28.1 ms. With the app's default Memory Usage these documents still
fall back to the CPU compositor on unified memory, so the budgets stay
unenforced.
Output: over the GPU parity suite (1620 renders) 1038 are bit-identical to
before and the rest differ by at most 1e-4/255, except one pixel one 8-bit
step apart where an adjustment rounds at .5; the mean difference from the CPU
reference is unchanged. New parity tests cover runs of every mode, Normal
runs at 8/16/32-bit and Lab, and exterior effects at any fill.
A pixel layer with nothing clipped to it is read from its tiles a row at a
time and blended straight onto the backdrop, instead of being copied into a
tile-sized buffer first (a megabyte per layer per 256² render tile). Normal
RGB rows go through a branch-free kernel over blocks of 8 pixels that the
compiler vectorizes; few-band renders also run their bands in parallel.
Bit for bit the same: the row kernel is tested against the scalar loop (NaN,
infinities, subnormals, signed zeros), the row path against the tile copy at
8/16/32-bit with every kind of mode, fill and mask, and the whole GPU parity
suite's CPU references (1620 renders) are unchanged.
P60 on the CPU compositor: 627 -> 530 ms (the render without effects
300 -> 199 ms).
same_format took everything after the last dot of the whole path, so a file name without an
extension (or a dot in a folder name) gave a bogus output extension and the documented PNG
fallback never ran. It now uses file_cmds::extension on the file name.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
#2455 stretched the pro Color panel's SV field and hue strip to the dock
group's height, but only the legacy dock told the field that height. The
shared docking renderer from #3097 drew the panel without it, so once the
layout was customized the field fell back to 120 pt. Both docks now offer
the group body's height through one helper.
`composite_ms` measures encoding and submitting a composite, not running it: on
a 24 MP refresh it read 7.7 ms for work the GPU spent ~171 ms on, so nothing in
the app could see a regression that moved work onto the GPU.
A timestamp is written on the GPU timeline at the start and end of every command
encoder a refresh submits, and the spans are summed and read back
asynchronously. Nothing waits, so a measurement arrives a frame or two later and
carries the pixel count of the refresh it belongs to. It is reported as a new
`Option<f64>` `perf.gpu_ms`; `composite_ms` keeps its meaning, so its consumers
are untouched. At 24 MP it reports 83.9 ms against a waited wall clock of
88.4 ms, where the app previously said 3.1 ms.
What it leaves out, documented in the module: queue-level uploads, which wgpu
runs in its own command buffer ahead of ours, and the end-of-batch writeback
after the last timestamp. That is ~5 % of the reported time here and
proportional to the composite, against the ~22x of the number it replaces.
Unavailable rather than wrong: without TIMESTAMP_QUERY and
TIMESTAMP_QUERY_INSIDE_ENCODERS the timer is never built and `gpu_ms` is None —
on older drivers, the CPU compositor, wasm, and Apple silicon, whose GPUs
support counter sampling only at stage boundaries. `PHOTOCRAFT_NO_GPU_TIMING`
suppresses both the timer and the feature request.
Pass-boundary timestamps would cover Apple silicon and need only
TIMESTAMP_QUERY, but they capture 70-78 % of the encoder-level span here and
the shortfall grows with document size, so they were measured and rejected.
D3D12 is exercised: device creation, a 100 ns/tick period and the full
write/resolve/map round-trip succeed with no validation errors, including
across a flushed three-buffer refresh. Numbers throughout are from an
integrated Intel GPU on Mesa/Vulkan and a software D3D12 adapter; no vendor
Windows driver and no Apple hardware was tested.
The dialog's histogram fitted its tallest interior column to the plot, so one spike (a dark sky, a bright wall) flattened the midtones' hill to a few pixels. Camera Raw 16.1.1 scales linearly by the typical columns and lets a spike run off the top (measured on Photoshop 25.4: 49 px per 10 000 pixels in a 106 px plot, the 95th-percentile column at 14 546, the tallest at 21 600).
`rgb_histogram::Spikes`: `Fit` (the old behaviour, every other histogram keeps it) and `Clip` (the scale is at most 1.5 × the 95th-percentile column, taller columns clip at the top). The Camera Raw dialog's histogram uses `Clip`.
Test: `camera_raw_scale_lets_a_shadow_spike_run_off_the_top`.
Split out of #3816 at the reviewer's request.
Co-authored-by: Claude Fable 5.1 <[email protected]>
`develop::tone` interpolates the camera curve logarithmically between knots, and a curve whose first knot sits at black (0, 0) made the first segment divide by log₂ 0: every value in it came out NaN, and `retone` (the open-time Shadows/Highlights preview) propagated it into the pixels. From a knot at black the segment is now linear, where log₂ has no value; the other segments are unchanged.
Test: `retone_lifts_shadows_with_a_curve_from_black` (finite output, shadows lifted, highlights kept) fails without the fix.
Split out of #3816 at the reviewer's request.
Co-authored-by: Claude Fable 5.1 <[email protected]>
The shortcut pass runs before the panels are drawn, and its crop/polygon
branch consumed Esc and Enter even while an egui popup of the options bar
was open (the Ratio list, the overlay and gear menus): Esc cancelled the
crop and left the list open, Enter committed the crop beneath it. Skip the
branch while `egui::Popup::is_any_open` (kept in memory from the last
frame; `ctx.any_popup_open` only sees the current pass).
Co-authored-by: Claude Opus 5.5 <[email protected]>
The stroke preview was white at a quarter opacity, invisible on white
and faint elsewhere. Photoshop shows the area to heal in half-opaque
dark grey; the trail now does too. Other retouch trails are unchanged.
Test: drag_preview_canvas::the_spot_healing_trail_is_a_dark_half_opaque_tint
drags the real canvas offscreen on white and mid-grey and checks the
trail darkens the pixels to 40-60 %; it fails with the old colour.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Sampler points are pixel indices, but rotate_arbitrary mapped them as the
pixel's top-left corner while the warp moves pixels by their centres, so
after a turn a point sat one pixel off its pixel, or fell off the canvas
(a corner point at 90° or 180°). Map the pixel centre, as #6720 does for
flips and right-angle turns; the turned Crop shares this map.
Co-authored-by: Claude Opus 5.5 <[email protected]>
rotate_arbitrary read `angle` as f32. rotated_crop sizes its canvas with
rotated_size at f64 and passes the angle through JSON; near a whole-pixel
ceil boundary the f32 angle grew the canvas 1 px more (1000x1000 at
0.11470651449337071: 1003 instead of 1002), shifting the crop by 1 px.
Co-authored-by: Claude Opus 5.5 <[email protected]>
A fill layer from a PSD keeps its stored pixels as a fill cache that covers
only the old canvas. Canvas Size, an extending Crop and Reveal All translated
that cache but kept it, so the added area of the fill layer stayed
transparent. Drop the fill caches whenever the canvas grows, so the fill
re-renders from its model over the whole canvas, as Photoshop does (Free
Transform and Perspective Crop already drop it).
Co-authored-by: Claude Opus 5.5 <[email protected]>
The + button in Window › Tool Presets stored the whole ToolOptions struct, so choosing
the preset later wrote every other tool's options back (e.g. Crop's Delete Cropped
Pixels). Reset Tool's per-tool field lists become `option_keys`, which the + button now
uses to store only the tool's own options. Brush/Pencil Clear, the Eraser's mode, the
Remove tool's options and Content-Aware Move's Transform On Drop join their tools' lists,
and a test checks that every option belongs to a tool.
Co-authored-by: Claude Opus 5.5 <[email protected]>
The copies were made with Document::new and kept the mode but not the
duotone inks, so a Duotone scan came out as plain gray. Copy them, and
grey the command out for Indexed Color documents, as Photoshop does.
Co-authored-by: Claude Opus 5.5 <[email protected]>
The rotation and instance-log tests in logging.rs, and two app_dirs migration-lock tests, drop a
flock and check straight away that it is free. A child that another test spawns at that moment
holds a copy of every open descriptor until its exec, the lock included, so the check sometimes
saw it still held (about 6 failures in 15 runs of the app's test binary). Tests that spawn
children (the portal mock's dbus-daemon, the display-profile helper test) now hold CHILD_SPAWNS
for reading while they spawn, and the lock tests hold it for writing.
The portal mock made its message stream inside its thread; a stream only gets messages that
arrive after it exists, so a SaveFile sent before the thread got going was lost and the test
waited forever (about 1 run in 25). The stream is now made before the thread starts.
Co-authored-by: Claude Opus 5.5 <[email protected]>
apps/photocraft/src/main.rs: #6499's parse_launch_args is the one parser; it also takes main's --help/-h, -- and unknown-option errors (#6435); usage_exit_code is the one exit helper
* Document tabs: keep whole names, hide what doesn't fit, list every document in the » menu (fixes#4487)
* Document tabs: check the active document in the » menu (#4487)
Like Photoshop's, the » menu marks the active document with a check mark,
drawn as the menu bar's menus draw theirs.
---------
Co-authored-by: Brandon Thomas <[email protected]>
* fix(format): saving onto a dangling symlink writes through to its target (#6605)
resolve_symlink fell back to the link path when canonicalize failed, so an
atomic save renamed over the symlink and replaced it with a regular file.
Dangling chains now resolve lexically (relative targets included, with a
depth cap), so the bytes land at the target and the link survives; a
symlink loop is a clear error instead of a silent link replacement.
* Follow dangling links without folding .. by text
A folder on the way may itself be a symlink, so leave .. for the OS to resolve.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: highperfocused <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Eighteen labels in ko.tsv used a different word than the rest of the
Korean catalog for the same term, or named a menu item by a label the
menu does not use (Reset All to Defaults reads 모두 기본값으로 초기화).
Only translation values change; placeholders and ellipses are untouched.
Co-authored-by: Claude Opus 5.5 <[email protected]>
- Both dialogs get the Preview checkbox (`PREVIEWED`).
- The canvas shows a filter preview through the preview's own colour management (its profile and
mode), not the committed document's: Assign Profile changes only the profile, so its preview
looked unchanged. The preview keeps the document's id, so Proof Colors still apply.
- Previews run with the app's Color Settings: `working` is the chosen working space, not the
built-in default.
- The `bpc` checkbox (Convert to Profile, Proof Setup) reads "Black Point Compensation" instead of
"Bpc"; every catalog already translates it.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Menu rows prefixed their label with "✔ ", three spaces or nothing, so
unchecked toggles (Lock Guides, Lock Slices) sat at a different x than
checked and plain rows, most visibly in CJK translations. Every row,
submenu headers included, now reserves one fixed-width checkmark column
(the width of "✔ " in the button font) and draws the mark in it.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* OpenRaster: layer locks no longer change silently on save (#7018)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* ORA: the Background's position lock is not reported as lost (#7018)
Every new document's Background carries a position lock, so the warning made a
plain .ora unsavable in place.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
The Elliptical Marquee used a dashed circle and Quick Selection a brush
inside a dashed circular arc, so the two read alike. Photoshop draws the
marquee as a horizontal ellipse and Quick Selection as a brush in an
upright dashed rounded C. New original ellipse-dashed.svg; quick-selection.svg
redrawn; tests check the ellipse is wider than tall and the C runs down
the left edge.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Found while testing crates/engine/src/commands.rs:117-120 (turn) with crates/engine/src/canvas_geom.rs:224 (Turn::affine); applied in crates/engine/src/lib.rs:807-814 (Session::edit)
Co-authored-by: Claude Haiku 5.5 <[email protected]>
With Remove after each stroke off, every finished stroke was fed into the
one shared tint trail, whose point counter still held the previous
stroke's length, so later strokes were stamped only past that count.
Trail::feed_stroke starts each new stroke's count at zero.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
A tall menu reopens where it was last scrolled to, so after reaching Edit ›
Preferences › Settings… or Window › Theme its top rows lie hidden above the
view, over the menu bar. egui opens a submenu whenever the pointer is over its
row, clipped or not: the hidden submenu row under the title opened on the
press, and the release on the title counted as a click outside that submenu,
which closed the whole menu. Hovering the top scroll arrow scrolled the menu
back up, which is why that "fixed" it.
Submenu rows are now drawn like `ui.menu_button` but skip egui's submenu
logic while the pointer is over a part of the row that is scrolled out of
view (an already open submenu, e.g. from the keyboard, is drawn as before).
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
composite_rgba8 removed the white matte from 8-bit RGB composites with
one integer division per colour sample (3 per pixel, ~108 million on a
36 MP file). The result depends only on the sample and its alpha, so the
same expression, now unmatte_u8, fills a 256 x 256 table once per call
and each sample is a lookup. Built per call, not as a static, so no
binary (wasm included) grows by the table.
The new test runs all 65 536 (sample, alpha) pairs through
composite_rgba8 against the replaced loop, kept verbatim as the
reference.
photocraft_psd::ase::parse counted colors toward MAX_SWATCHES but not
group starts, so the number of groups it read grew with the file's
size. An empty group start is six bytes, and parse held about 64 bytes
for each: in a test build, parsing a 60 MB file of 10,000,000 empty
group starts raised the process's peak memory from 63 MB to 705 MB.
swatches.import reads files of up to 512 MiB, and read_file built a
group for each group start before the import refused more than 10,000
groups.
parse now counts group starts and returns LimitExceeded past
MAX_GROUPS (10,000), the most groups the Swatches panel holds. Files
with at most 10,000 groups parse as before, so every file the import
accepted still loads. Files with more were refused before as well; now
parse refuses them before it holds more than 10,000 groups. write also
refuses more than MAX_GROUPS groups, as it refuses more than
MAX_SWATCHES colors.
ase_group_cap_is_enforced_on_hostile_input parses 10,001 empty group
starts and expects LimitExceeded, then parses the same file with one
block fewer and expects all 10,000 groups. too_many_groups checks that
write refuses 10,001 groups and that 10,000 groups are written and
read back.
Frames past the cap collected nothing, so the layers under dividers that
are never closed were lost once those dividers nested past max_depth,
and nothing was left to hoist at the end. A first pass now matches
dividers to folders and only closed dividers open a frame, so every
frame's depth is the final depth of its nodes and the capped tree equals
layer_tree cut at max_depth, still at most max_depth + 1 levels deep.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Edit > Presets > Preset Manager renamed and deleted brush presets directly, so a rename
left the current preset pointing at the old name (brush.presets.update then failed), accepted
a case-insensitive duplicate name, and a delete left the current preset dangling. It now uses
the same rules as brush.presets.rename / brush.presets.delete through two small shared
helpers (rename_at, forget_deleted_preset). Patterns and custom shapes are unchanged.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* Document tab says "Quick Mask" in Quick Mask mode, as in Photoshop
In Quick Mask mode, painting and Free Transform target the mask, not the
layer. The tab still read "(Layer 1, RGB/8)", so a user who pressed Q by
accident and then rotated saw the red overlay turn with the box and the
layer stay put, with nothing on screen saying why. The tab now reads
"(Quick Mask/8)", the same condition `paint_target` routes on.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Credit arnav-hiwarkar in the About window
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Keep contributors/people.toml as on main
The branch removed another contributor's entry; the added entry only repeated the username.
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]>
Found while testing crates/engine/src/notes_cmds.rs:105
Co-authored-by: Claude Haiku 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
The probe read the FileChooser `version` property and only counted a few D-Bus
error names as "absent". zbus wraps property errors in `Error::FDO`, and a portal
with no FileChooser backend doesn't export the interface at all, so the read fails
with `InvalidArgs` ("No such interface"). Neither matched, the probe said
"inconclusive", and Save As / Export did nothing without the "No file dialog can
open" notice.
Introspect the portal object and look for the interface instead, and treat
InvalidArgs, UnknownMethod and NameHasNoOwner (as `FDO` or `MethodError`) as absent.
Fixes#5739
Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu
Co-authored-by: Claude Opus 5.5 <[email protected]>
With no text on the clipboard (an image copied from a browser, a file from Explorer) egui-winit
swallows the Ctrl+V press and sends only the V release; the paste then ran only when that release
still carried Ctrl. Letting go of Ctrl first, as many people do, pasted nothing, so Ctrl+V seemed
to need several tries. A V release whose press never arrived, after Ctrl was held and before any
other key, is now that swallowed Ctrl+V and pastes once. A plain V tap (the Move tool) and the
web build, which always gets the key-down, are unchanged.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Keep backend defaults within supported limits while preserving the
adapter's texture and buffer maxima. Add regressions for V3D shader
variables, weaker GL adapters, minimum alignments and capable devices.
* Krita: uncompressed tiles are read as interleaved pixels (#7082)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* kra: the agent-surface test writes its raw tile interleaved, like Krita (#7082)
The krita.rs fixture wrote its flag-0 tile as byte planes, which only the old
decoder read back correctly.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Snap To Layers built each layer's edges and centre from transform_bounds, which clips the
layer to the selection. With a selection, every layer's targets were the selection's own
bounds, so dragging the outline with a marquee snapped it back to where it started (shown
as layer alignments) and beat nearby guides and document edges. Layer targets now use the
layer's whole content bounds, as in Photoshop; the moving box (layer_rect) is unchanged.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
They acted inside a circle fitted to the bounds' shorter side, so on a wide
or tall image only a central disc changed. Like Photoshop's, they now act
inside the ellipse inscribed in the bounds (selection or layer), reaching all
four edges; square bounds are unchanged.
Co-authored-by: Claude Opus 5.5 <[email protected]>
The pixel-grid walker stepped between the pixels holding each segment's ends. Smoothing splits
a Shift-click line into pieces along it (so does the pointer on a Shift-drag), and snapping every
piece to its own pixels bent the line into a jagged staircase; rounding the half-pixel ties away
from zero also made the line depend on which end it started from. The walker now samples the
segment's own line at each column (or row) centre and resolves ties on the pixel grid, so the
pieces of a straight line paint exactly the pixels of the whole line, both ways.
Co-authored-by: Claude Opus 5.5 <[email protected]>
* GPU: a group's effect cache follows its vector mask and children's Blend If (#5706)
group_key, which decides when a styled group's effect shape and distance
fields are rebuilt on the GPU, hashed the group's children and layer mask
but not the group's own vector mask, nor each child's Blend If and
Advanced Blending. A group's Stroke or Drop Shadow kept the old outline
after those changed. Hash them, as the CPU's layer_identity does.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
* GPU: pass the pin callback in the group key test after main's signature change
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Covers #5044's two names: BMDoHyeon (family "BM DoHyeon", guessed "BM Do Hyeon") and Daum_SemiBold (guessed "Daum_Semi Bold"). Both resolve to their installed faces and aren't reported missing on main, through the exact PostScript lookup (#1844) that missing-font checks follow since #608.
Co-authored-by: Claude Opus 5.5 <[email protected]>
fontSize is in points and type.create renders it at resolution/72 px
per point, but the bar height, label height and baseline treated it as
pixels. At 300 ppi a 12 pt label (50 px) overlapped the bar. Lay out in
font_size * dpi / 72 px; type.create still gets points. 72 ppi is
unchanged.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
paste_lorem decided whether to insert into existing text from the active
layer, while with_text edits the layer the "layer" param names. With a
pixel layer active, {"layer": T} made a new paragraph layer and left T
unchanged. An explicit "layer" now always goes through with_text, so a
type layer gets the text and any other layer is an error; without
"layer" the behaviour is unchanged.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
The "Open color-less EXR as ACEScg" option was only applied by file.openAs; background
opens (File > Open, drag-drop, Open Recent via Session::start_open) and file.openExrParts
kept io's linear-sRGB default. Both now retag through tag_exr_colorspace, start_open before
the Ask When Opening prompt so it sees the ACEScg profile.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
* fix(atn): a Paint Bucket step keeps its explicit colour through .atn export and import (#7199)
* test(atn): an explicit Paint Bucket colour survives an .atn round trip (#7199)
* fix(engine): flip and rotate presets turn about the selection's bounds, as Free Transform does (#6928)
* test(engine): cover flips and turns about the selection (#6928)
Found while testing crates/engine/src/variables_cmds.rs:285-292 (import_data_sets_text; the file is read in import_data_sets at line 279)
Co-authored-by: Claude Haiku 5.5 <[email protected]>
Found while testing crates/algo/src/camera_raw.rs:592 (value taken unchecked in crates/engine/src/lens_cmds.rs:137 raw_params)
Co-authored-by: Claude Haiku 5.5 <[email protected]>
Duotone::render used the raw curve output, so ink curves above 100 % pushed the
simulated print out of gamut (red + blue at full density showed green instead of black).
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Screen as 1 - (1 - c)(1 - a) pulls a 32-bit value above white down
towards 1.0 (2.0 with a = 0.5 became 1.5, the core became 1.0). Add
a·max(1 - c, 0) instead: the same result for c <= 1, unchanged above.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Image > Trap wrote the active layer's pixels without checking locks, so a
layer under Lock All or the pixel lock (its own or its group's) was trapped
anyway. Check check_pixels_unlocked before writing, like the fill commands.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
#6462 left Mean, Variance, Standard Deviation, Skewness and Kurtosis 5–15%
slower than the old serial loop on one thread. The cause: `stat` matched on
the mode three times per pixel inside one shared loop. `combine` now picks
the statistic once and hands `combine_with` a closure with a constant mode,
so each statistic gets its own loop with the match folded away. The sample
buffers are sized to the layer count and filled by index instead of `push`,
which re-checked the capacity per sample.
Every output is bit for bit the same (121 hashes match main).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FUAqfMqVHdoeDa2y68KQQw
Reveal in Finder on Linux opens the linked file's parent with xdg-open,
which launches whatever it is given. A link stored as `tool.jar/a.png`
names a file as its parent, so Reveal launched that file. Reveal now
refuses a parent that is not a folder.
Co-authored-by: Claude Opus 5.5 <[email protected]>
`damage_since` had a case for the Image > Adjustments dialog but none for the
Properties panel's `live_adjust` preview, so every parameter change returned no
damage and recomposited the whole document — `gpu-full` at 24.0 MP.
That is what made #5778 a latency regression at 100 % zoom: it correctly stops
CPU-compositing a 1.5 MP proxy, but the full-size GPU composite replacing it is
per-pixel work and 24 MP of it costs more. The Properties path now gets what the
dialog had: a session-tagged key, the layer's area as damage, and of that only
what the view shows, catching up as the view moves.
Intel UHD P630, 24 MP document, 12 changes, medians:
v0.9.0 70.3 ms 1.5 MP CPU proxy
main 203.1 ms gpu-full, 24.0 MP
this 47.8 ms gpu-rect, 1.10 MP
Byte-identical to the full-refresh base on all 12 changes and across a pan.
25 % zoom and PHOTOCRAFT_CPU_COMPOSE are unchanged.
`live_region` does not narrow by the layer's mask: a reveal-all mask keeps its
coverage in the default pixel and reports empty content bounds, which emptied
the damage and froze the preview. Every adjustment layer has one.
* Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Cargo.lock: photocraft-ipp at 0.10.0
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
`compose::flatten` returns an empty buffer for canvases over MAX_RENDER_PIXELS.
Trim (Top Left) indexed `px[0]` of it and Duplicate › Merged Layers Only fed it
to `write_region`'s length assert: both panicked; Trim's other modes reported
"nothing to trim to". Both now return a "too large to flatten" error first.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Testing on a 6000 × 4000 document found the proxy's sampling state computed
per pixel against every stroke segment: each stroke got slower, and an undo
tested 1.7 M pixels × 3000 segments on the UI thread, blocking it for over a
minute. The proxy is now painted as the engine paints the full-resolution
mask: the option's area and the fill area are sampled once at the proxy's
pixel centres (set_base), and each stroke is painted over them in order,
visiting only its own pixels (paint, restate_all), so a dab costs its segment
and a rebuild costs the strokes' areas.
Also from the same review:
- `edit.contentAwareFill {"ui": …}` with the workspace closed is an error
instead of a fill with the defaults (an agent polling the state after an
Esc added a fill per poll).
- The view sends one Down per press: the press is the stroke's first point
and the frames before egui's drag threshold are its next ones, instead of
drag_started() beginning the stroke over (the lasso lost its first points
the same way; the Zoom tool zoomed on every frame the button was held).
- The preview texture is box-averaged to the device's max_texture_side when
the plan's rectangle exceeds it (a 17000 px wide plan gave a validation
error every frame).
- A preview that fails counts as shown for that state (Preview::finish), so
it is reported once instead of restarting on every repaint.
- The view shows the document's visible layers composited (an Apply to a new
layer shows); the engine's limits on strokes, lasso edits and points hold
at the brush with a message instead of failing Apply; the footer is
Photoshop's reset, OK, Apply, Cancel on every platform; a leftover
`let _ = i;` is gone.
Tests: strokes painted once and equal to the engine's sampling area; `ui`
with the workspace closed; the first points of a stroke and a lasso through
egui_kittest; the preview texture at a 2048 limit; a failing preview
reported once; the view after Apply to a new layer; the limits at the brush.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
When a type layer's text changed, build_txt2 replaced the layer's object in
Photoshop's own Txt2 block with a bare `<< /0 << /0 text /6 runs >> >>`:
no paragraph runs (/5), no view (/1, which names the object's frame) and none
of the style keys. Photoshop 27.10 quits while opening such a file
(layer_effects.psd after type.edit), as it refused stripped objects in #2374.
An edited object now starts from the file's object at its slot: the text is
replaced, the paragraph runs are remade one per paragraph of the new text,
and every other run array of the model (style runs, ...) is cut or extended
to the new length before the auto-kern modes are applied. The view and other
keys are kept; only the old text's glyph pen positions (/21) are dropped.
An object whose run arrays aren't shaped as Photoshop writes them still falls
back to a fresh one.
Test: text edited_text_keeps_the_files_txt2_object.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The hue2 block holds a colorize triplet at byte 4 and a master triplet at
byte 10. The writer copied the active triplet into both, so a Colorize hue of
200 also landed in the master slot (outside its -180..180 range) and toggling
Colorize in Photoshop showed the wrong values.
Write the active triplet at its own offset and Photoshop's defaults in the
other (colorize 30/25/0, master 0/0/0), with the colorize hue stored signed as
Photoshop does (200 -> -160). The reader maps a negative colorize hue back to
the model's 0..360.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`file.package` absolutised the caller's `dir` and then built `folder`, `document`
and every `links` entry on it with `join`, which always writes `/`. On Windows
that left one string spelt both ways:
`C:\Users\...\pkg/Print Me/Links/art.png`. The control channel takes `/` and
rejects alternate separators, so a reported path could not be handed back to it.
Spell the absolutised directory with `/` as well, keeping a leading `\\`
(verbatim and UNC prefixes, where Win32 does not accept `/`) intact.
Unrelated to this branch's change; main already fails clippy --all-targets -- -D warnings
without it, which is what failed the Windows integration job here. One
clippy::nonminimal_bool site in canvas.rs, taking clippy's own rewrite: the
negation stays outside, so the is_finite guards and their NaN behaviour are
unchanged. A sweep of all 30 workspace members found no other site.
With the `auto` backend on Windows, eframe created its wgpu instance with
Vulkan, and creating it, listing its adapters and asking each adapter
about the window's surface loaded and ran the Intel Vulkan driver
(igvk64.dll) before the adapter selector could pick Intel's DX12
adapter. When the driver crashed there, the first launch died, and only
the next one moved to DX12 through the startup marker.
`configure` now lists the DX12 adapters first, with a DX12-only
instance, when the plan is `auto` on Windows without WGPU_BACKEND. If
one of them is an Intel adapter, the instance is created with DX12 and
GL only (GL stays the last resort), so no Vulkan driver is loaded. On a
system with an Intel and a discrete GPU, the discrete GPU then runs on
DX12 instead of Vulkan. WGPU_BACKEND, --safe-gpu, a chosen backend and
the crash fallback are unchanged, and System Info still shows the Intel
DirectX 12 note.
The decision is `windows_auto_backends`, tested with vendor lists.
Main prints through craft-print, which runs `lp`; a Flatpak has no `lp`,
so File › Print fails there. This keeps craft-print as the route wherever
`lp` is on the path (and on Windows) and adds two more behind it:
- `photocraft-ipp`, a pure-Rust IPP client (CUPS-Get-Printers/Classes/
Default, Print-Job over HTTP/1.1) that talks to CUPS over its socket or
TCP; the Flatpak manifests grant --socket=cups for it. A `printServer`
named in the request always takes this route. `file.print.printers`
lists the queues.
- The XDG Print portal as the app's spooler (`Session::print_spooler`),
for a sandbox without the CUPS socket or a system without a printer:
the system print dialog, seeded with copies, paper and orientation.
PHOTOCRAFT_PRINT_PORTAL=1 prefers it, =0 removes it.
A route that exists but fails (unknown printer, refused job) is an error;
only a missing route moves on. Results gain `backend` (lp, windows, ipp,
portal). docs/printing.md describes the routes.
Review follow-up on #6395:
- Check that the DLL actually loads (winsafe `LoadLibrary`, no unsafe) rather than that the
file exists: a DLL that is present but cannot load still aborts at the delay-load stub.
- The hint no longer says to clear Print Settings; that doesn't take a page off the PDF path.
It now points to Save as PDF, or printing an RGB image without page text or labels.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Import Brushes refused .abr files over 512 MiB: the engine's preset file cap
and the .abr decoder's sample budget were both 512 MiB. Raise both to 1 GiB
for brush files only; gradient, swatch, action and image-tip imports keep the
512 MiB cap. A compile-time assert keeps the reader and decoder caps equal.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Web: Place Embedded and canvas drops place images (#5749)
File > Place Embedded was disabled in the browser (the engine command reads a
path), though the menu path reads the picked file's bytes. Files dropped on
the canvas were always opened as new documents. The web shell now tracks the
drag position, and drops on the canvas are placed from their bytes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Format (cargo fmt)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Narrow to the web canvas-drop half: Place Embedded enablement landed in #5585
Drop the picker test that covered the part #5585 now owns.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
craft-libs#169 is merged, so the fork pin is no longer needed. Pinned to
storytold/craft-libs 82a24b006eefa036b43c09a67fb9a3fbd17ffe63 (main after the merge) rather than the older 12281d07,
which predates resolve_absolute and the Windows colon refusal.
logging::rotate renamed photocraft.log up the chain on every start without
knowing whether another instance still wrote to it. The running instance
kept its handle, so after a second launch its live log was .1.log, after a
third .2.log, and a fourth launch deleted it.
The instance that writes photocraft.log now holds an exclusive lock on
photocraft.lock until it exits (a separate file, since Windows locks are
mandatory and would keep the log itself from being opened). A start that
finds that lock held leaves the chain alone and writes
photocraft-<pid>.log, marked by photocraft-<pid>.lock. Those side-by-side
logs are pruned to the newest KEEP once their instances have exited; a
held one is never touched. Rotation and pruning run under rotate.lock so
two starts at once can't interleave. On a file system without locks every
start rotates as before.
* fix(desktop): `photocraft --help` prints usage; unknown options are usage errors (#6431)
The desktop binary's argument loop took every argument it didn't know as
a file, so `photocraft --help`, `-h` or a typo such as `--safegpu`
opened the window and logged "Couldn't open --help".
- `--help` / `-h` print the usage (every option and environment
variable) and exit 0.
- Any other argument starting with `-` (except macOS `-psn_`) is named
on stderr and the launch exits 2 before the log file or window, along
with any bad `--control` port (`control_args_exit_code` becomes
`usage_exit_code`).
- `--` ends the options, so a file whose name starts with `-` opens.
- `--help` and `--version` write without `println!`, so a closed pipe
(`photocraft --help | head -1`) can't panic.
Tests: apps/photocraft/tests/command_line_usage.rs runs the binary with
no display; 4 of its 6 tests fail on the old code.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* test(desktop): usage tests exit before the window opens; fmt smart_filter_authorization
`arguments_after_double_dash_are_files` and `macos_process_serial_number_is_still_ignored`
passed only valid arguments, so the app went on to open its window. Removing DISPLAY makes
that fail fast on Linux, but Windows runners have a desktop and both tests hung until
nextest's 600 s timeout. They now add a bad `--control` value so the launch always fails
with the usage errors, and assert stderr names only that error (no "unknown option").
Also `cargo fmt` crates/automation/tests/smart_filter_authorization.rs, left unformatted
on main by #5684, which failed the Format step of every PR's ubuntu test job.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Image Assets: two layers naming the same file no longer overwrite each other (#6614) [WIP]
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Image Assets: two layers naming the same file no longer overwrite each other (#6614)
generate_assets wrote every requested asset, so two layers (or two
default variants) naming one file silently replaced each other and the
result listed the file twice. Each file is now written once per run:
layers go top first, as the Layers panel lists them, the first writer
keeps the name, and later ones are reported in errors. Names are
compared ignoring case, since Logo.png and logo.png are one file on
Windows and macOS.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Saving writes a temporary file and renames it over the destination. A
rename only needs the folder to be writable, so on Linux and macOS a
read-only (chmod 444) file was silently replaced, while on Windows the
rename failed with a vague "locked or read-only" access error.
atomic_write now asks the AtomicFs whether the existing destination is
read-only (new `is_readonly`, false by default for other implementors)
and refuses before anything is written: "cannot save <file>: the file
is read-only; the original file was not changed". New files and
writable files save as before. Photoshop refuses the same way; the
user saves a copy with Save As.
Tests: a fake AtomicFs reporting read-only gets the error with nothing
written or renamed; a real file marked read-only keeps its bytes and
mode and gets the clear message on every platform.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
With Export Preferences › Location = Same Folder, Quick Export wrote
<name>.<format> beside the document's file with no check, so a document
opened from photo.png was exported over photo.png itself. The app now
opens the save dialog for that case, and file.export.quickExport refuses
it without a `path`. Overwriting an earlier export beside a PSD is kept.
Adds file_cmds::same_file (text, dev+inode on Unix, case-insensitive
canonical path on Windows).
Co-authored-by: Claude Opus 5.5 <[email protected]>
Both filled the Background's uncovered area with the background colour
including its alpha (tools.setColors accepts one), leaving see-through
pixels on a Background. They now use alpha 1, as crop_doc does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
size_params converted points at a hard-coded 72 per inch. It now uses
Preferences › Point/Pica Size, as the rulers, the type tools and the
Crop tool do.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
commit_crop cleared the box Undo, took the frame, turned the view upright
and reset the angle before running image.crop, and restored nothing when
the engine refused it (e.g. a resolution past 10 000 ppi). It now keeps
the frame, its turn, its box Undo/Redo and the view and puts them back on
an error. Leaving the tool with a refused frame cancels it instead of
retrying every frame.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
perspective_crop_ui::cancel_stale ran only on a pointer event or a
commit, so after a tab switch the other document's quad was drawn (and
reported) until the next press. sync_views now runs it next to the Crop
tool's cancel_stale.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The fast local Laplacian filter upsampled every remapped level in full for
every reference (up to 96), but each pixel reads that level for at most two
references. `llf` now samples it only where the weight is nonzero, with
`up_at` (same terms, same order as `up`; row taps found once per row,
`mirror` only near the edges). `quantile` selects its element instead of
sorting the whole work copy (5 sorts per render, serial).
36 MP dialog, all cores: Shadows 252 -> 168 ms, Highlights 275 -> 184 ms,
all 14 sliders 627 -> 411 ms. Output hashes match main.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FUAqfMqVHdoeDa2y68KQQw
The Straighten branch of crop_ui::pointer returned before the press step
was recorded and pushed, although it turns the frame and makes it
pending. With a crop pending, Undo then undid the document (or skipped a
box step). It now records the step like every other frame gesture,
through a shared push_press_step.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
image.imageSize set the new resolution and then resampled, which scales
the type transform by the pixel factor and re-renders at the new ppi
(text px = pt x dpi / 72): doubling 72 -> 144 ppi made text 4x as tall.
With Resample off the pixels stayed but the next re-render doubled them.
canvas_geom::set_resolution now changes the resolution keeping type the
same size in pixels, as Photoshop does: text space grows by new / old, so
the box and path scale with it and the layer transform undoes that.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A straight drag (no width or no height) made a zero-area quad that could neither be committed nor replaced by a new drag, only cleared with Esc. Such a drag now leaves no box.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Saving writes a temporary file and renames it over the target, so everything
attached to the old file went with the file that was unlinked. Several of those
are security decisions, so a save quietly undid them: a download lost its Mark
of the Web, an explicit ACL fell back to whatever the folder hands down.
New crates/format/src/atomic/meta.rs, reached from atomic_write through two
AtomicFs methods (copy_permissions became copy_metadata; replace_existing is new
and defaults to rename, so the failure-injecting test file systems are
unchanged):
- Extended attributes (#6603): flistxattr/fgetxattr/fsetxattr through safe
rustix, per attribute, best effort. macOS Finder tags, com.apple.quarantine
(the Mark of the Web's macOS counterpart) and Linux user.* all ride along.
Attributes the system owns refuse to be written and are skipped;
com.apple.decmpfs is skipped deliberately, since it describes storage the new
bytes do not have.
- macOS ACL (#7073): a new macOS-only exacl dependency (MIT).
fcopyfile(COPYFILE_ACL) is an unsafe fn and fclonefileat(CLONE_ACL) is
APFS-only and would mean writing every save as clone-then-truncate; the
reasoning is in docs/decisions.md. A file with no ACL is left alone.
- Windows (#7094, #7096): ReplaceFileW through winsafe's kernel feature (MIT,
no dependencies, a safe wrapper; the workspace forbids writing the FFI call
here). One call keeps the destination's DACL, its named streams including
Zone.Identifier, and its creation time.
- Automation write path (#7095): AuthorizedWorkspace::write copies the replaced
file's attributes through the two cap-std descriptors, never a path, so a
request path still cannot escape the write root. Permissions there remain
#4963; ReplaceFileW is path-based and so is not used on that path.
The order inside copy_metadata matters: attributes and ACL first, permission
bits last. Writing an attribute needs the destination open for writing, so
copying a 0444 mode onto the temporary file first locks you out of your own
temporary file. Covered by a regression test.
Atomicity is not weakened. The metadata step runs on the temporary file before
the swap, never touches bytes, and never fails a save. Windows is the one place
the swap itself changed, and a backup file name is passed because it is a
correctness requirement, not an option: ReplaceFileW without one documents
ERROR_UNABLE_TO_MOVE_REPLACEMENT as leaving the replaced file deleted and the
replacement under its temporary name, which is a failed save that loses the
user's document. With a backup name the same failure leaves both files alone.
ERROR_UNABLE_TO_MOVE_REPLACEMENT_2, the one error that does move things, is
unwound by hand; every other error falls back to the plain rename, i.e. to the
previous behaviour.
THE WINDOWS PATHS ARE UNVERIFIED ON WINDOWS. They were written and reviewed on
a Mac. The #[cfg(windows)] tests -- Zone.Identifier, a second named stream,
icacls output, creation time -- have never executed. They do compile and pass
clippy -D warnings against x86_64-pc-windows-msvc, which is as far as a Mac
gets. CI's Windows job is the first real run, and #7094 in particular (Mark of
the Web surviving a replace) is the whole point of the change and is the part
no one has observed working.
Tested on macOS: a real chmod +a entry and a real Finder tag both survive a
save (checked with ls -le and xattr, and as unit tests), plus a 64 KiB
attribute value, a read-only file, the mode, and the automation path.
Still open: Linux POSIX ACLs are not carried over (system.posix_acl_access is
not reported by listxattr, so it needs its own step; not part of these issues),
and macOS creation time is still reset by a save (that half of #6603 needs
setattrlist, which has no safe wrapper here).
arrow_keys_reset_caret_blink_phase (#5512) looked for a 1.5-wide line, the old caret. The caret is now CARET_STROKES (a 3 pt white halo and a 1 pt black line), so the test matches those.
`stat` raised each deviation from the mean to a power with `f32::powi`,
with the exponent passed through the moment closure. On
x86_64-pc-windows-msvc that call goes to the C runtime's `powf`, whose
cubes and fourth powers can differ from repeated multiplication in the
last bit. On Linux the same call gives the bits of repeated squaring, as
compiler-builtins' `__powisf2` computes it. Kurtosis
came out one ULP higher on Windows in two values of the test fixture, and
`stack::tests::statistics_are_bit_identical_to_the_serial_implementation`
failed there.
The moments and Skewness's `sd³` now use `ipow`, which multiplies by
repeated squaring: `x·x²` for the cube and `x²·x²` for the fourth power.
Results on Linux do not change, and the recorded bits stay as they are.
The full-registry fuzz (AGENTS.md Rule 9) is #[ignore]d and no workflow ran it, so nothing
enforced "must stay green". The new step reruns the Test step's nextest selection with
--run-ignored only, filtered to the panic_hunt binary, so nothing rebuilds; the ci profile
terminates it if it hangs.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A type render spends most of its time after the glyphs are drawn: composing each
colour's coverage over the premultiplied accumulator, dividing it back to straight
colour, and converting the tile bytes. All three are per pixel - no pixel reads a
slot another wrote - so they ran one after another on one core for no reason.
Measured on a 3.75 MP paragraph (4000 characters, 14 pt at 300 dpi, 1851 glyphs),
release, Windows 11, i5-10300H: rasterize 98.3-103.9 ms before, 60.3-61.8 ms after
(1.6-1.7x). Whole-render stage profile before: ink rect 4.1, draw 18.6-22.9, prefix
sum 7.0-8.1, composite 22.6-33.4, unpremultiply 11.2-14.0, write_region 23.8, prune
3.1 ms.
The glyph drawing, the winding prefix sum and the ink-rect pass stay on one thread:
the first two carry a running accumulator across pixels, and the band split that
would keep them bit-identical costs a second glyph traversal per band.
Native only (rayon, as the other crates): the web build keeps the serial loops.
`blocks` and `fnv` exist only for `harris_and_describe_hashes_recorded_from_the
_serial_code`, which is `#[cfg(all(target_os = "linux", target_arch =
"x86_64"))]` because the hashes it compares against go through the platform's
hypot / ln / cos / atan2 / sin_cos. Everywhere else the test is compiled out and
the two helpers are left unused, so `-D warnings` fails on `dead_code`:
error: function `blocks` is never used crates/algo/src/features.rs:704
error: function `fnv` is never used crates/algo/src/features.rs:708
They now carry the same cfg as the only thing that calls them.
CI's matrix is ubuntu-latest and windows-latest and runs clippy on both, so this
fails the Windows leg as much as a local run on macOS: `target_os = "linux"` is
false for both.
#5344 added the export_presets field to Persisted; #5616 rewrote load_presets_json to read each set with take() and its Persisted initializer lacked the new field, so main stopped compiling (E0063 at presets/mod.rs:180). Read export_presets the same way as the other sets; the existing load step and round-trip test cover it.
Co-authored-by: 00xJ0 <[email protected]>
The crop tool's W x H x Resolution mode converted points and picas at a
fixed 72 pt/in, while Image Size, Canvas Size (#5757), the rulers, Info
and the shape dialog use Preferences > Units & Rulers > Point/Pica Size.
With Traditional (72.27), W "72 pt" x H "1 in" at 300 ppi cropped to
300 x 300 instead of 299 x 300.
target() and ratio() now take the points per inch, and the callers
(commit_params, preset_ratio, fit_frame) pass the preference.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Stamp Down rebuilt the layer below from scratch and copied only its id,
name, blend mode, visibility and locks, so a clipped layer came back
unclipped (its pixels showing outside the clip base) and lost its colour
label and link. Photoshop writes the stamp into the existing layer; keep
the three properties as Merge Down already does.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A finite f64 like 1e39 became inf on `as f32`: zoom jumped to 100%,
rotation reset to 0, dockWidth fell to its minimum and center was stored
as [inf, 0], all with ok:true. A new f32_field (same filter as
rotate_view's finite_number) rejects these in the validate phase, so a
rejected call changes nothing. num_field stays as is: pointer events
must keep accepting huge coordinates.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With Resample on and the unit in inches/cm/mm/pt/picas, a new Resolution
only stored the resolution; width/height are kept in pixels, so the print
size shrank (720 x 576 px at 72 ppi, 10 x 8 in, set to 300 ppi stayed
720 x 576 px = 2.4 x 1.92 in). Photoshop keeps the print size and
resamples: 3000 x 2400 px. Scale the stored pixels by new/old resolution
in physical units; pixels and percent keep the pixel count as before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
decode_into computed out.len() + expected unchecked. With a non-empty
buffer and an expected length near usize::MAX it panicked in debug builds
and, in release, wrapped and returned Ok having decoded nothing. Compute
the target with checked_add and report LimitExceeded, and test each packet
as len > target - out.len() so the bound checks cannot overflow either.
The only callers are decode and decode_rle in this module, and the crate
README documents packbits::{encode, decode} as the public API, so
decode_into becomes pub(crate).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Mixer Brush could not be emptied. MixerState::clean dropped only
the pickup well, paint.mixerBrush refilled the reservoir from the
foreground before every stroke unless loadAfterStroke was false, and
the app always sent the defaults. A stroke on a new layer over an image
always carried the foreground color, and the options bar had no Load
Brush or Clean Brush control.
Cleaning now empties both wells, so a clean brush lays down only the
color it picks up from the canvas. The new commands brush.mixer.load
and brush.mixer.clean load and empty the brush. paint.mixerBrush reads
loadAfterStroke and cleanAfterStroke from the brush and applies them
after the stroke. Both are on by default, and a stroke with both on
paints as before. A stroke whose color differs from the one recorded
by the last stroke, load or clean loads that color first. A load or
clean records the foreground, so choosing a new foreground loads the
reservoir.
The options bar has the Current Brush Load swatch, whose menu holds
Load Brush and Clean Brush, and two toggles for the automatic Load and
Clean. Brush Settings shows the same two options. The new strings have
rows in every catalog.
Closing a path or pressing Enter with the Pen left only the 1 px outline, so the
path looked gone and the anchors to edit were hidden. The Pen, Freeform Pen and
Curvature Pen now draw the active paths' anchors as Path Selection does, as in
Photoshop.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
N is the whole algo-side Photomerge pipeline on 3 × 8 MP photos (register at the engine's
scale with the Auto layout, gain + vignette fit, seams, multi-band blend), 16 threads:
1042 ms before, 264 ms after, 3.9×, rounded to 4×. The engine's own glue (reading regions,
building weights, writing layers) is not in this number.
Registration and multi-band blending ran almost entirely on one thread per image (four
images on 16 threads), so most of the machine sat idle. Every output is bit for bit what
the serial code produced: a probe that hashes the public Photomerge entry points
(detect_many, match_pairs, align for every layout, photometric, seam_labels, multiband at
1 to 7 levels with 1 and 3 channels, with holes in the alpha, with images sticking out of
the canvas, plus direct harris / describe calls) matches on all 349 outputs before and
after, on 4 × 2 MP, 3 × 8 MP, 97×61, 211×157, 333×251, 320×240 two-image and 40×35 sets,
at 16, 3 and 1 threads.
- features `blur` (Harris window and BRIEF smoothing): the row pass runs rows in
parallel; the column pass cuts the image into bands of 64 columns, each holding the
`&mut` row segments of its own columns (no unsafe), run in parallel. Every column keeps
its own f32 running sum, started from its first window summed in order and updated in
the same order, so only the loop nest changes.
- `harris`: Sobel, the corner response and the non-maximum scan are parallel by rows (the
candidates are fully ordered by score, y, x afterwards, so arrival order is irrelevant).
`describe`: one descriptor per corner in parallel, kept in corner order.
- `reduce`, `expand`, `push_pull`: per-row maps in parallel. `reduce`'s column pass adds
five whole rows in tap order (same per-element sum, now vectorised); `expand` works out
its source columns once and truncates instead of calling libm `floor` (the clamped
position is never negative); `push_pull` builds each coarse row from its two fine rows in
raster order, which is the order the serial scatter-add used.
- `multiband`: grid fill per image, Laplacian difference, band accumulation (every row
walks the bands in the original order), normalisation, collapse and the coverage union
are parallel. Float additions are never reordered.
Left serial on purpose: the peak fold in `harris` (about 1 ms, an order-free max but not
worth the proof), the corner spacing grid, `seam_labels` (a dynamic-programming path whose
rows depend on the previous one), `photometric` sample gathering, and the pyramid levels
themselves (each level needs the one above).
Median of 5 (8 MP rows: 7), release, ms, interleaved before/after runs. The laptop was
busy with unrelated builds the whole time (load average 11 to 18 on 16 threads), so the
1-thread rows are noisy (a single thread is time-sliced against them) and rows can move
by 10 to 20 percent.
| Case (probe) | before (16 thr) | after (16 thr) | speedup | before (1 thr) | after (1 thr) |
|--------------------------------|----------------:|---------------:|--------:|---------------:|--------------:|
| register, 4 × 2 MP | 154 | 60 | 2.6× | 336 | 167 |
| multiband, 4 × 2 MP, 3 ch | 407 | 108 | 3.8× | 591 | 407 |
| multiband, 4 × 2 MP, 1 ch | 307 | 73 | 4.2× | 813 | 566 |
| whole pipeline, 4 × 2 MP | 437 | 160 | 2.7× | 716 | 504 |
| register, 3 × 8 MP (full size) | 327 | 124 | 2.6× | 978 | 391 |
| multiband, 3 × 8 MP, 3 ch | 2348 | 916 | 2.6× | 1742 | 1150 |
| whole pipeline, 3 × 8 MP | 1042 | 264 | 3.9× | 2264 | 1280 |
The 8 MP multiband row at 16 threads is the noisiest (minimum of 7: 959 to 224 ms, 4.3×);
the whole-pipeline row is steady. Max RSS for the 3 × 8 MP whole pipeline (the probe's
inputs are about 1.05 GB of it): 2.79 GB before, 2.79 GB after (three runs each).
Tests: the serial `blur`, `reduce`, `expand`, `push_pull` and `multiband` are kept in the
test modules as references and compared bit for bit over sizes that cross every band and
row boundary (1×1, 1-wide, 1-high, odd sizes, 63 / 64 / 65 / 129 columns, radius 0 to
wider than the image, holes, partial and zero weights, ROIs outside the canvas, 1 to 7
levels). Hard-coded hashes recorded from the old code cover multiband, push_pull,
reduce + expand everywhere (plain f32 arithmetic), and harris + describe on Linux x86-64
(they go through the platform's hypot / ln / cos / atan2 / sin_cos).
Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu
Co-authored-by: Claude Opus 5.5 <[email protected]>
* Save: ask before overwriting a file another app changed (#6596) [WIP]
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Save: ask before overwriting a file another app changed (#6596)
File › Save now notes the size and modification time of a document's
own file when it is opened or saved, and checks them again before
writing back. If another app changed the file in between, a prompt asks
whether to replace it (Cancel keeps both; Save As saves elsewhere)
instead of silently overwriting the other app's changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(windows): keep crash-recovery autosaves out of the roaming profile (#5648)
* test(windows): cover the local recovery folder and moving old autosaves (#5648)
A TySh warpValue, warpPerspective or warpPerspectiveOther beyond f32 became inf and the warp was silently dropped; they are now clamped to -100..100 like type.warpText.
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
Layer › Smart Objects › Stack Mode (`algo::stack::combine`) was one serial
per-pixel loop (1.0x parallelism, identical at 1 and 16 threads), and Entropy
built a HashMap per channel per pixel.
- Output pixels are computed in parallel in 4096-pixel chunks (`par_rows`, so
wasm keeps its serial path). Each chunk owns its scratch buffers.
- Each pixel uses exactly the same arithmetic in the same order as before, so
every mode except Entropy is bit-identical: 110 output hashes (10 modes x 11
cases: 3 x 24 MP, 7 layers of odd/mismatched sizes, transparency, HDR values,
1 layer, no layers) match the old code.
- Entropy: a `[u32; 256]` histogram plus a short list of the bins touched
replaces the HashMap; touched bins are summed in ascending order, so the result
is now deterministic. The old HashMap summed in random iteration order and
differed run to run (up to 1.2e-7 between two runs of the old code). New vs old
output: max abs difference 1.19e-7 (one f32 ulp near 1.0), on up to ~10% of
values in the 7-layer cases; the 3-layer 24 MP case is identical.
- Modes that never use the mean (Median, Min, Max, Range, Summation, Entropy) no
longer compute it; Median uses an unstable sort (same result under total_cmp).
3 x 24 MP, release, median of 5 interleaved before/after runs (load average
~3.5-4.5 from an unrelated build running alongside), ms:
| Mode | before | after (16 thr) | speedup | after (1 thr) |
|------------|-------:|---------------:|--------:|--------------:|
| Median | 1100 | 99 | 11.1x | 1064 |
| Mean | 308 | 36 | 8.6x | 338 |
| StdDev | 342 | 41 | 8.3x | 380 |
| Entropy | 4727 | 269 | 17.5x | 2730 |
| Kurtosis | 458 | 55 | 8.3x | 488 |
| Maximum | 368 | 36 | 10.1x | 346 |
The other modes land between 8x and 10x. Max RSS for Median is unchanged
(1.50 GB, of which 1.13 GB is the input layers).
Tests: hard-coded bit patterns recorded from the old code for every mode but
Entropy; Entropy against hand-computed values and run-to-run determinism; and a
chunk-boundary test comparing a multi-chunk image to per-pixel results.
Claude-Session: https://claude.ai/code/session_01AfRHB1wHUjrJx6agBYGnVu
Co-authored-by: Claude Opus 5.5 <[email protected]>
The image crate's Targa encoder let run-length packets run on from the end of
one row into the next. TGA 2.0 says a packet must not cross a scanline, and
readers that decode a row at a time misread such files: Pillow fails on them
with "buffer overrun", and texture tools and game engines reject them.
Targa is now written by our own encoder (codecs::tga), as Photoshop writes
it: RLE true colour or grayscale, every packet within its row, rows stored
bottom-up (the origin readers that ignore the descriptor assume), the alpha
bits in the descriptor and the TGA 2.0 footer. Reading stays with the image
crate.
One malformed set in the preferences file (gradients, swatches, styles...)
made load_presets_json ignore the whole presets blob, so saved New Document
presets vanished at every launch: the shell merges back only changed keys,
leaving the damaged set on disk. Each set now loads on its own; null and
malformed sets keep their defaults.
Refs storytold/photocraft#5602
Co-authored-by: Cursor <[email protected]>
Horizontal scale was applied after line breaking, so paragraph text below
100% wrapped early and text above 100% overflowed the box. Break box text
at the available width divided by the paragraph's horizontal scale (the
largest one when runs mix), scale the first-line indent and alignment
offset back to the box, and keep the scaled trailing space out of the
line extent.
Fixesstorytold/photocraft#5565
Co-authored-by: Cursor <[email protected]>
The prefix cache's chunk textures are only copied to and from
(COPY_SRC | COPY_DST), but went through Tex::new, which always creates
a default view. Vulkan rejects a view on an image with no sampled,
storage or attachment usage (VUID-VkImageViewCreateInfo-image-04441);
wgpu doesn't check it, so it only shows with the validation layers on,
once per new chunk while painting. The view was never used. Chunks now
hold a bare wgpu::Texture. No rendering change.
Co-authored-by: Claude Opus 5.5 <[email protected]>
usvg builds the glyph outlines of a text element (Text::flattened) with
identity absolute transforms, and the SVG importer read each path's
abs_transform(). Text positioned by its own or an ancestor's transform,
as Illustrator writes every text element (matrix(1 0 0 1 x y)), lost it:
it landed off the canvas, so nothing showed, or at its untransformed
place and size.
The converter now composes each node's transform from its parent chain,
which equals abs_transform() outside text, and sizes raster fallbacks
from the same transform. A new test checks translate, matrix, scale and
transformed groups against resvg's own render; it fails on main.
Co-authored-by: Claude Opus 5.5 <[email protected]>
On a document over about 5 MP with Low Resolution Previews on, a filter
dialog's live preview ran on a reduced proxy at every zoom, so at 100 % the
canvas showed the image in k-pixel blocks and hid exactly what Unsharp Mask,
High Pass and Smart Blur do.
ensure_filter_preview now caps its factor with proxy::view_factor(k, zoom),
the same rule adjustment previews got in #5764, so from 100 % the preview is
full resolution and stays reduced when zoomed out. The preview already runs on
a cancellable background worker, so the full-resolution path does not block the
UI thread.
Also in this change:
- canvas.rs and filter_preview_ui.rs share one canvas::filter_preview_factor,
so the in-dialog preview window cannot size textures from an uncapped factor
and rebuild them when the first result arrives.
- The cap is gated on cfg!(target_arch = "wasm32"), so the web build keeps its
proxy rather than running a full-resolution filter inline on the UI thread.
- The end-to-end test moved into mod tests and covers Low Resolution Previews
off; a new factor test covers 25/50/75/100/400 %, NaN and zero zoom.
Photoshop writes a split custom warp as `quiltWarp` in the placed layer's
SoLd/SoLE descriptor: a grid of Bezier patches (deformNumCols x
deformNumRows control points, patch boundaries in quiltSliceX/Y). Only a
single 4x4 `warp` mesh was read, so such smart objects rendered unwarped
and in the wrong place. Read the quilt grid into a multi-patch
BezierMesh; anything inconsistent is still refused.
On two real mockups (a sneaker and a V-neck jersey), the mean difference
from another PSD renderer over the design area drops from 14.4 to
0.66/255 and from 13.0 to 0.64/255. Refs #4398.
The Community packages heading from #1149 landed before the maintainers' releasing note and the IMPORTANT status admonition, so those two paragraphs rendered under Community packages instead of Get started. Move the section to just above Documentation; the content is unchanged.
Strict workspace Clippy stopped on collapsible_match in affinity, raw, plugins, engine, ui-egui and xtask. Pure conditions become guarded match arms (each falling through to an existing empty arm), and the ruler drag state and the measurement command result are bound to locals, preserving evaluation order, error messages and fallbacks.
The same lint fixes also appear as a side commit in #5157 (David2766), which can drop it on rebase.
With a document open in the web app, Place Embedded was disabled because its menu enablement inherited the engine's native-filesystem check, although the menu path reads the picker's bytes and never touches a path. Place Embedded is now enabled whenever a document and a file-dialog service exist; Place Linked keeps the engine's native guard, since the web could only link to a bare file name (caught in #5817 by Higgs32584). An enablement test runs with the native check forced off, and a file-dialog test covers the browser answer shape, cancel, invalid bytes and the Free Transform handoff.
Fixes#1209. Fixes#5749.
egui places font runs left to right with no bidi reordering, so a right-to-left layer name showed every word correctly but in reversed order. bidi_text::visual puts a label into display order with the Unicode Bidirectional Algorithm (unicode-bidi, already in the tree via usvg): right-to-left words stay in logical order for the shaper, everything else in a right-to-left run is reversed around them and brackets are mirrored. The Layers panel's rows and the app-drawn title bar use it; the title keeps a left-to-right paragraph so '— PhotoCraft' stays at the right, and a long right-to-left name is cut at its logical end so its start survives. Display only: stored names and the OS title keep the logical text.
Fixes#5159.
Co-authored-by: ahmedhosnypro <[email protected]>
A command that fails on a layer id or name the document doesn't have now says which layers there are, top-down as id and name (at most 20, names cut at 64 characters), so an agent can recover from the error alone: ids are not positions, and a freshly opened PNG's Background is layer 2. NoLayer becomes UnknownLayer { id, known } at both command entry points, keeping the 'no such layer' prefix. A layer name that matches no layer exactly matches regardless of case; an exact match always wins.
Fixes#5058.
photocraft_algo::faces::detect finds faces in an RGBA image with a score, a box and five landmarks, through a pure-Rust port of libfacedetection's YuNet network (Shiqi Yu, BSD-3-Clause; licence, NOTICE and ATTRIBUTION rows included). The weights are the library's, converted to half precision (107 KB), parsed defensively. image.detectFaces reports the document's faces to scripts and agents; it is read-only with no history step and no menu entry. The network is compiled out of the browser build, where the command reports itself unavailable. Head outputs are pinned against the C++ reference.
Part of #3603.
Adds a Community packages heading to the README, noting these installers are maintained by community members, with the Gentoo recipe for media-gfx/photocraft-bin from the ::snakebyte overlay. The overlay's ebuild fetches the official Linux x86_64 release tarball with Manifest-pinned hashes that match the release's SHA256SUMS.
A single oversized input event reached egui text layout in a numeric field even though it could never become a valid value. number_edit now drops Text, Paste and IME events longer than 128 scalar values while the field has focus, and the custom parser rejects accumulated text over the cap before normalizing. DragValue keeps focus, accessibility, drag and arithmetic behaviour. Six harness tests cover the event kinds, the Unicode boundary, accumulation, undo, and document text fields staying unrestricted.
Part of #2150.
brush.defineFromSelection allocated the sampled tip and the selection mask infallibly over the whole selection, so a large selection could abort the process. Both now go through the engine's fallible allocator and return a normal error that leaves the brush, presets, history and document untouched. A failpoint test asserts all four.
Part of #212.
The caret's blink phase was tied to the wall clock, so an arrow key or keystroke that landed in the off half-cycle moved an invisible caret. A transient caret_blink_time on UiState is reset on every handled key event, on a click that places the caret, and while dragging; draw_overlay computes the phase from it so the caret is solid for 530 ms after each interaction, and the repaint is scheduled to the next phase boundary.
Fixes#5511.
With Only Web Colors on, every picker edit (field, sliders, hex, Lab/CMYK, screen sampler, Current) snaps through the clamped 216-colour ramp. The checkbox is remembered on the Color panel state so the foreground, field, command, Layer Style and type-style pickers open with the same setting. Opening with it on snaps the colour and its original together, so OK without an edit leaves a fill layer unchanged. The hex field follows the colour when another path changes it while focused.
Fixes#4995.
Creating a Solid Color fill from the Layer menu or the Layers panel footer now opens the shared Color Picker seeded with the foreground colour. The layer is created on OK in one history step; Cancel leaves the document and history untouched. Parameterised calls (CLI, MCP, actions) still create the layer directly.
Fixes#5144.
An auto-hidden taskbar reserves no work area, so the maximize guard left the
caption-less window covering the whole monitor and the shell treated it as a
full-screen app, never revealing the taskbar. On each monitor edge with an
auto-hidden taskbar (found via Shell_TrayWnd / Shell_SecondaryTrayWnd through
safe winsafe calls) the window now stops 1 px short of the monitor.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The cached guide targets were keyed only by document and revision, so showing the
grid or turning Snap To Layers/Document Bounds off kept the stale targets; the key
now includes the Snap To options.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Batch strips Save/Save As/zoom steps from every action of a loaded set, called actions
included, and remaps their unchecked/dialog step indices.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
A number past f32's range (e.g. opacity 1e39) decoded as infinity, so the document opened but
could never be saved again; read_manifest now refuses it as corrupt, naming the field.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
memory-stats reads /proc/self/smaps by default on Linux: every sample formats and parses one
block per mapping while holding the mmap lock. The benches' RssSampler polls it every 2 ms, so
on a profile of bevel_bench --size 4000x3000 the rss-sampler thread was ~22% of all samples
(smaps_account 14.7%, smaps_pte_entry 6.3%), contending with the measured threads.
Enable the crate's always_use_statm feature: one short read of the kernel's RSS counters, the
same number VmRSS reports. The measured code is unchanged; only the harness overhead goes.
parley's default overflow wrap only breaks at UAX #14 opportunities, so
a word longer than the paragraph box ran out past its edge and resizing
the box changed nothing. Paragraph text now sets OverflowWrap::BreakWord,
so such a word wraps at the box edge as Photoshop does; a space or hyphen
still wins over a mid-word break, a box that fits the word lays out as
before, and point text is untouched.
* fix(linux): avoid duplicate AppImage desktop launcher when managed by AM / AppMan (#6392)
* test(linux): cover AM / AppMan launcher detection in AppRun smoke test (#6392)
The local Laplacian filter behind Shadows and Highlights builds a whole
pyramid per reference value (up to 96). Three costs dominated it:
- blur_step called mirror() (an integer remainder) for every tap of every
pixel; the source rows are now found once per row and columns mirror only
within two pixels of an edge.
- up() blurred a zero-stuffed plane, three samples in four zero; it now adds
only the nonzero taps, in the same order.
- the small pyramid levels were split into one-row rayon tasks, costing more
in scheduling than the rows; planes under 32K samples run on one thread.
The output is bit for bit the same: a new test checks blur_step and up
against the plain versions on odd, even, tiny and parallel-sized planes.
With View > Snap on, the canvas snapped the Type tool's press (a point
gesture) before the tool's handle hit test, with the edited text's own
glyph bounds and centre lines among the targets. The snapped press could
leave the 6 pt handle grab area, so the press placed the caret or
committed the edit instead of resizing the box.
- The Type tools pick the box handle or glyph at the raw press, as Free
Transform already does.
- While editing, only a press on a box handle starts snapping, and the
resize drag snaps to guides and other layers, not to the text being
resized; caret drags are no longer snapped.
Canvas-level test drags all eight handles (at the handle and inside its
grab area) at 25-200 % zoom, 1x and 2x HiDPI, plain/scaled/rotated layer.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
called_actions only followed actions.play steps, so a droplet whose action
used actions.conditional saved neither branch and failed with "no action
named ..." when run standalone. It now also queues the then/else targets.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Free Transform and Warp moved a fill layer's linked masks (and so the frame its
gradient is laid out in) but kept the cached render, which the compositor draws
as-is while the Fill is unchanged. Drop the cache in apply_transform and
warp_layer, as transform_layer_geometry and Perspective Crop already do.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Photoshop's 16-bit PSBs can mark the merged transparency only by naming it
"Transparency" in the alpha channel names (positive Lr16 layer count, no Mt16).
The importer read it as a user alpha channel, and the next save wrote a second
one. merged_has_alpha now also accepts that name when the layers sit in a
global Lr16/Lr32 block and the names cover every extra channel.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Tx::filter_effects converted only the first u64-length section of FEid/FXid and copied the rest as stored, so a second smart object's section kept the source byte order and FilterEffects::parse lost the whole block.
It now mirrors FilterEffects::parse: one section per smart object, each with its 4-byte padding, until only trailing zero padding remains.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
plugin.run wrote into raster and smart object layers without checking
locks. It now refuses layers with locked pixels or Lock All like the
built-in filters, and with Lock Transparency it keeps the layer's alpha
(as built-in filters do, via filters::keep_alpha).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
renameGroup in the shared preset group editor renamed without checking
that the new name was free, so two groups could share a name and a later
replace import of that name removed both. It now refuses like the
Swatches sibling; renaming a group to its own name stays fine.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
map_fill multiplied the offset by the full linear part of the canvas transform, so Image Size
(and Perspective Crop) scaled it: 200% moved a 25% offset to 50%. Each axis of the linear part
is now normalised, so scale drops out while flips and 90° turns still mirror and turn it.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
With the layer mask targeted, apply_image took the layer branch and fetched the
layer's pixel surface with the target dropped, so the mask stayed unchanged and the
pixels were overwritten. A mask target now goes through the single-plane path and
is written back over the canvas, keeping the mask's default outside it.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
actions.rename only rewrote the "action" param of actions.play steps, so an
actions.conditional step whose "then"/"else" named the renamed action failed
with "no action named ..." on the next play. The rename loop now also rewrites
matching then/else branches, in saved actions and the pending recording.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Auto-Align warped a moving layer's mask with the colour warp (warp_placed), which
returns a grey + alpha surface defaulting to 0, so a reveal-all mask hid everything
outside its painted area. Masks now go through warp_gray (planar placements) or
warp_mesh_gray (cylindrical/spherical), keeping one channel and the mask's default.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
color_from_desc had no HSBC arm, so HSB descriptor colours fell through to the
RGB fallback and came back None: solid fills vanished, strokes and effects went
black. Convert HSBC (hue in degrees, saturation/brightness 0..100) with the
existing channel_map::hsb_to_rgb, now pub(crate).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
fit_image read width/height with f64_param and clamped to 1, so a 0 or
negative side (dialogs send 0 for an empty field) became a 1 px box and
shrank the document to 1x1. It now uses size_param like the rest of
file_cmds.rs, so such a side falls back to the document's own size.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The byte-order converter read Idnt/indx reference items as a bare u32, but
they are class + u32 (as descriptor.rs reads them since #4434), so a block
holding such a reference desynced and was dropped (or left unconverted).
Read the class first; regression test round-trips such an lfx2 block.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
In Photoshop a path is deleted by dragging its row in the Paths panel
onto the Delete (Trash) button at the panel's bottom: the row follows
the cursor and releasing over the Trash deletes that path, whichever
row is selected. PhotoCraft's rows only reacted to clicks, so the
drag gave no feedback and a drop on the Trash did nothing.
Saved path, work path and vector mask rows now start a drag. While it
runs the row's name follows the cursor (the ghost the Layers and
History panels use) and the Trash is outlined when under the pointer;
releasing there runs the row's own delete command (path.delete for a
saved or the work path, layer.vectorMask.delete for a vector mask),
reusing the History panel's dropped_on helper. A drag that never sees
its release is dropped next frame.
A shape layer's path row does not start a drag: what deleting it does
is #6552's change, kept separate.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
An adjustment layer whose block no mapper knows (Photoshop 4's `hue `, or a
type newer than PhotoCraft) opened as an empty pixel layer with no notice.
Its block did survive in psd_blocks, but the layer showed as a paintable
empty pixel layer and saved without the "pixel data irrelevant" flag.
Photoshop writes every adjustment layer without pixels and with "pixel data
irrelevant" set (every adjustment layer in the corpus; pixel, type and smart
layers never have it). A record like that which is not a group, matches no
known content kind, and carries exactly one block that is neither a known
layer-information key nor a known adjustment now imports as
Adjustment::Unsupported with that block, plus the usual "could not be read"
warning naming the key. With two or more candidates nothing is guessed and
the layer stays a pixel layer as before. The rule only fires for layers
without pixels, so no pixels can be lost, and the block is written back
verbatim either way.
Export keeps an unknown-key Unsupported block in place while it is
unchanged; before, it was written a second time beside the preserved copy.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The right-click menu of a shape layer's "<Layer> Shape Path" row in the
Paths panel had no Delete Path, while Photoshop offers it enabled.
Measured in Photoshop: with a rectangle shape layer's shape path targeted
in the Paths panel, Delete Path removes the whole shape layer as one
"Delete Layer" history step (another shape layer below is untouched);
it does not leave a full-canvas fill behind.
The row now offers Delete Path, dispatching the existing layer.delete
command for that row's layer (passed explicitly, so a multi-layer
selection is not deleted with it). Saved and work path rows keep
path.delete and a vector mask row keeps Delete Vector Mask.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
After an Alt tap or F10 the in-window menu bar owns the keyboard, and
shortcuts::handle returned before dispatching anything, so Ctrl+D, F7
and the other command shortcuts did nothing until Esc left the bar.
Since the shortcuts run before the menu bar in a frame, the bar can't
let go of the key itself in time.
menu_keys::leave_for_shortcut takes the keyboard off the bar when a
Cmd/Ctrl combo or a function key other than F10 is pressed while the
bar is focused with no menu open; shortcuts::handle calls it before its
early return, so the shortcut runs in the same press. Arrows, Enter,
Esc, letters, Alt+letter and F10 keep driving the bar, and an open menu
still swallows shortcuts.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Selecting a saved path in the Paths panel drew nothing: the canvas overlay
only drew the work path, the active shape layer's path and a targeted vector
mask. The selected saved path is Photoshop's target path, so it is now drawn
with any tool (anchor squares with Path Selection, and with Direct Selection's
overlay), still hidden by View > Show > Target Path and Extras.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Paths these features keep in the document go through the same resolver as
linked smart objects (relative to the document's folder; other computers,
devices and URLs refused):
- A Displace smart filter's `mapPath` and a Color Lookup smart filter's LUT
`file`, re-read on every re-render: a path that is not followed leaves
the pixels as they are, as for a missing map or LUT.
- Apply Data Set's Pixel Replacement images.
- Reload Frame of a video layer's footage.
Apply Data Set and Reload Frame say why a path is not followed.
Save As PSD/PSB, layered TIFF and Package as PSD read a linked smart
object's file to embed it. The path is resolved with the same resolver as
other linked-file reads, against the document's own file (the new
ExportOptions::document_path / PsdExportOptions::document_path, set by the
engine's saves and the app's Save As). A link that is not followed is
written as pixels with a warning that says why, as for a missing file.
Nested smart-object documents resolve against the same document.
A linked smart object's path comes from the document. Every place that
follows it now goes through one resolver (photocraft_io::doc_path, built on
craft-docpath): a relative path is taken from the document's folder, and
only local files or files on the share the document itself is on are
opened. Paths to other computers, device paths and URLs are treated like a
missing file: the layer keeps its last rendering.
- Link watch: such links are not stamped, and no background look starts
when no link of the document is followed.
- Update Modified Content and other re-renders read only followed files;
the error names why a link is not followed.
- Package lists such links as missing instead of copying them.
- Reveal in Finder for a linked layer shows only a followed file; the
document's own Reveal is unchanged. The macOS invocation ends options
with `--`, and Explorer is not asked to select a path containing a comma
or a quotation mark.
- Place Linked stores a relative path as an absolute one.
The document path travels to smart-object renders in a thread-local scope
entered by Session::edit, command dispatch and background jobs.
Co-authored-by: David Mann <[email protected]>
The live preview only covered selections up to 256x256 px; larger ones (the
command takes up to 16 M) errored, the UI remembered the failure and showed
nothing until release.
Engine: a preview whose selection (or landed area) is over 256x256 is now
solved on a scaled-down copy of the area around the selection and where it
lands (area-average resize of the target, or of the composite with Sample All
Layers, and of the selection), on a one-layer document of its own, then
enlarged back (bilinear, float samples, so any depth) over the selection's
shape where it lands and, for a move, where it left. The landed content is
sampled through the scaled placement, so rounding the scaled offset does not
shift it. ContentAwarePreview::refine gives the full-resolution job, with the
command's own margin, so the refined preview is the command's result bit for
bit. Every step checks the JobCtx; the session is never touched; bad params
and selections over the command's limit still error. Selections within
256x256 take the unchanged path (same output as before, checked by hash
against main). The bilinear resampler used by Transform On Drop now runs its
rows in parallel (same results).
Measured (release, 24 MP 8-bit document, Ryzen 9 7950X with other builds
running): coarse preview 600x400 0.26 s, 1500x1000 0.38 s, 3000x2000 0.69 s
(256x256 full-resolution preview: 0.21 s); the exact refine takes as long as
the command (0.8 s, 4.1 s, 9.4 s).
UI: cam_preview shows the coarse result, then starts the refine as a second
cancellable job and swaps it in; a failed refine leaves the coarse result
shown, and a failed first job is still not retried.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
`photocraft_compose::change_bounds` returned "anywhere" for every
adjustment layer before looking at `clipped`, although a clipped
adjustment only changes its clip base's pixels. Hiding or showing one
(or undoing that) therefore recomposited the whole canvas, which on a
heavy document fails the GPU memory fit check and falls back to a full
CPU redraw taking seconds to minutes.
`change_bounds` sees one layer and cannot find the base, so a new
`change_bounds_in(layers, k, canvas)` looks at the sibling list: a
clipped adjustment's change bounds are its base's change bounds. It
stays `None` (anywhere) when no unclipped base lies beneath it (the
compositor then draws it as an ordinary adjustment), when the base is
an adjustment (layers clipped to it composite atop the adjusted
backdrop), and, through `change_bounds`, when the base is a
pass-through group holding an adjustment (visible clipped layers make
that group isolated, so they decide whether its adjustment reaches the
layers beneath) or draws outside its bounds. The engine's
`layers_damage`, `step_damage` and `one_sided_bounds` use it.
Tests: a compose unit test that the clipped adjustment's bounds equal
the base's, that every pixel the toggle changes lies inside them, and
the cases that stay unbounded; an engine test that hide, undo, redo,
show and `layer.setProps` visible report the base's rect (top level and
inside a group), and that recompositing only that rect over the
previous canvas equals a full recomposite.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The Lightness slider's track in the Hue/Saturation editor (Properties
panel and dialog, which share it) ran black -> the current hue -> white.
Photoshop's Lightness track is a fixed black -> mid grey -> white ramp
whatever the Hue. lightness_track() now returns that ramp and no longer
takes the hue; the Saturation track still follows the hue. The UI-217-16
scorecard row describes the new track.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Conflicts:
- distort_ui.rs: main's `drop_foreign` (#5092, #5123) closes the Content-Aware Fill workspace
too when another document becomes active or its own is closed, stopping its background
preview as Cancel does; what Apply wrote stays. New test
`the_workspace_is_dropped_when_another_document_becomes_active`.
- sr, tr, vi catalogs: both sides' rows. Dropped es's High, Low, Duplicate Layer and Contract,
which main has now with the same translations.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
With more documents than tiles, the run of shown documents was worked out from the active
index alone, so after activating document 2 in Two-up, a click into the visible document 1
moved the tiles back to [0, 1]. The run is now kept from frame to frame and slides only when
a hidden document becomes active.
paint::color followed a spot colour's RGB equivalent and base colour with
no depth limit. Colours are shared objects, so a spot colour that links to
itself overflowed the stack and aborted the import, and a chain whose levels
link to the next through both took exponential time. Stop after 4 levels of
spot colours defined through spot colours, with a warning.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Fixes#3708; part of #1540 (Flatpak needs its own follow-up: each sandbox has its own loopback).
A second launch hands its files to the running instance over an authenticated loopback channel (random port, 32-byte token in a private instance.json, constant-time check, bounded clients, requests and paths). Files are acknowledged only once the UI has taken them; the instance shuts the hand-off down before a CPU-compatibility relaunch; it runs only in the per-user settings mode. --new-instance and PHOTOCRAFT_NEW_INSTANCE=1 opt out.
Thanks to @MrCloudy2 (#2358), whose Linux version first handled the relaunch and per-user endpoints, and @Desayuno64 (#5273), whose shutdown flag and read deadline informed the final design.
Both harnesses built their device with egui_kittest's .wgpu() (egui's default limits) instead of
gpu_canvas::wgpu_setup() (the adapter's own limits, what the app requests), as #5954 fixed for the
background_redraw and navigator tests. On that device:
- canvas_16f's sixteen_bit_gradient_on_screen and thirty_two_bit_preview_exposes_values_above_one
panicked in egui-wgpu ("Failed to create staging buffer for index data", renderer.rs:984), every
run, single-threaded too;
- arbitrary_rotation_preview's two tests failed "the fixture must use the GPU canvas".
Both binaries pass on the app's device.
validate_token accepts uppercase hex and server_token keeps the token
lowercased, but authentication_reply passed the client's token to
token_matches unchanged, which compared bytes exactly. A client sending
the token exactly as configured (--control-token or a token file in
uppercase) was refused; only the bundled bridge, which lowercases, got in.
token_matches now folds ASCII letters on both sides, still without an
early exit. Only A-Z fold, so a non-hex byte never matches a hex digit.
* Save As on Linux: the file type picked sets the extension (#6121, #4328, #4394)
The GTK file chooser behind the desktop portal keeps the typed name when
another file type is picked, and rfd 0.17.2 reads only the file name from
the portal's answer, so Untitled-1.psd with PNG picked was written as a
PSD. On Linux, Save As now asks the portal's FileChooser.SaveFile itself
(over the zbus already used for the portal), reads current_filter, and
gives the name that type's extension: an extension PhotoCraft saves is
replaced, none or another is appended. If that names an existing file, the
dialog opens again under the new name so the portal asks before replacing
it. rfd remains the fallback without a portal, and this can go once an rfd
release reads current_filter (PolyMeilex/rfd#341).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Save As on Linux: keep typed extensions, open on the stem, follow the request handle
Review follow-ups, checked on real GNOME 46 and KDE Plasma 6.7.5 sessions
(Wayland and X11):
- An extension the user typed is kept, as in PolyMeilex/rfd#341: only a
name with none, or still with the extension the dialog opened with, takes
the picked type's. GTK reports the type even when it is untouched, so
art.png with the PSD type left alone was saved as art.psd.
- The dialog opens on the stem (test), as on Windows (#4407): with
test.psd in the name and PNG picked, GTK asked to replace test.psd,
which the save leaves alone. A name that then exists is asked about
again under that name; the rule compares against the suggested name, so
a test.psd GTK completes back still becomes test.png.
- Response 2 (GNOME's dialog closed with Esc) is a cancel: it fell back to
rfd, which opened a second Save As.
- The answer is awaited on the handle SaveFile returns, which a portal
older than 0.9 picks itself (the Request documentation).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* style(automation): format smart filter refresh closure
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: skylar taylor-barrick <[email protected]>
* GPU memory: a graphics card's own memory isn't charged for the document; GPU Memory preference; GPU Memory Full in the status bar
Many layers hit a cliff: past what the GPU memory budget leaves for layer pages, every edit
that redraws the whole document (opacity, visibility, a nudge) goes to the CPU compositor.
Measured on 3000x2000 full-canvas layers (RTX 5090, 31 GB RAM, Memory Usage 8 GB): 150
layers took 2.6 s per such edit, 300 layers 6 s. The budget was Memory Usage minus the
document's pixels and History, which is right where the GPU shares system memory, but a
discrete card's memory doesn't hold the document, so 150 layers of 6 MP (3.6 GB) squeezed
the budget below what they needed.
- Automatic budget: discrete adapters are no longer charged for the document's pixels
(integrated and software adapters still are). 150 layers: 2.6 s -> 25 ms.
- Preferences › Performance › GPU Memory (MB, 0 = Automatic; performance.gpuMemoryMb) sets
the budget outright. 300 layers with 12 GB: 6 s -> 45-72 ms.
- The status bar shows "GPU Memory Full" while the active document's layers don't fit
(both status bar styles); hovering gives the numbers, clicking opens Preferences ›
Performance. Its Graphics rows show the budget and what the layers need. The slow CPU
redraw notice gives the numbers and points at GPU Memory.
- Headless: the preference is a normal pref (prefs.set), and ui.state's perf.timings
reports gpuLayerFit {doc, needed, budget} next to gpuBudgetMb.
Next (owner-approved plan): after #5370, read the card's real limit from the driver
(wgpu memory budget thresholds) and shrink on out-of-memory; then redraw the visible
area on the GPU first when layers still don't fit.
* GPU memory: typed discrete check; the status and budget follow a GPU Memory change at once (measure_fit); perf rows P60/P61 for whole-document edits among 150 full-canvas layers under the app's budget
* GPU memory on top of #5234: memory_budget takes the card's memory (vram) and the GPU Memory value; the document's pixels are charged unless the card's own memory is known; a set GPU Memory is capped at the card's share; benches use video_memory
* perf(gpu): cap the GPU memory budgets by a discrete GPU's own memory
The compositor and canvas budgets came from system RAM only, so a 16 GB card in a 96 GB machine planned for up to 8 GB of compositor pages plus 24 GB of display textures. Detect a discrete adapter's dedicated memory (DXGI on Windows through winsafe, amdgpu sysfs on Linux) and cap each budget at three eighths of it. PHOTOCRAFT_VRAM_MB simulates a smaller card. Fixes#4536.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* perf(gpu): keep a 16-bit canvas on the GPU in 8-bit when floats don't fit
With the canvas budget capped by a small card's memory, a 16-bit document under the float-canvas pixel limit could need more than the budget as Rgba16Float and fall back to the CPU canvas although its Rgba8Unorm set fits. Choose the float canvas only when it fits the budget, which is now shared atomically so format_for can read it while the renderer is locked.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* perf(gpu): detect a card's memory once per adapter, and test the Linux reader
video_memory built a DXGI factory each time it was asked, three times at
startup; it now remembers the answer per (vendor, device), so a GPU retry
that picks another adapter still gets its own. The amdgpu sysfs reader
takes the drm folder as a parameter and is tested against a temporary
folder: the matching card's VRAM, a card without the file, garbage ids, a
malformed size, no match and no folder.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
- The CPU canvas texture and the crop's beyond-canvas bands were reduced to fit MAX_TEXTURE (4096)
whatever the device allows. On a device with a smaller limit (2048 on some older GPUs and WebGL
contexts) a 2600 px document was uploaded at full size: egui asserts and the backend can't
create it. They now fit the smaller of the two. Test: cpu_canvas_texture_fits_the_device_limit.
- background_redraw and navigator GPU tests built their harness with egui's default wgpu limits
instead of the app's wgpu_setup (the adapter's own limits), and the GPU canvas lost the device
on it ("Device is lost", every run on an RTX 5090). They now use wgpu_setup.
- Eleven tests built a wgpu device without photocraft_testkit::gpu_lock, which testkit requires so
devices aren't created concurrently; they now hold it.
The web writer only triggers an anchor download, which the user can cancel
and which reports nothing back, yet Save treated it as a confirmed write:
the document was marked clean and the leave-page guard disarmed. Services
gains `write_unconfirmed` (set by the web shell); a save through such a
writer names the document but keeps its changes unsaved.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A Hue/Saturation smart filter (Fltr class HStr, filterID 'HStr') opened
as psd.unsupportedFilter, so re-rendering the smart object (replace
contents, relink) dropped it, and a Hue/Saturation smart filter made in
PhotoCraft was left out of PSD saves.
The new hue_sat_filter_map module maps Photoshop's layout (measured on a
Photoshop-saved file: presetKind, Clrz, Adjs = master Hst2 {H, Strt,
Lght} plus one Hst2 per edited range with LclR, BgnR, BgnS, EndS, EndR,
all longs) to image.adjustments.hueSaturation with the command's params,
and writes it back in that layout. Anything outside it (unknown keys,
duplicate or unknown ranges, values the dialog can't hold) stays an
opaque unsupported filter written back verbatim. smart_map.rs only gets
the dispatch hooks.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
A Perspective Warp smart filter (Fltr class perspectiveWarpTransform) fell
through to psd.unsupportedFilter, so a re-rendered smart object (e.g. after
Replace Contents) lost its perspective. Each quad now becomes a plane of
layer.smartObjects.perspectiveWarp (src = vertices, dst = warpedVertices),
and PSD save writes Photoshop's descriptor back: the original item with
only the geometry rewritten, or a new item in the layout Photoshop writes.
Inconsistent descriptors stay opaque, as before.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Photoshop scales the Dual Brush diameter by the main size ratio, unrounded,
with a 5000 px cap and no lower floor: main 100 / dual 20 resized to 1 px
gives a 0.2 px dual tip, and back at 100 px it is 20 px again. PhotoCraft
already scaled the dual tip (#4856) but floored it at 0.5 px, so a trip to
1 px and back left a 50 px dual tip. Drop the floor (rendering bounds the
diameter itself), keep the cap, and leave a non-positive or NaN result alone.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
winprint's PdfCreateRenderer made photocraft.exe and photocraft-cli.exe
import Windows.Data.Pdf.dll at load time, so Windows images without the
PDF component (NTLite-stripped, some N/LTSC builds) refused to start:
"windows.data.pdf.dll was not found". Both apps now delay-load it, and
PDF printing checks the DLL is installed first and reports an error
instead of aborting.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Security: enforce the automation read boundary on stored smart-filter params
Smart filters store a command id and params inside the document, so a
crafted .pcraft could resolve stored ambient paths (displace mapPath,
color-lookup LUT files) at render time without the authorize gate:
synchronous smart-object rendering has no Session argument, spawned job
threads drop the requesting session's authority, and a trusted UI
render could warm the process-wide stack cache with a stage the gate
never saw.
Thread the requesting session's gate through a thread-local in jobs.rs
(entered around command execution, reinstalled on spawned workers and on
job apply, restored on drop), re-check stored filter commands in
render_in before source resolution and any cache lookup and in
apply_filter_to_surface, and re-authorize layer.smartFilter.setParam
with the complete merged params after merging stored document params —
including edits whose request contains no path and actions.play steps.
Denial is conservative: the document is unchanged and no worker starts.
Regression tests in the automation crate cover rerender, cached-stage
reuse, merged-param edits, action playback, background jobs, and filter
transfer, for both the displace map and color-lookup LUT vectors.
* Review: carry render authority through Session::edit and surface denials
Session::edit is the engine's universal mutation entry (linked-file
refresh, transforms, place); renders reached through it ran with no
authorization scope installed, so stored smart-filter params read
ambiently there even after the execute-path fix. Install the scope in
Session::edit as well, keyed on the session's authorize gate.
apply_filter_to_surface also conflated a denied filter with an unknown
one: authorize_render_command's Result was discarded with .ok()?, so a
denial silently rendered unfiltered pixels and the stage entered the
process-wide stack cache. The signature becomes
Result<Option<Surface>>: Err for denials (callers propagate), None for
ids that aren't pixel filters (pass-through semantics unchanged), and an
unreadable map keeps its deliberate pass-through. apply_smart_filter and
apply_smart_filters propagate instead of cur.clone().
Two tests: a refresh driven outside command execution (Session::edit)
is denied and leaves the document unchanged; a denied render caches no
stage. Nine authorization tests pass, engine suite and strict Clippy
clean, panic hunt green.
* ui_set description: name the layerFilter field main added
The Layers-panel filter dropdown (#5048) added layerFilter to the
control method's UI_SET_FIELDS without extending the ui_set tool's
description, so the drift guard (mcp.rs, ui_set_description_names_every_
accepted_field) fails on Windows CI for any branch built on main. Name
the field with its patch shape: by (kind, name or mode), kinds (a list
of layer kinds), name (matches part of the layer name), mode (a blend
mode name). The 40 MCP tests pass; strict Clippy clean.
* wasm size: compose and algo join cms at opt-level "z" to clear the 24 MiB gate
Main's browser build is 171 KB over the 24 MiB wasm gate in CI
(25,337,343 bytes), so every branch built on current main fails the
web size check. Demote three pixel crates in the wasm-release profile:
- cms: transforms are built once and cached (transform::cached),
so "z" costs little runtime (measured -42 KB).
- compose: the browser app composites per frame on the GPU (crates/gpu,
WebGPU/WebGL2), so the CPU compositor is not the browser's hot path
there (measured -122 KB).
- algo: filters run per pixel on every interactive filter application.
"z" there is the lever of last resort (measured -331 KB), taken
because the gate is enforced in CI; if browser filters feel too slow,
restore 3 here and reclaim the bytes from a feature-gated decoder.
Final size 24,811,949 bytes locally, 354 KB under the gate; the native
profiles are untouched. If a maintainer prefers keeping algo at 3, the
alternative is raising the self-imposed 24 MiB early-warning limit
(Cloudflare's hard cap is 25 MiB) - a policy call in packaging/web/
package.sh, not a code fix.
"levels" now finds two results (Image › Adjustments › Levels… and the
Levels adjustment layer), and one → only reaches level|s, so the test
needs a second → before typing the space.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With Smart Guides on and the Move tool, holding Ctrl (Cmd on the Mac) with no
button down and hovering another layer now draws magenta measurement lines
with labels between the selected layers' bounds and that layer's: the gap
between facing edges, or the distances between corresponding edges on an axis
where the two overlap. Over empty canvas, the Background or the selection
itself the distances run to the canvas edges. While an Alt-drag duplicates the
layers, the distance from the original to the copy shows.
Overlay only: no command, no history state. The hovered layer is the
Auto-Select hit test, cached per document revision, selection and pointer
pixel. Values use the ruler unit.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
In the Photoshop themes the caret was a 1.5 pt white line with a 0.75 pt
black hairline inside, so on a white background only a faint grey line
showed. It is now a solid 1 pt black line inside a 3 pt white halo, readable
on any background, in every theme.
The Path Selection and Direct Selection tools had no canvas menu, so Make Selection, Fill and
Stroke Path, Free Transform Path, Delete Path and the combine and style rows needed the Pen tool
or the Paths panel. Like Photoshop, their right-click now opens the path menu for the selected
path, with the Pen menu's rows and the same enablement.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Tests: Free Transform applies when another tool, layer or New Layer is picked (#5665, #5667)
Whole-app regression tests for the paths #5665 and #5667 report (a moved box, and a placed image moved or untouched; toolbar tool click, Layers panel row click, New Layer). They pass on main, so these issues don't reproduce here; the tests pin Photoshop's behaviour (apply, never silently cancel) while the reporters narrow down their environment.
* New Layer while Free Transform is open applies the transform first (History: the transform, then New Layer; a placed layer's step no longer swallows New Layer); tests: a real placed fixture, the New Layer button, History order
* Free Transform + New Layer: check the command can run before applying the transform; a refused transform keeps the box and the placed layer and returns the refusal
* Free Transform + New Layer: Session::precheck validates the call (params shape, New Layer's values, precondition) before the box is applied
* Free Transform + New Layer: commit-first only for PRECHECKED commands; precheck shares dispatch's admission checks (named-layer precondition, jobs, hidden target) and checks a named layer exists; Group from Layers documents its layer param; test: precheck agrees with execute
* Free Transform + New Layer: precheck resolves a layer given by name as the command does; Layer via Copy/Cut leave PRECHECKED (their failures show only when they run); rebased (lib.rs mod lines)
* Shortcuts without the UI: shell commands and tools in the engine, reserved keys, set files
A retro against "everything is drivable headless; the UI is a layer" found the shortcut
work leaning on the UI. Fixed:
- The shell's command table (UI_COMMANDS: View › Zoom In, the panels…) moves into the
engine as data (shell_commands::SHELL_COMMANDS; menus::UI_COMMANDS is now an alias), with
the tools (shell_commands::TOOLS: id, Photoshop name, default key; a test keeps it equal
to the toolbar). edit.keyboardShortcuts binds, lists, imports, exports and checks them for
conflicts with no UI: a headless .kys import takes tool keys, and an export is the whole set.
Tools may share a key (Shift cycling); a command given a tool's key takes it from them.
- Reserved system keys are refused by the engine (the dialog uses the same check) and left
out of imported sets.
- importKys / exportKys / summarize take {"path": …} to read or write files, like
actions.save; the automation gate refuses those paths for MCP.
* Keyboard shortcuts: bindings commit only after their exports are written (a bad target changes nothing); docs: the command knows the tools
* Keyboard shortcuts: both export targets are resolved and checked before either file is written; a failed later write removes the earlier one
* docs: reserved keys per platform
* Keyboard shortcuts export: files are staged next to their targets and moved into place only when all are written; a failure leaves the user's files untouched
* Keyboard shortcut exports all or nothing: unique create_new staging and backups beside each target, restored on any failure; the same target twice refused; docs: Ctrl+Shift+Esc
* Shortcut exports: a failed backup move removes its placeholder; undo restores backups without deleting first and names any it couldn't; read-only targets refused up front
* PHOTOCRAFT_PRESENT_MODE / PHOTOCRAFT_FRAME_LATENCY launch overrides for latency A/B tests (#1343)
Linux traces put the remaining stroke latency in presentation queueing (the app's frame fits
in one refresh; the rest waits on the swap chain), and an NVIDIA user cured it by forcing
vsync off. Testers can now compare without rebuilding: PHOTOCRAFT_PRESENT_MODE (fifo,
mailbox, immediate, autovsync, autonovsync) and PHOTOCRAFT_FRAME_LATENCY (1-3), applied over
the platform default. Bad values are logged and ignored. The active mode is logged at start,
kept in GpuInfo.present and shown in every stroke trace line.
RTX 5090, Vulkan, 120 Hz: default autovsync/latency 1 waits 6 ms per 8.3 ms frame; mailbox
renders every 2.3 ms with 0.2 ms wait, so each vsync shows the newest frame.
* docs: present mode and frame latency overrides
* Present mode overrides never request a mode the driver may lack (Mailbox crashed on X11/NVIDIA)
* Preferences › Performance › Low-Latency Drawing, on by default: no vsync wait while drawing (#1343)
Testers' traces put the ~50 ms stroke lag in the swap chain's vsync wait. With vsync off it
disappears (X11/NVIDIA; Windows/NVIDIA via the driver), and a shorter queue doesn't help. The
surface now presents with AutoNoVsync by default: wgpu picks Immediate, else Mailbox, else
Fifo by what the driver offers, so no unsupported mode is ever requested. Turning the
preference off brings vsync back (smooth, but late). It applies at the next launch, like the
other graphics settings. Where only Immediate exists the canvas may tear while it changes;
frames render as fast as input arrives during a stroke and stop when idle.
* Runtime and launch controls: ui.set strokeTrace, --stroke-trace, --present-mode, --frame-latency
The retro's remaining gap: the stroke trace and the presentation overrides were env-only. The trace now switches at runtime over the control channel (ui.set {"strokeTrace": true|false|"stderr"|path}) and with --stroke-trace; --present-mode / --frame-latency sit beside their env vars and win over them. Bad values are reported and ignored; none fails a launch.
* docs: strokeTrace beside the other ui.set fields
* docs: regenerate the scorecard after rebasing onto main
* Low-Latency Drawing: frames capped at 240 fps without vsync (after #4472); ui.set strokeTrace takes on/off only (a CSV path is launch-only); tearing documented as usual (Immediate); pref hidden on web; doc comment order
* stroke_trace: from_value doc says ui.set passes only on or off
* Low-Latency Drawing ships off (opt-in), per the owner's decision
* docs: present mode default is vsync (Low-Latency Drawing ships off)
The filter dialog's own preview window matched the canvas preview by the
filter's command, but an edited smart filter previews as
layer.smartFilter.setParams, so the window kept the unfiltered image.
smart_ui::preview_command names the command the preview runs.
After All Formats the Open dialog listed only PhotoCraft, OpenRaster, Krita,
SVG and Paint.NET. Every format it reads now has its own filter (Photoshop,
Affinity, HEIF, PNG, JPEG, TIFF, Portable Bitmap, OpenEXR, Radiance HDR,
camera raw, …), and together they cover All Formats exactly.
Photoshop's Replace Color shows the replacement colour under the Hue,
Saturation and Lightness sliders; picking one in its Color Picker sets the
sliders so the sampled colour becomes it. tone::replace_color_result gives
the swatch (the sampled colour after the shift) and tone::replace_color_shift
the slider values for a picked colour.
Re-opening a smart filter dropped the dialog's Preview, since the live
preview ran the filter command and would have stacked a second copy. The
preview now runs layer.smartFilter.setParams on the proxy, as OK does, at
full size and reduced afterwards (the smart object renders again from its
source, which the proxy doesn't scale), so it shows the filter replaced in
its stack.
Photoshop's first click on the image after opening Replace Color samples the
colour. The dialog now opens (and resets) with its canvas eyedropper active;
the pipette button still turns it off and on.
While a text field in an open menu has the keyboard, Nav::keys leaves
← / → (cursor) and Space (a typed space) to it: ← / → no longer switch
Help to Window / File, and Space no longer runs the lit result. ↑ / ↓,
↩ and Esc are unchanged. Follow-up to #5996 / #6030 (letters).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Wind, Extrude and Tiles applied as smart filters were dropped from PSD
saves with a warning, and the same filters in a Photoshop PSD opened only
as verbatim, uneditable entries. smart_map.rs now maps them both ways in
filterFX:
- Wind (`Wnd `, filterID 1466852384): `WndM` enum (`Wnd `/`Blst`/`Stgr`)
<-> method; `Drct` enum: `Left` <-> fromRight (streaks extend to the
left), `Rght` <-> fromLeft.
- Extrude (`Extr`, 1165522034): `ExtT` enum (`Blks`/`Pyrm`) <-> type,
`ExtS`/`ExtD` long <-> size/depth, `ExtR` enum (`Rndm`/`LvlB`) <->
depthMode, `ExtF`/`ExtM` bool <-> solidFrontFaces/maskIncompleteBlocks.
- Tiles (`Tls `, 1416393504): `TlNm`/`TlOf` long <-> count/maxOffset,
`FlCl` enum (`FlBc`/`FlFr`/`FlIn`/`FlSm`) <-> fill; the entry's
`FrgC`/`BckC` <-> foreground/background.
Our random seed has no PSD key and is not written. Missing keys, wrong
value types, out-of-range numbers and unknown enum values keep the entry
verbatim; params with no PSD equivalent are reported, never guessed.
Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back; each code also checked on a test image (pixels
compared). Scorecard FILE-215-2 note and file-format parity updated.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Mezzotint and Color Halftone applied as smart filters were dropped from PSD
saves with a warning, and the same filters in a Photoshop PSD opened as
unsupported (verbatim) smart filters. smart_map.rs now maps them both ways:
- Mezzotint: filterID/class `Mztn`, `MztT` enum (FnDt, MdmD, GrnD, CrsD,
ShrL, MdmL, LngL, ShSt, MdmS, LngS) <-> `type` (fineDots .. longStrokes).
PhotoCraft's `seed` has no Photoshop equivalent and is not saved.
- Color Halftone: filterID/class `ClrH`, `Rds ` and `Ang1`-`Ang4` longs <->
`maxRadius`, `channel1`-`channel4`, written within the dialog ranges
(4..127, -360..360).
Unknown enum codes, wrong value types and out-of-range values are kept
verbatim as unsupported filters, never a panic.
Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back; codes confirmed by rendering each option.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Smart Sharpen (filter.sharpen.smartSharpen) and Dust & Scratches
(filter.noise.dustAndScratches) smart filters are now mapped both ways in
the PSD filterFX list instead of being dropped on save and opening as
uneditable entries.
- Smart Sharpen: filterID 1131 (numeric), stringID class smartSharpen;
useLegacy bool, Amnt UntF #Prc, Rds UntF #Pxl, noiseReduction UntF #Prc,
blur blurType enum. PhotoCraft removes a Gaussian blur, so it writes
blur GsnB with useLegacy false. Entries with Lens Blur (Photoshop's
default), Motion Blur (with Angl), legacy mode, any other key, wrong
types/units or out-of-range values stay verbatim. presetKind
(presetKindType) is accepted on open but not kept or written: the
engine takes no such parameter, and Photoshop leaves it out of entries
with explicit values.
- Dust & Scratches: filterID / class DstS; Rds and Thsh long (radius
1..500, threshold 0..255; PhotoCraft params are rounded on write).
Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back (Smart Sharpen default / Gaussian / Motion,
Dust & Scratches 1,0 and 4,20). Tests cover the measured shapes, option
round trips, malformed descriptors kept verbatim and hostile params.
Scorecard FILE-215-2 note and file-format parity doc updated.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
smart_map.rs maps four more filters both ways in filterFX:
- Diffuse: filterID `Dfs `, Fltr `Dfs ` { Md = DfsM enum Nrml / DrkO / LghO /
anisotropic (long id) } <-> filter.stylize.diffuse `mode`.
- Trace Contour: `TrcC` { Lvl long 0-255, Edg = CntE enum Upr / Lwr }
<-> filter.stylize.traceContour `level`, `edge`.
- Crystallize: `Crst` { ClSz long 3-300 } <-> filter.pixelate.crystallize.
- Pointillize: `Pntl` { ClSz long 3-300 } plus the entry's BckC as the
`background` colour <-> filter.pixelate.pointillize.
Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back. Our random `seed` has no PSD key and is not
written. Missing keys, wrong value types, out-of-range numbers and unknown
enum values keep the entry verbatim; values with no PSD equivalent on save
are reported instead of guessed.
Scorecard FILE-215-2 note and docs/file-format-parity.md updated (stays
partial).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Blur, Blur More, Average, Despeckle, Find Edges, Facet, Fragment, Sharpen,
Sharpen More, Sharpen Edges and Solarize now map both ways in a smart
object's filterFX. Photoshop stores these filters with hasoptions false, no
Fltr descriptor and only the four-char filterID as a long (Blr , BlrM, Avrg,
Dspc, FndE, Fct , Frgm, Shrp, ShrM, ShrE, Slrz). PSD saves write exactly that
item (Nm, blendOptions, enab, hasoptions, FrgC, BckC, filterID) instead of
dropping the filter with a warning, and Photoshop's entries open as the
editable PhotoCraft filter instead of a verbatim pass-through. An entry that
carries a Fltr with settings, or an unknown or mistyped filterID, is still
kept verbatim.
Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back.
Scorecard FILE-215-2 note and docs/file-format-parity.md updated (25 filters
mapped).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Map three more smart filters both ways in filterFX (smart_map.rs):
- Surface Blur: filterID 1135 (numeric), Fltr class `surfaceBlur`,
`Rds ` UnitFloat #Pxl -> radius, `Thsh` long -> threshold.
- Smart Blur: filterID `SmrB`, `Rds ` / `Thsh` doubles, `SmBQ` enum
(SBQL/SBQM/SBQH -> low/medium/high), `SmBM` enum
(SBMN/SBME/SBMO -> normal/edgeOnly/overlayEdge).
- Radial Blur: filterID `RdlB`, `Amnt` long, `BlrM` enum (Spn/Zm ->
spin/zoom), `BlrQ` enum (Drft/Gd/Bst -> draft/good/best).
Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back. Every measured Fltr descriptor is written back
byte for byte (checked against the measured files locally; they are not
committed). Entries with missing keys, wrong types, out-of-range or NaN
values, unknown enum values or keys we don't model stay verbatim, as does
Radial Blur at its defaults (no Fltr stored). An off-centre Radial Blur
centre and unknown enum names are reported on save, not guessed.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Offset (filter.other.offset) and Custom (filter.other.custom) applied as
smart filters were left out of PSD saves with a warning, and the same
filters in a Photoshop PSD opened only as verbatim, non-editable entries.
smart_map.rs now maps both ways in filterFX:
- Offset: filterID/class Ofst; Hrzn and Vrtc as long (signed), Fl as the
FlMd enum: Wrp = wrap, Rpt = repeat, Bckg = transparent ("Set to
Transparent" on a layer). Unknown enum values, wrong types or missing
keys keep the entry verbatim.
- Custom: filterID/class Cstm; Scl and Ofst as long, Mtrx as 25 longs,
row-major 5 x 5. Written as the engine renders it (no kernel = identity,
missing cells 0, values clamped to -999..999, 1..9999, -9999..9999);
fractional values have no PSD equivalent and are reported. On open,
values outside Photoshop's ranges or a matrix that is not 25 longs keep
the entry verbatim.
Measured in Photoshop: applied through Action Manager to a smart object,
saved, filterFX read back (Offset Wrap 0/0, Set to Background 12/-7,
Repeat 5/3; Custom default sharpen and a blur at Scale 2, Offset 10).
The measured files open as the editable filters and survive export and
re-import unchanged (checked locally, not committed).
Scorecard FILE-215-2 note and the file-format parity doc list the two
filters (still partial).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
With Preferences > General > Animated Zoom on (the default), a Zoom tool
press held still past 0.3 s zooms continuously about the press point
(2 doublings a second, frame-rate independent, within the zoom_levels
range) until release; Alt zooms out. The release is then no click step.
A quick click still steps, Scrubby Zoom and the zoom rectangle are
unchanged, and with the preference off a hold is one click step.
The hold logic lives in the new zoom_hold.rs; canvas.rs only calls it.
general.animatedZoom leaves HIDDEN_UNTIL_IMPLEMENTED; prefs that do
nothing 25 of 166.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Review of f116c32 was right that the guard did not establish the bound it
advertised. reject_bounds ran on the value stroke_coverage() returned, and that
function calls dense_coverage() internally, so the dense Vec was already
allocated by the time it was checked. reject_huge_stroke, the only check that
ran first, estimated from the raw points and half the tip, which brush dynamics
exceed: Scattering offsets a dab centre by its amount times the tip size.
Split rendering from dense extraction. stroke_coverage_within renders the stroke
into the renderer's tiles, which hold only what the dabs touch, reads the extent
once every dab is placed and refuses before allocating the width x height
buffer. retouch_cmds::coverage wraps it at MAX_STROKE_PIXELS and every stroke
command goes through it, so the bound now holds whatever the dynamics do.
reject_bounds is removed; it could not do the job it was named for.
spot_heal_surface returned a bare Rect and so had nowhere to refuse; it returns
Result<Rect> now. reject_huge_stroke stays as the cheap early-out and pads by
the scatter reach and the dual-brush radius so it no longer falls short.
The allowance is unchanged, so no stroke that worked before starts failing.
Tests: coverage_within takes the budget as a parameter, and a scattering brush
proves the rendered extent exceeds what the points alone suggest, is refused one
pixel under it and fits at it. Every command's refusal now asserts the pixels
and the history step count are untouched, and the ordinary stroke uses a valid
fixture and must succeed rather than merely avoid the size error.
Dragging layers onto another document's tab landed in #851 (#589), and the
pasteboard colour menu in #2633 (#2462); the TOOL-214-4 note and the
context-menu parity row still listed both as open.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Releasing Alt counted as a tap (keyboard focus to the menu bar, File lit) unless a key, text or
pointer-button press came in between. A mouse-wheel event was not on that list, so zooming with
Alt+wheel and letting go of Alt focused the bar; Space then opened File with New... lit, and a held
Space's key repeat ran it. A wheel event, or the window losing focus while Alt is down (Alt went
to a desktop shortcut), now cancels the tap.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Cancelling a 24 MP Gaussian Blur r 50 job took up to ~400 ms (P25, budget
200 ms max). Stage timings showed two stretches without a cancel check:
- The transparency-lock pass (keep_alpha) that puts a Background layer's
alpha back after the filter ran serially over every changed tile, about
400-450 ms on 24 MP, with no cancel check. It now fixes the tiles in
parallel, each in its own scratch surface, and checks for cancellation
before each tile. Results are bit-identical to the serial pass (tested
against it at 8, 16 and 32-bit).
- Box-blur column strips checked only before each strip; with ~31 strips
per band and 32 threads one strip is tens of ms. line_passes now checks
between passes.
P25 max: 337-399 ms -> 13-44 ms over five runs. The uncancelled job is
also faster: p50 about 690 -> 400 ms.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Preflight oversized pixmaps and conversion peak before allocating.
Only charge the U8 retained-surface allowance after render succeeds.
Clarify the warning and cover later small raster groups with regressions.
Merge upstream main to refresh the pull request branch.
Tool options and the selected tool were already remembered (tool_memory, #2814), but
each painting tool's own brush (UiState.tool_brushes) was lost at quit, and the tool that
owned the session brush restarted as the Brush, so after quitting with the Eraser its
brush became the Brush's.
- Preferences.toolBrushes {owner, brushes}: saved change-driven when the pointer is up and
on exit, restored at launch with stored tips as references (loaded on swap).
- brush_cmds::remembered_settings / restored_settings: shared with lastBrush; restored
brushes pass the tools.setBrush checks and are clamped (size, hardness, opacity, flow,
smoothing).
- tool_memory::sanitize clamps restored tool options to their options-bar ranges.
- Reset Tool / Reset All Tools clear the persisted state through the same path.
- Scorecard UI-217-1 note updated (stays partial: font recents).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Preferences > File Handling > Save in Background was never read. Saves
already run as a background job; the setting now picks how the app
waits for it, as in Photoshop:
- On (default): you keep working while the file is written; progress
shows in the document tab and the status bar. The modal progress
dialog no longer appears for these saves.
- Off: the same job, worker and atomic write, but the modal progress
dialog shows at once and no shortcut reaches the app until the file
is complete; a close or quit asked for meanwhile waits for it.
The setting now shows in Edit > Preferences. Prefs audit: settings that
do nothing 26 -> 25 of 166.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
A shape below 100 % Density renders over the whole canvas, and a Feather
grows its pixels, so Free Transform, the Move tool's transform controls,
snapping and Properties > Transform framed the canvas or the blur. As in
Photoshop the frame now follows the path and stroke whatever the mask
Density and Feather: photocraft_compose::hard_shape_bounds (shape_bounds
without the soft reach) feeds whole_layer_bounds and the Properties
Transform fields. Rendering keeps the soft bounds; other layers and hard
shapes are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Explain why clip_damage replaces the covered intersection, including an
empty one, while retaining the affected region for uncovered to recompute.
This is a comment-only follow-up to review on storytold/photocraft#5413.
Existing controlled 24 MP release measurement: median refresh/upload-
submission time was 734.677 ms for the full 6000x4000 region and 59.641 ms
for a 1280x720 viewport, five measured samples per region after warmup.
This compares regions in the same candidate binary on CPU fallback with
Vulkan llvmpipe, not two revisions or physical-GPU completion latency.
Evidence: https://github.com/jonathanwxh-cell/photocraft/actions/runs/38512473874
- Edit > Purge > Histories resets a state source to the default instead of
leaving every document without one (History::clear).
- The pinned source state counts against the history byte budget first; the
newest states fill what remains, so trimming stays within max_bytes.
- The History Brush and Fill > History refuse a source of another canvas size
(after Crop, Canvas Size or Image Size) instead of painting shifted pixels.
- Snapshots can be the source: history.setSource {snapshot}, a source column on
snapshot rows, HistorySource::State|Snapshot in the engine; deleting the
snapshot leaves no source, Purge > Histories keeps it.
- paint.historyBrush and Fill > History take History panel rows for `state`
(redo rows included), as history.setSource does, and accept `snapshot`.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Step Backward in the History panel puts a floating selection back like Edit > Undo; Step
Forward and Edit > Redo put it back and step instead of dropping it first (which recorded a
Move and wiped the redo states); with nothing to step to the piece keeps floating.
- Memory figures and the byte budget count the states Clear History keeps for its undo.
- Undoing Clear History keeps a saved document clean.
- New Snapshot... in the panel menu runs history.newSnapshot.
- Tests: Clear on one document leaves the others alone, and each fix above.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Pattern Stamp was the only retouch command that refused a stroke it could not
render. Clone Stamp, Healing Brush, Spot Healing and Remove all reach the same
dense conversion: coverage is accumulated in tiles, but dense_coverage()
materialises the whole bounding box, and the retouch paths read a Region over
that box for every channel. The box comes from the stroke's own points, which
are validated against MAX_COORD rather than the document, so it is not bounded
by the canvas. Both allocations go through the infallible vec!, which aborts
instead of returning an error.
With the guards now shared in retouch_cmds, apply them to the rest:
reject_huge_stroke before anything is rendered, and reject_bounds before the
dense region read where the command can return one. spot_heal_surface returns a
Rect, so Spot Healing charges the stroke in the command; Remove builds a list,
so each stroke is charged as it is parsed.
The allowance is unchanged, so no stroke that worked before starts failing.
Test: every retouch stroke refuses the oversized box, leaves the pixels alone,
and still accepts a stroke across the whole canvas.
Image > Mode > Bitmap now opens a dialog laid out like Photoshop's:
Resolution (Input, read-only; Output in pixels/inch or pixels/centimeter),
Method (50% Threshold, Pattern Dither, Diffusion Dither, Halftone Screen,
Custom Pattern) with a pattern picker enabled for Custom Pattern, and the
Halftone Screen settings (Frequency, Angle, Shape) shown only for that method.
The image.mode.bitmap command gains `resolution` (resamples to the Output
resolution keeping the print size, then sets it) and the `customPattern`
method (threshold map ranked from the pattern's lightness, tiled from the
top-left). The halftone screen is laid out at the output resolution. The
live preview keeps the canvas size.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
An entry's value only has to lie inside the file, so many Exif entries can
point at the same bytes: a 62 KB TIFF with 1,000 such entries made the reader
copy 50 MB (and the block writer copy it again), on every TIFF import and on
export of a document carrying an EXIF block.
read() now has a copy budget shared by IFD0's descriptive tags and the GPS and
Exif directories: at most 4 MiB, and never more than the stream's own length,
since the values of a well-formed stream are distinct bytes of it. An entry
that does not fit is left out and later, smaller ones are still kept; the
small directories are read first so a huge maker note cannot crowd out the
GPS position. Maker notes of 200 KB and 1 MB round-trip as before.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rj8vs4QyUES3uwgX6SuGGh
With documents tiled side by side, a click into the other one moved it
to the first tile, swapping the two, and only the active tile showed
rulers. Each document now keeps its tile (with more documents than
tiles, the shown run slides just enough to include the active one), and
inactive tiles and floating windows draw their own document's rulers.
Guides and the zero point are still dragged in the active view.
Unicode's line-breaking rules allow a break between any two Hangul
syllables, so Korean box text split words (설 / 정이나). Korean is set
with breaks at the spaces between words, as Photoshop does: runs of
Hangul now lay out with WordBreak::KeepAll. Chinese and Japanese keep
breaking between characters.
- the bevel program asks for both fields with B::bevel_fields, so they always come with the
same reach;
- field and bevel_fields share one windowing (fields_in_windows: one window, or bands on wasm),
so wasm pairs them per band instead of taking a separate path;
- compose's bevel_height and bevel_distance_fields share bevel_dists;
- sync_fx recomputes the pair together and returns Unsupported when the two are not over the
same rectangle at the same reach, instead of falling back to one at a time.
Test: paired_bevel_fields_equal_the_fields_one_at_a_time also checks the banded path.
On the GPU canvas a bevel size change recomputes BevelInside and BevelOutside on the CPU, one
distance_field call each, and each call ran sharp_edges (edge pixels and refined normals) on the
same window again; the CPU compositor already shares them (bevel_height). sharp_edges was ~17% of
the CPU time of those size changes.
When both fields are recomputed over the same rectangle at the same reach (a size change, a new
shape), sync_fx now computes them together: compose::effects::bevel_distance_fields finds the
sharp edges once and returns both. Same window, same functions, so both fields are bit for bit
the ones computed one at a time; the chamfer fields, wasm and a single bevel field keep the old
path.
Test: paired_bevel_fields_equal_the_fields_one_at_a_time (reach 3, 9, 40; whole region and two
parts; bit-equal to field() of each kind).
A chisel bevel size change rebuilds the bevel maps of the whole layer region. The distance
fields already run on threads (#2119), but the passes after them still ran on one thread over
every pixel: the chisel height from the two distances, the texture term, the highlight/shadow
shading (once, or twice for emboss styles) and the emboss inside/outside mix.
Each of these computes a pixel from its own inputs only (the height map, the shape, the
distances), so they now go row by row through par_units / par_fill like the fields: the same
arithmetic in the same order per pixel, so the maps are bit for bit the old ones whatever the
thread count. Maps under 65536 pixels and wasm stay on the calling thread.
Test: bevel_maps_do_not_depend_on_the_threads (4 shapes x 5 styles x 3 techniques, with soften
and a texture, 1 vs 2, 5 and 16 threads, every map bit-equal).
An Indexed Color document holds only its colour table's colours, and a
save quantises to them. Interpolating methods showed blended colours on
screen that the saved file then lost. image.imageSize now resamples an
Indexed document with Nearest Neighbor whatever method it is given, and
the dialog offers only Automatic and Nearest Neighbor for one.
In an open menu a plain letter runs the row with that mnemonic. Typing
into Help › Search did too: "S" ran System Info and "A" opened the
ArtCraft website, so only "De" of "Deselect" could be typed. Mnemonics
now wait while a text field in the menu has the keyboard.
The History Brush renders its coverage and reads its source region densely
over the stroke's bounds. Coverage is accumulated in tiles, so a thin stroke
holds only the tiles it touches, but dense_coverage() materialises the whole
bounding box and Region::read allocates that box again for every channel.
Neither is admitted first, and both go through the infallible vec!, which
aborts the process instead of returning an error.
Pattern Stamp already refuses a stroke it cannot render: reject_huge_stroke
bounds it from the points and the brush size before anything is rendered,
reject_bounds checks the rendered box, and MAX_STROKE_PIXELS (8192 squared)
leaves room for a stroke that runs corner to corner across a 24-36 MP canvas.
The three were private to that module.
Move them to the shared retouch_cmds module and apply them in the History
Brush: reject_huge_stroke before the source surface is cloned or any coverage
is rendered, reject_bounds before the dense source read. The budget is
unchanged, so every stroke that worked before still works.
Clone Stamp, Healing Brush, Spot Healing and Remove reach the same conversion
and are left for a follow-up.
Test: a stroke whose box dwarfs the canvas is refused with the size error and
leaves the pixels as they were, while one that runs corner to corner across
the canvas still paints, at 8, 16 and 32 bits.
Photoshop writes image resource 1092 (guides v2: 1032's guides plus a
colour per guide) and 1097 (one GUID per guide) next to 1032. Export
regenerated 1032 from the document but wrote 1092 and 1097 back verbatim,
so after Clear Guides, New Guide, a move or a recolour the file carried
two guide lists that disagree.
Both are now written back only while 1092 still describes the document's
guides (positions, directions and colours) and 1097 has as many entries;
otherwise both are left out and the file carries 1032 alone, as files
from before guides v2 do. Import takes the guides' own colours from 1092
(Photoshop's default Cyan stays with the preference colour), the read
half of #5967.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
brush.presets.importAbr named each imported preset with unique, which
compared a candidate name with the names of the presets one by one and
tried "Name", "Name 2", "Name 3" and so on until one was free. A
version 1 or 2 brush with a computed tip and no name is named after
its tip ("9 px Round"), so a file of n such brushes of one diameter
cost about n^3/6 string comparisons. In a test build, importing 10,000
of them took 25 minutes (22 minutes of CPU time).
The import now builds the set of preset names once and keeps the next
number to try for each repeated name, as swatches.import does for
swatch and group names. Names still compare case-insensitively, and
every preset gets the same name as before. brush.presets.importImage
names its one preset with the same code.
repeated_brush_names_are_numbered_in_linear_time imports 10,000 such
brushes on a thread of its own and fails when the import takes more
than 20 seconds. repeated_brush_names_take_the_first_free_number
checks the names when the file repeats a name in other letter cases,
when a preset already holds one of the numbers, when the file has a
name that the import already gave to an earlier brush, and when a
name clashes with a built-in preset.
A shape layer's path is its vector mask in Photoshop (Properties > Masks),
with its own vector-mask density and feather. PhotoCraft's ShapeLayer had
neither, so soft shapes could not be made, and stroked soft shapes from PSDs
lost both on import.
- doc: ShapeLayer gains `density` (0..1, default 1) and `feather` (px,
default 0).
- vector: the shape renderer applies them to the fill as a soft vector mask
(fill shows beyond the path at 1 - density; the feather is the vector
mask's Gaussian), so the shape's own pixels, the GPU (which samples them)
and the stroke/fill split all follow. The stroke stays crisp. The feather
blur moves from compose::masks to vector::feather and both share it and the
feathered area, so a shape and an equal vector mask render the same pixels.
- compose: soft shapes' bounds reach their fill, their layer effects follow
the soft alpha, and the split cache key includes density and feather.
- engine: `layer.vectorMask.edit` on a shape layer sets density/feather in one
history step (path/enabled/linked/invert are refused there); density is now
validated (finite 0..100) instead of clamped. `layer.vectorMask.info`
reports a shape's values.
- ui: Properties shows the Vector Mask Density and Feather sliders for a
selected shape layer in both themes (live drag, one undo per gesture).
- .pcraft: manifest and convert carry both with serde defaults (older files
load at 100 % and 0 px).
- PSD: export writes them as the vector density/feather mask parameters;
import reads them into the shape (re-rendered, as the stored pixels lack the
fill beyond the path). Soft unstroked solid/gradient shapes now import as
shapes instead of fill layers with a vector mask; pattern-filled ones stay
fill layers.
Photoshop's 10 px feather on a 100 px black square, row 100, x = 30..72:
ours is within 1 level of every measured value (8, 16 and 32 bits);
density 50 % shows 127/128 beyond the path and 0 inside.
Scorecard: TOOL-214-12 done.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Main's History panel can select a snapshot, and its trash then deletes that
snapshot (#5324). The panel menu's Delete still deleted the current state.
It now follows the same selection.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Main has the marquee Shift+Alt intersect test (#3624), so the row no longer
says it is missing.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Gradient layer effects baked only their colour stops; the opacity stops came
through raw, so Smoothness, opacity midpoints and the interpolation method
never reached the transparency ramp. Gradient fill layers applied Smoothness
and midpoints but always with Classic.
Both now share one bake (gradient_bake::bake_opacity). Measured on scripted
Photoshop 25.1 (overlays and fill layers render identical pixels): Linear and
Perceptual also bend the opacity curve (stops enter the method's space as
sRGB grey levels, the result leaves as y^0.65), fitted within 0.015 of every
8-bit sample. Smooth keeps Classic's opacity curve (not measurable on 25.1).
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The merge of main (5a4df1a3) committed unresolved conflict markers in the
status lines of gaps.md, target-app-parity.md and ui-parity.md and in the
Type/Vector rows of target-app-parity.md. Keep main's text and add this
PR's own items back: G8 Warp Text preview done (TYPE-218-7), the Type
row's evidence, and the selection-modifier and Alt-wheel rows.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Before this change, Export As always resampled the exported copy with Bicubic, and the preview
used Bilinear. Pixel art and screenshots could not be enlarged with hard edges. Photoshop's
Export As has a Resample menu next to Width/Height/Scale for this.
- engine export_preset_cmds: RESAMPLE lists Photoshop's six choices (Bicubic Automatic, the
default, then Bicubic, Bicubic Smoother, Bicubic Sharper, Bilinear and Nearest Neighbor)
and export_resample() checks a value. An unknown value is now an error. Before, Image Size
quietly treated it as Bicubic.
- Export presets save and restore resample. Older presets load with the default.
- layer.exportAs takes a `resample` param, checked even at 100 %.
- Export As dialog: a Resample dropdown under Image Size. It is remembered with the other
settings, and a bad saved value falls back to the default. export_document passes the
choice to image.imageSize for the exported copy only.
- Preview: the proxy keeps its fast Bilinear path, except for Nearest Neighbor, which is
just as cheap and is what the user wants to see. The preview texture also uses NEAREST
magnification then, so small exports look blocky instead of blurred.
- Translations for the three new labels in all 20 catalogues.
- Scorecard FILE-215-18 is now done. Also fixes a stale doc comment on VectorMask::feather,
because the compositor does feather vector masks.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
With Format "Same" every output took its input's extension, so a folder of
ARW/CR2/NEF/DNG files tried to write raws, which PhotoCraft can't, and every
file failed. Raws and other import-only inputs (PDN, Krita, Affinity) are now
saved as PSD; other inputs keep their format.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Re-checked every row this PR flips against current main: the cited
tests still exist and none of the rows was flipped meanwhile.
BUG-208-4 is done too: shift_alt_before_the_drag_intersects_the_selection
(marquee_tests.rs) covers the marquee Shift+Alt intersect.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Session::edit has reported a new layer's bounds as damage since #3847, so
Duplicate Layer, Duplicate Layers and Paste no longer need to keep the
document from before the edit and work the damage out again with
note_insert. Paste Into still does: it is three edits.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A Multichannel PSD stores ink planes as tone (0 = solid ink), but the
planes DisplayInfo (1077) marks as alpha-type (kind 0/1) are stored as-is,
like alpha channels in every other mode. Import inverted every plane and
export wrote every channel inverted, so a white (selected) alpha imported
as 0 and RGB -> Multichannel -> save wrote alpha channels black.
Measured with a file Photoshop saved itself: a new 8x4 8-bit white RGB
document plus a "selected areas" alpha channel, white left half / black
right half, converted to Multichannel and saved as PSD. Merged planes:
Cyan, Magenta, Yellow = 255 everywhere (ink-inverted: no ink on white
paper); alpha = 255 255 255 255 0 0 0 0 per row (as-is, 255 = selected).
DisplayInfo kinds: 2, 2, 2, 0.
- io: after DisplayInfo, flip alpha-type channels back; on export invert
only spot channels.
- compose: an alpha-type channel prints as black ink of 1 - value, so the
composite of a Multichannel file is unchanged.
Tests: a kind-0 alpha imports as stored (white = 1.0) with the spot plane
still read as ink, the composite and the byte-for-byte round trip hold
(8 and 16 bit); a full alpha exports as a max plane next to a solid ink
stored 0. The #4031 composite check now compares against a black ink of
the inverted alpha, within f32 rounding; the compose unit test uses the
as-is polarity for its alpha channel.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
With alpha kept (Lock Transparent Pixels, Fill's Preserve Transparency),
the brush, mixer, Fill, Gradient and Paint Bucket composited source-over
and then restored the old alpha, so a half-transparent pixel took the new
colour by as / ao instead of as. They now blend source-atop, as Photoshop
does: Co = (1-as)*Cb + as*B(Cb,Cs), alpha unchanged. New shared helper
blend::composite_atop; over_native's lock branch lerps by the source alpha.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
CharStyle had no superscript/subscript attribute, so PSD import ignored FontBaseline and the first re-render (entering text editing) drew the run as plain text. Model it as CharStyle::baseline, read and write FontBaseline in EngineData, draw such runs at 58.3% of their size shifted by 33.3% of it (Photoshop defaults), and accept "baseline" in the character params.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Smart-guide targets were built even when the master snap switch was off; gate them on snap_on like other targets.
Co-authored-by: Cursor <[email protected]>
layer.newFillLayer.gradient and gradient.presets.apply cast `angle` to f32
unchecked, so 1e39 stored an infinite angle (NaN render, .pcraft save
refused) and 450 stayed 450. They now share gradient.fill.set's parsing:
non-finite is BadParams, the angle is normalised to (-180, 180].
Pattern placement (Pattern Fill layer, Edit > Fill, Paint Bucket) does the
same for its angle and rejects a phase outside +/-1e7 like pattern.fill.set.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Smoother::push moved the brush part of the way to the pointer but gave
every point the pointer's newest pose (`..p`). On a fast stroke the
brush trails far behind, so when the pen lifted, the trailing brush took
the lift's near-zero pressure while still short of the end, and the
end catch-up drew a straight hairline from there to the pointer: a thick
stroke stepping down to a thin tail.
The brush's pressure, tilt, rotation (the short way round) and wheel now
move with its position, for exponential smoothing and the pulled string.
The end catch-up then ramps from the brush's pressure to the pen's last,
a taper instead of a step.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
From Transparency and Mask All Objects attached a mask to the Background
without turning it into "Layer 0" first, unlike every other add-mask path.
They and Hide Selection also built their masks as 8-bit, so a 16/32-bit
layer's alpha was quantised to 256 levels by From Transparency. All three
now build the mask with mask_at_depth at the document depth; 8-bit
documents are unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
adjust_as_smart_filter recorded image.adjustments.* (and Shadows/Highlights)
as a smart filter without checking locks, so a Lock All or Lock Pixels smart
object still took the filter. It now refuses with check_pixels_unlocked, the
same rule filter.* smart filters use. Regression test covers both locks.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Both commands stored the `fps` param unchecked: 0 or a negative rate was
kept, and 1e39 became an infinite rate the next .pcraft save refused. They
now use timeline_cmds::fps_param (finite, above 0, at most MAX_FPS), as
timeline.create and timeline.setProps do; a missing fps keeps the old
fallback. Regression test covers 0, -1, 1e39 and "x" for both commands.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
count.deleteGroup only clamped the active index to the new length, so
removing a group before the active one silently made the next group
active and later count.add calls landed there. Shift the index down
when the removed group precedes it; deleting the active last group
still selects the new last group.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
With always() the gate also ran when a newer push cancelled the run,
taking a runner slot only to report a red check on the superseded
commit. !cancelled() still runs it when a test leg fails (a skipped
required check would count as passing), but not in a cancelled run.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Deleting or dragging away the only smart filter now clears the filter mask, as
Clear Smart Filters already did, so the next filter takes the new selection.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Root cause: run_filter in lens_cmds.rs added the smart filter and returned
for a smart object before it checked the pixel and full locks, so a smart
object with Lock All or Lock Pixels (its own or a parent group's) took the
filter and a history step.
Fix: check the locks before the smart-object branch, as other filters do.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The canvas|gpu|mip|type_tool name filter missed ui-egui unit tests that
build a Harness with a real wgpu device: background_redraw's GPU tests,
panel_docking's floating_group_titles_are_opaque_in_studio_themes and
swap_arrow's rendered_glyph_has_a_left_head_and_a_lower_right_head. Name
them in both overrides, so they share the 4-thread cap and get the CI
retries. The other .wgpu() harnesses only render when a fixture env var
is set, or are #[ignore]d.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
monitor_macos is harness = false, so nothing answered cargo-nextest's
discovery: `--list --format terse` ran the whole test and nextest failed
to parse its output on macOS. List the one test, and nothing for
`--ignored`, as mac_menu_appkit already does; `monitor_macos --exact`
still runs the body.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
selected_style reads the font engine through try_shared, which returns None while the system
font scan is still running. Under cargo test the test passed only because an earlier test in the
same process had already finished the scan; run on its own (nextest runs every test that way, and
`cargo test -- --exact` does too) it saw no engine and got "Regular" instead of "SemiBold". It now
waits for the scan with photocraft_text::shared(), as every_theme_shows_the_type_options does.
On main, alone, it failed before this and passes after, under both cargo test and nextest.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Splitting the Windows tests renamed their check from `test (windows-latest)` to one per leg. No
status check is required on main today, but if one is added under the old name it would wait
forever. A small job under that name now fails unless the platform pick and the whole test matrix
passed (`needs` gives one result for all legs, so a failing Linux leg fails it too). It checks the
result instead of exiting 0 under `if: always()`, which would pass with every leg red. Suggested
by Zach in review.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The test runs without a harness (AppKit needs the main thread), so nothing answered the `--list`
that cargo-nextest sends each test binary: on macOS the binary ran the whole test as its listing
and nextest failed on the output. It now lists itself in libtest's terse format, and nothing for
`--ignored`; a run passes `--exact <name>`, which falls through to the test as before. Found by
Zach running `cargo nextest run --workspace` on a Mac.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The Blur and Sharpen tools ran each dab's separable Gaussian
(local_blur) and the mix back onto the working copy on one thread. A
large brush is a lot of work per dab: a 2500 px dab is about 10^9
multiply-adds, so strokes on big images stalled the app for seconds.
Both passes of local_blur and the per-dab mix now run their rows in
parallel with rayon, as blur.rs already does. Every row does exactly
the same arithmetic in the same order, so the output is bit-identical:
the layer pixels after the strokes below hash the same as on main.
paint.blur, 200-point stroke on a 6000x4000 noise layer, release,
10-core M-series:
brush commit (main -> this) live stroke (main -> this)
300 px 323 ms -> 124 ms 497 ms -> 314 ms
1000 px 1.11 s -> 312 ms 1.70 s -> 996 ms
2500 px 2.77 s -> 843 ms 4.26 s -> 2.44 s
The live stroke keeps a larger serial share (region copies and
mix_back per push); that is left for a follow-up.
Refs #5426
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The integration tests run as one leg again. On the Windows runner (EPYC 7763, 3 runs) that leg
took 14.3-15.3 min, against 10.5-10.8 for each half, so the slowest leg is now the integration
one at ~15 min instead of the unit one at ~12.8. In exchange each PR that runs Windows takes two
Windows runners instead of three and ~7 fewer runner-minutes: every leg pays its own setup
(~1 min) and builds the workspace libraries its tests link against (~4 min even from sccache).
That matters more while PRs queue for the account's concurrent-job limit.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
#3560's test expected the flat Bitmap file to be saved with a layer section.
With #4814 Bitmap is written in its own mode, which has no layers in
Photoshop: the test now checks the Bitmap header, the missing layer section
and that the saved file reopens with its Background.
Conflicts:
- edit_menu_cmds.rs: main's #4133 (Sample All Layers off unless the active layer has no pixels
in the window) now decides `sampleAllLayers` inside the workspace's fill plan; the job keeps
the plan-based fill, the spec keeps the workspace's parameters.
- lib.rs: both module lines (caf_ui, cam_preview).
- hu, tr, vi catalogs: both sides' rows. Dropped de's High/Low (main now has them) and hu's
older "Vanishing Point" row main reworded. Added the 15 workspace strings to sr.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
None of the five rows was flipped on main; every test the notes cite
exists on main. Main made TYPE-218-1 (type on a path) partial, so the
Type recount in gaps.md G8 and the target-app-parity Type row now say
5 done, 3 partial, 1 missing and name it partial. Header lines of
gaps.md and ui-parity.md keep main's entries plus this PR's.
docs/scorecard.md regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The reduced CPU canvas composites one-colour tiles once per colour instead
of every document pixel, and GPU canvas textures the device refuses fall
back to the CPU canvas instead of disabling the GPU for the session.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
A brush dab on an A4 300 ppi CMYK page spent ~5 ms per touched tile in the
GPU canvas's tile upload: every pixel of the 256x256 tile went through the
CMYK -> sRGB transform on one thread. CMYK and Lab tiles now convert in
16-row bands in parallel, and a run of identical pixels (the transparent or
flat area around a dab) is converted once. Texels are bit-identical to the
per-pixel conversion.
The band workers enter the document's CMYK profile scope; before, the rayon
workers converting tiles did not, so a CMYK document tagged with its own
profile uploaded tiles through the built-in coated CMYK instead.
P33 (Windows 11, 32 threads, RTX 5070 Ti, 15 reps): p95 13.7-16.8 ms -> 2.6-3.3 ms.
enforce stays false: the committed baseline machine measures every row
over budget, so it is left for the next baseline.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
photocraft_psd::fixtures (feature testgen) generates the FILE-216-2
fixtures at test time: a 20 MP layered PSD and a 150-layer A4 layout
(groups, masks, hidden layers, drop shadows, known merged colours), a
2.02 GB PSD just under Photoshop's cap and the 4-5 GB PSBs, streamed row
by row. io tests/large_files.rs opens, checks, saves and re-opens each
(the 2 GB PSD and 4.38 GB PSB are opt-in). The ui-egui large_file_bench
times opening the big two, wired to P20 and a new P60.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Background Eraser: main now samples and matches against the pre-stroke pixels (#4552); the
shared dab effect takes the pre-stroke surface so the live preview, whose working areas are
per dab, reads the same pixels as the commit.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Conflicts in docs/gaps.md, docs/target-app-parity.md and docs/ui-parity.md:
kept main's status lines and rows and this branch's Type, selection-modifier
and Alt-wheel evidence. docs/scorecard.md regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The layered TIFF export builds its layer data through the PSD exporter,
which now writes these modes in their own PSD mode (palette, 1 bit,
duotone). TIFF has no such layout and interleaves the merged image in
the pixel format, so the PSD model for it is made in that format (RGB or
Grayscale), as before.
Refs #4814
Indexed Color (mode 2): the flattened document as palette indices, the
Color Table as Color Mode Data (256 reds, greens, blues), its length in
resource 1046 and the transparent entry in 1047; the importer now reads
both back. Bitmap (mode 0): 1 bit per pixel, 1 = black. Neither mode has
layers in Photoshop, so both are written flat, like Multichannel; the
layerless file building is shared with multichannel_map.
A Duotone keeps the ink data it was imported with (undocumented, kept
verbatim in Duotone::psd_raw) over its grey plate. Duotones whose inks
were set in PhotoCraft have no such data and stay Grayscale with the
warning, as before.
Fixes#4814
Hungarian and Turkish now claim complete menus, so the History panel
menu's New Snapshot… and History Options… need entries there too.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
i18n::tests::every_tl_literal_is_translated needs every tl! string in the complete catalogs:
the options bar hint and the History panel source column tooltip get rows in all 17.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
main added a complete Vietnamese catalog; the panel menu's new labels need rows there too.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- The fill area and sampling area (`content_aware_plan_with`) are worked out
inside the background job, cancellable row by row as #3395 made the rest of
the fill: the window and selection are read in parallel bands, Auto sampling
is `auto_sampling_with`. The params are still checked before the job starts.
The workspace's preview builds its plan with its own cancel flag.
- The apply step is #3395's: only the fill area's bounds keep the original
pixels and coverage. `CafPlan::layer_px` is kept only when Sample All Layers
samples something else than the layer.
- `fillEdits` and `samplingStrokes` take at most 1000 entries and 200 000
points in all (MAX_CAF_EDITS, MAX_CAF_POINTS).
- `paint_strokes` leaves a mask that does not cover its area alone, writes
through `get_mut`, and visits each segment's own bounds instead of the whole
stroke's for every segment (the same pixels). The workspace's
`sampled_at`, `in_hole` and outline read their buffers with `get`.
- Content-Aware Scale's transform box (#3096) and the Vanishing Point
workspace keep their menu entries next to the Content-Aware Fill workspace.
- Vietnamese labels for the workspace.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Pattern Stamp (aligned through LiveRetouch, Impressionist dab by dab) and
Background Eraser now paint during the drag; the release commits exactly the
pixels the live stroke showed (per-pixel tests at 8 and 16 bits). Spot
Healing keeps healing on release and shows a dark overlay while dragging.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The History panel's menu (☰) now has Photoshop's items: Step Forward, Step
Backward, New Snapshot… (greyed, snapshots aren't built yet), Delete, Clear
History, New Document, History Options… (greyed, built in parallel) and Close,
above the dock's own group items. Each runs an engine command.
- history.stepForward / history.stepBackward: one state along the list
(Err when there is none).
- history.clear: removes every state but the current one without changing
the image. It is undoable: History keeps the cleared states until the next
step, and Edit > Undo puts them back. Fade is dropped and can no longer
underflow on an empty history.
- history.deleteState is enabled only when there is an earlier state.
- Translations for the new labels in all complete-menu languages.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
The History panel gets Photoshop's source column at the left of every state:
the History Brush icon marks the source, and a click there makes that state
the source through the new `history.setSource {state}` command (not a step,
and the row is not stepped to). `paint.historyBrush`, the interactive History
Brush and Fill > History use the source when no `state` is passed; by
default it is the oldest state held, as before.
The source is kept by state, not index: photocraft-ops gives every history
state a stable id, so the source stays on its state while older states fall
off. As in Photoshop (checked by script), the source state itself does not
fall off the history-state limit or the memory budget, and once it is
deleted or discarded by a new step after stepping back there is no source:
the History Brush then refuses to paint without an explicit `state` until a
new source is set. Rows count the greyed redo states too, so the common
"filter, step back, paint the filtered state in" workflow works.
`document.inspect` / `ui_inspect` report `historySource` {state, label}
(null when its state was deleted). Snapshots are not implemented yet:
`snapshot` returns an "unsupported" error.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
AGENTS.md sends contributors to the scorecard's missing and partial rows
when picking work; these five described code that has since landed.
- TYPE-218-7 missing -> done: Warp Text opens with the layer's warp
(filter_dialog.rs warp_defaults) and previews live; covered by
warp_text_dialog_tests.rs and
warp_text_preview_matches_the_full_size_result_on_a_reduced_proxy.
- TOOL-214-13 partial -> done: Properties shows Density and Feather for
both pixel and vector masks (mask_props_ui.rs, mask_props_ui_tests.rs).
- BUG-207-5 missing -> done: all twelve retouch tools are tested against
mask, alpha channel, Channels-panel and Quick Mask targets; Sample All
Layers and the smudge fringe have their own tests (retouch_cmds/tests.rs).
Pencil and Mixer Brush targets are still untested, noted in the row.
- UI-217-18 missing -> partial: Alt+wheel zooms around the pointer at
x1.1 a notch, measured on Photoshop in #1718, not the ~5 % the row asks
for; the status bar zoom controls fit (#2823); Navigator not checked.
- BUG-208-4 stays partial with a corrected note: marquee add/subtract,
Lasso add/subtract/intersect and Polygonal Lasso are tested from
modifiers held at the press; a marquee Shift+Alt intersect test is
still missing.
docs/gaps.md (G8), docs/target-app-parity.md (Type row) and
docs/ui-parity.md updated to match, with revision-history rows.
docs/scorecard.md regenerated: Tools 26/15/2, Workspace and UI 8/9/6,
Type 2/4/3, Open bugs 30/2/0.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Comments and docs still said CI runs cargo test, that the craft-fonts tests run a second time in
the Linux job, and that CI's debuginfo matches the dev profile everywhere. development.md also
notes that test builds compile ui-egui unoptimized.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
nextest --partition splits the integration tests in two, so Windows runs three legs: on the
Windows runner (EPYC 7763, 2 runs) the integration halves took 9.8 and 10.8 min against 13.8 for
one, leaving the unit leg (~12.8) as the longest.
A ci nextest profile retries the GPU tests: on WARP, under load, one hit wgpu's poll timeout once
in ~40 runs while measuring. A test that passes on retry is reported as FLAKY.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
rust-cache keeps only the dependencies, so every run recompiled the workspace crates. sccache
caches their libraries (test executables can't be cached and still link). Warm, on the Windows
runner (EPYC 7763, 2-3 runs), the unit leg built in 7.1 min instead of 11.5 and the integration
leg in 4.3 instead of 8.8. Only main writes (PRs use SCCACHE_GHA_RW_MODE=READ_ONLY); a full set
of entries was ~90 MB.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Unit tests (--lib --bins, plus doctests) and integration tests (--test '*', plus Clippy and the
layering check) now run on two Windows runners at once. The feature selection is the same, so
both legs restore one dependency cache; the integration leg saves it, so Clippy's metadata stays
in it. On the Windows runner (EPYC 7763) the job took 28.3 min; the legs take 18.2 and 18.3,
running the same 6095 tests between them.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Its library and unit tests were the biggest test-build units after engine's (12.6 of ~63 compile
minutes on the Windows CI runner), and its tests run no heavy pixel code of their own. On the
Windows runner (EPYC 7763, 3 runs vs 7): build 18.9 -> 15.4 min, test run 10.0 -> 10.5. Only test
builds change; cargo run and cargo build use the dev profile.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
They ran after the main tests in the Linux test job and added ~10 min to its critical path (and
were why that job ran out of disk). Now a two-leg job restores the test job's cache read-only
(shared-key: test) and saves nothing, so the cache budget (#2688) doesn't grow.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
With the binaries running in parallel, running the rest after a failure costs little, and a red
main no longer hides other failures behind the first one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Writing PDBs for ~200 test binaries took 2.2 of ~21 build minutes on the Windows runner (3 runs
each on the same CPU model). A panic still reports its file and line; Linux keeps
line-tables-only for backtraces with lines.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
cargo test runs the ~200 test binaries one after another; on Windows that took 14 of the job's
37 minutes, 5 of them in gpu's parity tests on the software adapter. nextest runs them in parallel
with the GPU group from .config/nextest.toml. The examples were built and linked but never run;
Clippy's --all-targets still checks them. Doctests get their own step.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Edit › Content-Aware Fill… opens Photoshop's workspace (measured on 25.4)
instead of the generic dialog: a full-window dialog like Liquify's.
- Tools: Sampling Brush (B), Lasso (L), Hand (H), Zoom (Z), each with its
options bar.
- Document view: the sampling area tinted green at 50 % and the fill area's
marching ants.
- Preview panel: the fill computed in the background after each change,
opening framed on the sampling window.
- Content-Aware Fill panel: Sampling Area Overlay (Show, Opacity, Color,
Indicates), Sampling Area Options (Auto, Rectangular, Custom, Sample All
Layers), Fill Settings, Output Settings, and the reset buttons.
- OK, Apply, Cancel.
As Photoshop's:
- The Sampling Brush subtracts in Auto and Rectangular and adds in Custom,
which starts empty; Alt reverses. Its first size is a tenth of the
window's side. Painting keeps the option, and added strokes may lie past
the window.
- The lasso edits the fill area (New, Add, Subtract, Intersect, Expand,
Contract); the window and the Auto area follow.
- Apply fills and stays open; Cancel keeps what was applied. Ctrl+Z undoes
a stroke or an edit. Output opens on New Layer; settings are remembered.
The workspace holds plain data. `edit.contentAwareFill` takes it as
`samplingStrokes`, `fillEdits`, `sampleAllLayers` and `layer`, and both
the preview and OK run one plan (`content_aware_plan`), so the preview is
what gets written. New strings are translated in every catalog.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Photoshop 25.4 was measured as a black box (synthetic and public-domain
images, results read back exactly) and the content-aware family follows it:
- Fill (inpaint): coarse to fine with the coarser level's matches scaled up
and voted with the finer level's own pixels, the coarsest level filled
from the hole's edge inwards, a shallower pyramid. The fill is pieces of
the image copied pixel for pixel with narrow seams, as Photoshop's;
before, it was visibly blurred. Pixels have roles (hole, context,
source, void), so excluded areas stay context instead of being filled.
- Colour adaptation: Image Melding's per-patch gain and bias, limited per
level, instead of a low-frequency membrane pull.
- Sampling window: Photoshop's square of side 4·√(max(w,50)·max(h,50))
centred on the selection, slid inside the canvas; Edit › Fill,
Rectangular, Auto, Delete and Fill and Spot Healing use it.
- Auto sampling area: what looks like the selection's surroundings (local
colour and contrast, one min cut), fitted to Photoshop's overlay.
- Patch Tool: a Content-Aware mode (Structure, Color, Sample All Layers).
- Content-Aware Move: Structure 3..7 alike, a band about a patch wide;
Color as a gain/bias limited to about 0.04 per step.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 02:25:24 +02:00
650 changed files with 59842 additions and 4774 deletions
if [ "$EVENT" != pull_request ] || [ "$FULL" = true ]; then
echo "os=$all" >> "$GITHUB_OUTPUT"
echo "legs=$all" >> "$GITHUB_OUTPUT"
echo "web=true" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -72,20 +79,25 @@ jobs:
echo "web=false" >> "$GITHUB_OUTPUT"
fi
if git diff --name-only "$BASE" HEAD | grep -Eq '^(apps/|crates/(ui-egui|text|gpu|tablet|heif)/|Cargo\.lock$|(.*/)?build\.rs$|\.github/workflows/ci\.yml$)'; then
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
@@ -19,7 +19,7 @@ PhotoCraft is an open-source, native, Photoshop-comparable image editor written
| `docs/parity-checklist.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` |
| [photocraft-corpus](https://github.com/storytold/photocraft-corpus) + `docs/development.md`› Test corpora | Real-file test oracles (our Photoshop-authored PSDs and GIMP-authored XCFs); 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
@@ -27,7 +27,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
tablet L0 standalone pen tablet input (macOS AppKit, X11 XInput2, Windows barrel button); 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
@@ -52,7 +52,7 @@ xtask/ cargo xtask layers | wasm | ci | stats | corpus | t
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"`. 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.
- **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) and on Windows a pointer message hook (`GetPointerPenInfo`). Only its `src/macos.rs` and `src/windows.rs` allow `unsafe` (`unsafe_code = "deny"` crate-wide, every block has a `SAFETY:` comment, tested against real `NSEvent`s and pointer messages); 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.
@@ -107,7 +107,7 @@ cargo xtask test-corpus # if you touched psd, io, codecs, compose, gpu, text
While iterating, run less and keep the full commands above as the final gate: `cargo test -p <crate> --lib --tests` skips the examples and doctests plain `cargo test` builds every time (ui-egui has 11 examples), and `cargo test -p <crate> --test <file>` runs one integration-test file. `cargo nextest run` runs tests in parallel across binaries; see `docs/development.md`› Faster test loops.
**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.
**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- and GIMP-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).
| `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`,`ellipse-dashed.svg`,`gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
@@ -38,11 +38,12 @@ next to them.
| `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/ellipse-dashed.svg` | Elliptical Marquee tool glyph: a dashed horizontal ellipse, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/frame-placeholder.svg` | Frame tool glyph: an empty placeholder frame, crossed corner to corner | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/gradient.svg` | Gradient tool glyph: a rounded rectangle filled with a left-to-right linear ramp, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush` plus a dashed selection arc around the bristles | Lucide Icons and Contributors; arc by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/quick-selection.svg` | Quick Selection tool glyph: Lucide `brush`, scaled to 70%, inside a dashed upright rounded "C" selection | Lucide Icons and Contributors; selection by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
@@ -57,6 +58,7 @@ next to them.
| `crates/ui-egui/src/theme.rs` (Solarized Dark palette) | Solarized colour scheme used by the Solarized Dark theme | Ethan Schoonover | <https://ethanschoonover.com/solarized/> | MIT, [`LICENSE-solarized.txt`](crates/ui-egui/src/LICENSE-solarized.txt) |
| `crates/ui-egui/src/theme.rs` (Breeze Light and Breeze Dark themes) | The Breeze Light and Breeze Dark themes are original colour values on PhotoCraft's Studio layout, chosen to look close to KDE Plasma's Breeze (cool blue-greys, blue accent, 5 px frame radius) so the app blends into a Plasma desktop. None of Breeze's colour values, files or code are copied; the values are a few levels away from its schemes and tuned for text contrast | PhotoCraft contributors | Inspired by the look of KDE Breeze, <https://invent.kde.org/plasma/breeze> (its colour schemes are LGPL-2.0-or-later) | Original work |
| `crates/ui-egui/src/theme.rs` (Soft Dark and Soft Light themes) | Original colour values and radii for two low-contrast themes on PhotoCraft's Studio layout; no third-party palette, file or code is used | PhotoCraft contributors | Original | Original work |
| `crates/algo/data/libfacedetection.bin` (face detection weights, read by `crates/algo/src/faces.rs`, whose network code is a port of the library's) | libfacedetection CNN (YuNet design) weights and inference code, weights converted to half precision | Shiqi Yu | [ShiqiYu/libfacedetection](https://github.com/ShiqiYu/libfacedetection) (`src/facedetectcnn-data.cpp`, `facedetectcnn.cpp`, `facedetectcnn-model.cpp`) | BSD-3-Clause, [`LICENSE-libfacedetection.txt`](crates/algo/data/LICENSE-libfacedetection.txt) |
### Optional build input: craft-fonts (not files in this repo)
| `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/gimp/` (28 files, 251 KB) | GIMP XCF oracles: 40×30 procedural layers written by GIMP 3.2.6 through the corpus generator `tools/gimp-xcf/xcf_fixtures.py`, each with GIMP's merged rendering as a PNG | PhotoCraft contributors (authored with GIMP 3.2.6 by a script) | [https://github.com/storytold/photocraft-corpus](https://github.com/storytold/photocraft-corpus) (`gimp/`, 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) |
@@ -16,6 +16,7 @@ Inter font (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
Lucide icons (assets/icons/*.svg, except slice-knife.svg) — ISC; icons derived from Feather also MIT. See assets/icons/LICENSE-lucide.txt.
SCOWL English word list (assets/dict/en_US-scowl-50.txt.gz) — SCOWL license. See assets/dict/LICENSE-SCOWL.txt.
libfacedetection face detection weights and network (crates/algo/data/libfacedetection.bin, crates/algo/src/faces/cnn.rs) — BSD-3-Clause, Copyright (c) 2015-2019, Shiqi Yu, all rights reserved. See crates/algo/data/LICENSE-libfacedetection.txt.
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and
<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">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_photocraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/photocraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -199,7 +202,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
</td>
<td width="33%" valign="top">
<h4>🗂️ Formats</h4>
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
PSD and PSB, layered TIFF (Photoshop's layer data in the TIFF, read and written in either byte order), <a href="docs/pdn.md">Paint.NET PDN3 (read, with editable layers and all 14 blend modes)</a>, <a href="docs/ora.md">OpenRaster (read and write, with layers, groups and blend modes, for Krita, MyPaint and GIMP)</a>, <a href="docs/xcf.md">GIMP XCF (read, with layers, groups, masks, blend modes, channels and the selection)</a>, <a href="docs/kra.md">Krita .kra (read, with paint layers, groups, blend modes and transparency masks)</a>, SVG (opens as shape layers, places as a vector Smart Object), plus flat PNG, JPEG, TIFF, WebP (lossy and lossless), GIF, BMP, TGA, ICO, QOI, PNM, OpenEXR, Radiance HDR and FITS (astronomy images: 8/16-bit and 32-bit float, gray or RGB, header kept), and the native <code>.pcraft</code> format. Flat formats retain the depths they support: PNG keeps 8/16-bit samples, TIFF keeps 8/16-bit and 32-bit float samples, and OpenEXR keeps half/float samples; JPEG, WebP and the other 8-bit formats reduce higher-depth samples with an export warning. See the <a href="crates/codecs/README.md#capability-matrix-default-build">codec capability matrix</a> for each format. HEIC photos from iPhone and Mac are read-only (included in official builds; optional <code>--features heif</code> for source builds). AVIF is write-only with the optional <code>avif</code> build feature; AVIF decoding and JPEG XL are unavailable. Affinity documents (<code>.af</code>, <code>.afdesign</code>, <code>.afphoto</code>, <code>.afpub</code>) open natively, read only, with artboards, layers, vectors, text, images and masks; effects, adjustments and other unsupported parts are listed in a warning, and Affinity export is unavailable.
The Flatpak prints through the host's CUPS socket (File › Print talks IPP to it directly, no `lp` needed); without CUPS, or without a printer, File › Print opens the system print dialog through the print portal. See [printing](docs/printing.md).
The AppImage needs no install: the first run registers its launcher icon and menu entry in `~/.local/share` so the dock shows PhotoCraft's icon on Wayland. Set `PHOTOCRAFT_NO_DESKTOP_INTEGRATION=1` to skip that, and see [`docs/releasing.md`](docs/releasing.md) › Linux to undo it.
On a Wayland session, files dropped on the window don't open yet: winit 0.30, the windowing library under egui, has no Wayland drag and drop (#386). Use File › Open, or copy the image in your file manager and paste it with Ctrl+V. To drag and drop, start PhotoCraft under XWayland: `WAYLAND_DISPLAY= photocraft`, `WAYLAND_DISPLAY= ./photocraft-<version>-linux-x86_64.AppImage`, or `flatpak run --nosocket=wayland --socket=x11 ai.storyteller.photocraft`. On Wayland, PhotoCraft shows the command for your install in a notice until you dismiss it. Preferences › Performance › Linux display server = X11 does the same on every launch. With a fractional display scale (125%, 150%, 175%) an XWayland window is drawn at a lower resolution and enlarged by the compositor, so the interface is blurred there; native Wayland stays sharp (#1072).
@@ -300,6 +305,19 @@ Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are
> [!IMPORTANT]
> **Status:** PhotoCraft is in early alpha, and we want to be straight about where it stands: much of Photoshop's feature surface exists in some form, but **it is not yet a Photoshop replacement for daily professional work**. The biggest gaps are AI/generative features, 15 missing tools, depth in typography and pro workflows, and plug-in compatibility. Every Photoshop menu item is wired to a command ([`docs/parity-checklist.md`](docs/parity-checklist.md)), but that measures wiring, not behaviour. The honest, dimension-by-dimension picture and where we're going next are in the [roadmap](ROADMAP.md) and [parity assessment](docs/target-app-parity.md). Expect rough edges, and please file issues (include your OS, document size, layer count and a screenshot). You can also tell us what broke on [Discord](https://discord.gg/artcraft).
## Community packages
These installers are maintained by community members, not by the PhotoCraft team.
On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the Linux release as `media-gfx/photocraft-bin`:
@@ -19,6 +19,7 @@ Every route that writes a document to a flat format (Save As, the CLI, MCP `doc_
- Formats without transparency (JPEG, Radiance HDR) get the image composited over white, as Export As and Quick Export do; in CMYK, white is no ink.
- Formats that can't embed an ICC profile (GIF, BMP, TGA, QOI, ICO, Netpbm/PFM) get RGB documents in another profile (linear light, Display P3, …) converted to sRGB through the colour engine (perceptual intent), since an untagged file is read as sRGB. OpenEXR and Radiance HDR get linear sRGB instead (linear gray for a grayscale OpenEXR), which is how they are tagged when opened.
- TGA, as Photoshop's Targa: an RGB Targa is 24 or 32 bits per pixel. Save As and Save a Copy ask (Targa Options, starting at 32 with an alpha channel, else 24); Export As has a Resolution choice (Auto, 24 or 32 bits/pixel); scripts pass `tgaBits` (`--tga-bits` on the CLI). At 32 bits the document's alpha channel is the file's alpha when it has exactly one (spot channels don't count; with several, none is used), and the colours are written exactly, never premultiplied or cleared where the alpha is 0, so a game texture's mask and its colours both survive. Without a usable alpha channel the layers' transparency is the alpha (opaque without any). 24 bits write no alpha, compositing transparency over white. Without a choice: 32 with an alpha channel or transparency, else 24. A 32-bit Targa opens as an opaque Background with its alpha as "Alpha 1", so it round-trips. Grayscale Targas are 8-bit.
- DDS, for game and 3D textures: Save As and Save a Copy ask (DDS Options) for the compression (None, uncompressed and lossless; BC1/DXT1, whose alpha is on or off; BC2/DXT3; BC3/DXT5) and whether to generate mipmaps (the full chain down to 1×1, each level a box filter of the one above). Scripts pass `ddsCompression` (`none`, `bc1`, `bc2`, `bc3`) and `ddsMipmaps` (`--dds-compression`, `--dds-mipmaps` on the CLI). Opening one reads its top mip level.
Flat TIFF and JPEG exports of composited CMYK documents convert the RGB composite back through
the document's CMYK ICC profile (relative colorimetric with black point compensation) and embed
//! Adobe's CMYK profiles (U.S. Web Coated SWOP, Coated FOGRA39 …) are proprietary, and the
//! freely downloadable characterisation-based profiles come with redistribution conditions, so
//! Photocraft ships a CMYK profile it generates itself from a documented printer model:
//! PhotoCraft ships a CMYK profile it generates itself from a documented printer model:
//!
//! * **Forward model** (CMYK → XYZ): Yule–Nielsen modified Neugebauer (n = 2) over the 16
//! ink overprints, with Demichel coverage weights and a parabolic tone value increase (dot
Loaded 100 of 650 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.