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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
* 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.
Keep the full workspace and feature build selection while running the craft-fonts pass only for text, io and ui-egui. Ordinary workspace test coverage remains in the first test pass.
Run workspace tests with pinned cargo-nextest, a CI fail-fast profile and a ten-minute per-test hang limit. Keep cheap checks first and doctests in a separate step so failures propagate on Windows.
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.
* Layers panel: blend mode, Opacity, Fill and locks change every selected layer; Photoshop's History names
With several layers selected, the Layers panel's blend mode, Opacity, Fill and lock controls
changed only the active layer, silently (the layers audit's top panel gap). They now change
every selected layer, as in Photoshop, in one undo step. layer.setProps takes
"layers": [id, …] for that (headless too); a list with an unknown id, a bad shape, or a
name for several layers is an error that changes nothing.
History names follow Photoshop for a single kind of change: Opacity Change, Fill Opacity
Change, Blending Change, Rename Layer (a mix stays Layer Properties).
* Layers panel with several selected: Pass Through only for groups (refused for other layers), the Basic lock toggle and the blend-mode hover preview take every selected layer, the controls grey with the Background selected; History icon for opacity/fill/blend changes
* Layers panel: leave the Background-in-a-selection behaviour as it was until Photoshop's is confirmed (#5856)
* Layers panel: a selected Background is left out of several-layer Opacity, Fill, blend and lock changes until Photoshop's behaviour is confirmed (#5856)
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 <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>