Testers report ~50 ms input-to-pixel while painting on Linux and macOS, and asked for a
per-stage breakdown. With PHOTOCRAFT_STROKE_TRACE=1 the app prints, per stroke, p50/p95 of
the frame interval, pointer moves per frame, the live stroke's render, the canvas refresh
(composite + upload), the UI build, eframe's CPU time (UI, tessellation, submission) and the
remaining vsync/swap-chain wait, with the GPU backend; =<file.csv> also appends every frame.
The last summary is in the control channel's perf.strokeTrace.
RTX 5090, Vulkan, 120 Hz: interval 8.33, cpu 2.2 (ui 0.9, stroke 0.8, canvas 0.4), wait 6.0 ms.
On macOS the brush outline fell back to the app-drawn outline past 255 points, and that
outline trails the pointer by 26 ms on a 120 Hz Mac (syncroot's measurement in #1343): a
1000 px brush at fit, or 300 px at 200 %. AppKit sets no size limit on an NSCursor image, so
macOS now takes the same 2048 cap as Windows. Linux keeps 255 for X servers and hardware
cursor planes.
A circle's bitmap evaluated every pixel of its square, though only a thin ring is painted:
a 1000 px radius took 41.6 ms per bitmap, a hitch on each zoom step or [ / ] resize (on
Windows too). Only the ring's two arcs per row, and the centre mark, are evaluated now, on
a transparent buffer: 6.2 ms (radius 500: 10.6 -> 1.7 ms), identical pixels. App-drawn
tip outlines likewise only write the pixels their segments splat.
* Native brush outline and crosshair cursors on macOS, X11 and Wayland (#1343)
The brush outline and crosshairs were OS cursor bitmaps only on Windows; on
macOS and Linux the app painted them, so they trailed the pointer by the
render, present and compositor latency and jumped to catch up on slow frames.
tool_cursor now hands the OS the bitmap on every desktop root viewport.
macOS takes NSCursor images in logical points, so the bitmap scales with the
UI zoom only there; Windows, X11 and Wayland take physical pixels. Off Windows
bitmaps stay within 255 px (X servers and hardware cursor planes), and a
larger tip falls back to the painted outline plus a small OS crosshair.
The Pencil's grid-snapped square and the pipette stay painted, and the Hand
bitmap stays Windows-only (the other platforms draw their own hands).
Co-authored-by: jerrykimo <[email protected]>
* Cursor test: the Hand is a bitmap only on Windows; elsewhere the OS draws Grab
---------
Co-authored-by: jerrykimo <[email protected]>
Brush phase 6c. A stroke composites in float, then each pixel is rounded to 8 bits, so a soft,
low-flow stroke's gentle gradient collapses into flat bands (the audit counted 35 levels against
119 at 16-bit). The pixels the stroke changes now get the same deterministic per-pixel offset of
under half a level on every channel before quantisation:
- fractional values round up or down in proportion, so bands become fine grain;
- a value already on an 8-bit level rounds back to itself, so exact colours never change, and
pixels the stroke doesn't touch are left alone;
- the offset depends only on the pixel's position, so the live preview, the commit and a replay
agree.
16-bit and float targets are unchanged.
* Releasing a brush stroke no longer freezes: the commit takes the pixels the live stroke already drew (P32: 136 ms → 0.8 ms)
Brush phase 4. On release the canvas dropped its live stroke and ran `paint.stroke` over every
point again (the whole stroke re-rendered), then re-composited the stroke's bounding box, although
the canvas already showed exactly those pixels. Long strokes froze for 100–250 ms at mouse-up
(#2068).
- `Session::offer_live_stroke` hands the finished live stroke to the next command. A
`paint.stroke` / `paint.pencil` whose command, document revision, params, seed and points match
it exactly takes its pixels instead of rendering (result `"fromLive": true`). Anything else
ignores the offer and renders as before. It still runs through the normal dispatch
(authorization, journal, one history step), and a replay renders identical pixels.
- The canvas feeds the release frame's last points to the live stroke first, then remembers the
stroke's per-step damage for one revision (`CommittedStroke`): each canvas cache re-composites
only the steps it hadn't shown, usually nothing, instead of the bounding box.
- P32 (brush release on a long stroke) becomes measurable: perf_scenarios times a 600-point
200 px soft stroke on 24 MP. 136 ms → 0.83 ms on the GPU canvas, 257 ms → 0.71 ms on the CPU
canvas (RTX 5090, release). Budget 16 ms, not enforced until a baseline run.
- Scorecard: TOOL-213-10 (⌃⌥-drag resize with readout) is done; TOOL-213-7 (live strokes for every
brush-based retouch tool) is partial (Spot Healing, Mixer Brush, Background Eraser and Pattern
Stamp still aren't live).
* Mask view test: a commit that takes the live stroke uploads nothing it already showed
* Pen pressure curve: Preferences › Tools shapes how pen pressure reaches every brush
Brush phase 6a (TOOL-213-11, #610). Every pen paints its pressure straight through, so artists
with a heavy or a light hand, or a tablet whose response feels off, had no way to adjust it.
- `tools.pressureCurve`: [input, output] points, linear by default, settable with `prefs.set`
(validated: 2..=16 pairs within 0..1).
- `prefs::PressureCurve` reads it as a monotone cubic (Fritsch–Carlson), so a firmer press never
gives less pressure. It sanitises the points itself (finite, clamped, sorted, one per input,
capped, outputs made non-decreasing; flat beyond the ends), because a whole-section update or a
hand-edited file can store anything.
- The stylus maps every pen sample's pressure through it before any brush sees it, so the live
stroke, the commit and the journal agree. A mouse stays at full pressure.
- Preferences › Tools draws a curve editor under Use Tablet Pressure: drag a point, click to add
one, right-click to remove one, with Linear, Soft and Hard presets. Translated in all 16 catalogs.
* Regenerate docs/scorecard.md
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---------
Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Brush phase 1 (owner-approved option A of the brush audit):
- Soft tips fall off as (1 - t²)⁴ from the hard core to the edge, a Gaussian-like curve that
tracks exp(-4.6 t²) but is exact on every platform and reaches 0 at the edge with a flat tangent.
The old smoothstep kept so much of the tip near full strength that overlapping dabs (Flow builds
up, as in Photoshop) turned a soft stroke's edge to half the tip's width. It now keeps about two
thirds of it, at the default 10 % spacing, with no ripple along the stroke.
- Computed tips under 3 px radius are rasterised by covered area (a 4×4 grid per pixel, each
sample anti-aliased over its quarter pixel) instead of at pixel centres: a 1 px dab's total
coverage no longer swings 1.00–1.17 with its sub-pixel position, so thin lines don't bead.
- The default spacing stays 10 %. Photoshop's documented 25 % beads soft strokes with either
curve; it waits for Photoshop-authored reference strokes.
Brush phase 3b. The stylus feed was one latest-value slot, so every pointer move in a frame
painted with the frame's last pressure, tilt and rotation: fast pen strokes got stair-stepped line
weights (#2046, #2036, #1776, #610).
- `StylusFeed` keeps the latest sample (for proximity, the eraser end and tilt) and also queues
every sample in order (bounded to 1024). `set()` is unchanged, so the macOS, X11 and web
sources, and a WinTab source later, need no changes.
- Each frame takes its pen samples in order: the feed's queue, then the touch forces (Windows).
Pointer move k of n takes the pressure, tilt and rotation interpolated at its share of the
frame, from the previous frame's last sample. Rotation takes the short way round. A mouse
still paints at 1.
- A new stroke starts from this frame's samples, never from a sample an earlier stroke left behind.
- The stylus updates once per egui frame: the canvas runs once per view, and egui can run a
frame in several passes, so a second update drained an empty queue.
Brush phase 3a (owner-approved brush overhaul):
- Every stroke point now carries a time (`timeMs`). egui's pointer events have none, so a frame's
samples are spread evenly between the previous point and the frame time. Speed spacing, airbrush
Build-up and Stroke Catch-Up worked from scripts but never from the canvas, because every point
had time 0. The live stroke and the commit get the same times.
- A stroke started on the press takes its moves from the press on. The moves before egui
recognised a drag (its 6 pt click distance) were dropped, which gave every stroke a straight
start and lost the pen's pressure ramp-in.
- Smoothing pulls by stroke time (16 ms steps, fractional below that), not once per input event,
so a 1000 Hz mouse and a 60 Hz pen feel the same. Without Stroke Catch-Up a paused pointer stops
the brush, as in Photoshop.
- A pointer held still keeps the stroke's clock running only while the stroke changes with time
(airbrush Build-up, or smoothing still catching up): it repeats the held point every 16 ms and
asks for repaints, then goes idle.
The deep EXR decoder (#642) was verified against the OpenEXR project's deep test images, but those
lived in a hand-copied `corpus/exr/` and the test skipped when they were absent, so CI never read
them. `cargo xtask corpus --exr` (and `--all`) now fetches the five `*.deep.exr` files of
openexr v3.5.2 `src/test/bin/test_images`, sha256-verified against `xtask/exr-corpus.sha256`.
`real_deep_files_decode` is behind the `corpus` feature and fails when the corpus is missing. It
checks each file's sample total, largest per-pixel count, cumulative counts and channel lengths,
and that the decoded u32 ID channels hold exactly the IDs listed by the upstream `.txt` sidecars
(an independent oracle), plus the flattened decode. FILE-216-3 moves to partial (EXR read in CI;
TIFF and RAW still are not).
- Color Lookup: a stored document's `size` is untrusted. `size³ × 3` overflowed for sizes of
2²² and up: debug builds panicked, release builds wrapped past the table-length guard and
indexed out of bounds. One checked `photocraft_doc::adjust::lut3d_len` now backs the CPU
compositor, the GPU planner and the PSD writer.
- Quick Selection: a stroke point far off the canvas (`select.quick {"points":[[3e9,0]]}`)
saturated to i32::MAX and then overflowed on `+ 1`.
- Link Layers: `max + 1` over stored link group ids overflowed at u64::MAX (debug panic, release
wrap that could merge unrelated layers). A fresh id is now the smallest unused one when the
largest is taken; a selection's first group is read with `first()`.
- Vanishing Point clone footprint: the same `as i32 + 1` saturates (hardening; no reproducer).
`ui.dialog.set` can store any JSON in the dialog's `__variables` field, and the Define and Data
Sets pages write fields into its entries (`d["name"] = …`, `set["name"] = …`), which panics when an
entry is a number or a string. The dialog now normalizes the state each frame, keeping only object
entries in `defs`, `dataSets` and each set's `values`.
Untrusted sessions (MCP, the control channel) checked only the top-level command and the steps of
`actions.play`. Any other command that runs commands internally ran them unchecked, so the allowed
`file.automate.conditionalModeChange` could run `image.mode.*`, which the desktop gate denies
because it resolves the user's configured colour profiles.
`Session::dispatch` now applies `Session::authorize` to every command before any side effect,
nested ones included, which closes the class rather than this one command. Sessions without a
gate (local UI, `photocraft-cli run`) are unchanged.
#800 narrowed the craft-fonts test run to -p photocraft-text -p photocraft-ui-egui. A
different package and feature selection resolves a different feature set (no
--features heif), so cargo recompiled the whole dependency graph into the same
target dir on top of the Test step's build, and test (ubuntu-latest) ran out of
disk (No space left on device, linker bus error) on main and every PR.
Back to cargo test --workspace --locked --features heif, which reuses the Test
step's artifacts and rebuilds only the crates that read CRAFT_FONTS_DIR. The comment
says why the selection must match.
Co-authored-by: Claude Opus 5.5 <[email protected]>
load_actions treats a missing actions.json as an empty list via missing_file(), which
matches the error text ("not found", "no such file", "os error 2"). When the store
directory itself doesn't exist yet, as on a first launch, Windows reports os error 3
("The system cannot find the path specified."), which none of those match. So opening
a fresh store warned "actions.json skipped", and two engine tests failed on Windows
(actions_persist_with_the_preset_store, group_with_sampled_8_and_16_bit_tips_round_trips).
DirBackend::read now maps io::ErrorKind::NotFound (os errors 2 and 3) to the same
"{name}: not found" wording the in-memory backend uses. New test: a store directory
that doesn't exist yet opens with no warnings (fails on Windows before the fix).
Co-authored-by: Claude Opus 5.5 <[email protected]>
Three agent_tasks tests failed on Windows at remove_dir_all with os error 32. The
headless session's AuthorizedWorkspace opens its roots as cap_std::fs::Dir handles,
and the server task that owns it was still alive when the test deleted the folder:
c.cancel() closes the client, but the spawned server drops the session
asynchronously. Linux deletes directories with open handles, Windows doesn't.
connect/connect_headless now return the server task, and shutdown() cancels the
client and awaits the server, so the handles are gone before the folder is removed.
Test-only change.
Co-authored-by: Claude Opus 5.5 <[email protected]>
#825 made ExportOptions::default() write a flat TIFF (headless saves stay flat
unless they ask for layers). corpus_round_trips_through_layered_tiff exported with
the defaults, so every corpus file came back flat and the round trip failed (670
failures). Test-only change: request tiff_layers: true.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Main's CI was effectively unverified: with cancel-in-progress on every ref, each merge
cancelled the run of the one before. Between 09:56 and 20:34 UTC on 2026-10-07, 80 of 83
push runs on main were cancelled, and a formatting break from two PRs that were each green
alone (#663 + #700) went unnoticed until PRs updated to main started failing.
- ci.yml: cancel-in-progress only for pull_request. Main runs finish; a merge that lands
meanwhile waits and runs next.
- ci.yml: a `platforms` job picks the test matrix. main, manual runs and PRs labelled
`full-ci` get Linux, macOS and Windows. Other PRs get Linux only, unless they touch
code whose behaviour differs by platform (apps/, ui-egui, text, gpu, tablet, heif,
build.rs, Cargo.lock, ci.yml).
- ci.yml: the second Linux test run with craft-fonts covers only the crates that use the
fonts (photocraft-text, photocraft-ui-egui), not the whole workspace again.
- freebsd.yml, windows-arm64.yml: nightly on main instead of on every push and every
Cargo.toml change; PRs touching their own packaging still run them.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Maintainer decision on #672: the app's Save As keeps TIFF layers as Photoshop does (it sets
ExportOptions.tiff_layers from its Layers option), but scripted and agent saves stay flat by
default so conversions keep predictable sizes. #672 merged with the default on, so every
headless TIFF save became layered.
- photocraft-io: ExportOptions::default() has tiff_layers: false. The desktop and web Save As
already set it explicitly from their settings, so the app is unchanged.
- photocraft-cli: --tiff-layers on convert, run and batch (listed in the usage text).
- engine: file.saveACopy, Batch, Image Processor, the export-to-files commands and the lens
batch take "tiffLayers": bool=false, through one SaveOpts { quality, tiff_layers }.
- automation: serve doc.save and MCP doc_save take tiffLayers (default false).
- Tests: default ExportOptions write a flat TIFF; the CLI writes flat without the flag and
layered with it (run and convert); #672's layered-TIFF tests ask for layers explicitly.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Formatting only: #672 and #733 both changed export_dialog.rs and were each
formatted on their own branches; merged together, cargo fmt --check fails.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Follow-up to #752. After a directory save verifies an object, later saves trust it while its
file signature is unchanged. A file damaged in place with the same length and modification
time (bit rot, or a tool that restores timestamps) was trusted until the writer was recreated.
- The signature also carries inode, device and change time on Unix, which an in-place rewrite
changes even when the modification time is restored.
- Each directory save re-reads and re-checks cached objects, oldest-checked first, up to
DEFAULT_REVERIFY_BUDGET (16 MiB of compressed data; at least one object), so over enough
saves every object is read again and a damaged one is rewritten. Repeat saves of small
documents stay a full check; large ones pay a bounded cost per save.
PcraftWriter::set_reverify_budget tunes it (0 = trust signatures, u64::MAX = check all).
- PcraftWriter's doc comment states what the cache trusts.
- Tests: a same-size, same-mtime tamper is repaired on the next save (fails with the budget
at 0 on Windows, where there is no change time), and with a 1-byte budget the rolling
re-check reaches the damaged object within one pass over the objects.
Co-authored-by: Claude Opus 5.5 <[email protected]>
korean_covers_generated_filter_options_and_gallery_names (from #604) requires a
Korean label for every generated filter option. Relight (#735) added the options
ambient and warmth; its CI ran on a base without #604's test, so main now fails
it. Adds the two catalog entries.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Formatting only: cargo fmt --check (rustfmt 1.99, as CI) fails on main at
crates/codecs/tests/deep_exr.rs:360 since #642 landed.
Co-authored-by: Claude Opus 5.5 <[email protected]>
#663 and #700 each added one entry to the complete-catalog list in
source_key_set_is_covered_by_complete_catalogs. Each line passed rustfmt on
its own branch; together they exceed the width limit, so cargo fmt --check
fails on main and on every PR updated to main. Formatting only.
Co-authored-by: Claude Opus 5.5 <[email protected]>