* Compose: render in place and in bands; area-reduced and proxy thumbnails render_tiled wrote every tile into a Vec and then copied them into the output: two full-size float copies (6.3 GB for a 14000² document). Tiles are now written straight into the output band by band. New streaming APIs so large documents never need a full-size float composite: render_bands (sink per band of ~8 MP), render_reduced (banded area average, bit-identical to the old flatten-then-average thumbnail) and flatten_to_surface (Flatten Image, Merge Visible, Copy Merged and the other flattened-surface users). thumbnail() reduces a downsampled proxy (moved from ui-egui to compose::proxy, now also scaling spot channels) for documents over 16 MP when that is faithful (no layer effects or vector masks), so navigator, channel and histogram thumbnails of a 196 MP document cost milliseconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * GPU canvas: request the adapter's real limits; banded CPU fallback; navigator thumbnail egui-wgpu asks for an 8192 px texture limit, so the wgpu compositor rejected every document wider or taller than 8192 px and each refresh fell back to a full CPU flatten. The desktop and web apps now request the adapter's own max_texture_dimension_2d and max_buffer_size (gpu_canvas::use_adapter_limits); the benches use the same limits. The CPU fallback (documents beyond the GPU limit, or features it lacks) is GpuCanvas::refresh: it renders and uploads the composite band by band instead of a full float composite plus a full RGBA8 copy. The CPU canvas texture is reduced in bands too, and the Navigator on the GPU canvas uses a cached thumbnail instead of a full-resolution CPU composite per revision. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * IO: banded export and import; no full-size copies in encoders; larger decode limits - Flat export renders the composite in bands and quantises each band; alpha is dropped in place when every pixel is opaque. The single-layer native path copies surface rows a band at a time (no f32 read_region and no per-pixel Vec). Buffers come from try_reserve: Err, not an abort. - PSD export encodes the merged image band by band into its planes (no flatten, no second plane copy); near-opaque documents are re-encoded without the white matte so the output is unchanged. Layer interleaved bytes are freed before their planes are compressed. - Flat import converts in ~32 MB bands (and not at all when already RGBA); the PSD merged-image un-matte works per tile row. - Encoders borrow the image when no conversion is needed (Image::converted) instead of cloning it; PNG only copies 16-bit data for the byte swap. - Decode limits: 2^30 pixels and 8 GiB on 64-bit targets (a 20000² JPEG was refused at 2^28). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Filters: write tile results in memory-bounded groups apply_tiled collected every tile's float result before writing any (3.1 GB for a 14000² layer). Results are now produced and written in groups of about 256 MB (at least one tile per core). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * History: bound pixel memory by Preferences > Performance > Memory Usage History::max_bytes caps the current document's tiles plus the tiles only history holds (layers, masks, alpha channels); History::trim drops the oldest states beyond it after every recorded step and when preferences change, always keeping the last step undoable. The engine sets it from memory_usage_mb, which was unused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Large-image benchmark (14k / 20k): open, refresh, thumbnails, filter, stroke, saves One operation per run on a cached synthetic photo, with the GPU device created the way the app creates it, so /usr/bin/time -l reports that operation's peak memory. Documented in docs/development.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
18 KiB
Development guide
Prerequisites
- Rust stable (1.90+). Add the web target with
rustup target add wasm32-unknown-unknown. - macOS, Windows or Linux. Linux needs
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev.
Build and run
cargo run --release -p photocraft -- path/to/image.psd # desktop app
cargo run --release -p photocraft -- --control 7878 --control-token-file .private/control.token \
--automation-read-root work --automation-write-root work
cargo test --workspace # everything
cargo xtask ci # fmt + clippy + tests + layers + wasm
cargo xtask stats # tests and lines per crate
cargo xtask parity # Photoshop menu coverage -> docs/parity.md
Image code is slow at opt-level 0, so the workspace profile builds dependencies at opt-level 2. Use --release for anything interactive.
Environment variables
| Variable | Effect |
|---|---|
PHOTOCRAFT_CONTROL_PORT |
Same as --control <port> |
PHOTOCRAFT_CONTROL_TOKEN |
64-hex bearer token for control TCP (avoid on shared systems where environment inspection is possible) |
PHOTOCRAFT_CONTROL_TOKEN_FILE |
Read, or create for a server, the control bearer-token file |
PHOTOCRAFT_AUTOMATION_READ_ROOT |
Directory capability for automation reads; requests use relative paths |
PHOTOCRAFT_AUTOMATION_WRITE_ROOT |
Separate directory capability for automation writes; requests use relative paths |
PHOTOCRAFT_CPU_CANVAS=1 |
Force the CPU canvas path instead of the wgpu shader canvas |
PHOTOCRAFT_GPU_TILE=2048 |
Force GPU canvas tiling (tests tile seams) |
PHOTOCRAFT_FX_NOCACHE=1 |
Bypass the CPU layer-effect map cache (compose::effect_maps) |
PHOTOCRAFT_CPU_COMPOSE=1 |
Keep the wgpu canvas but composite on the CPU (compare GPU vs CPU renders, e.g. with the snapshot example) |
PHOTOCRAFT_FX_TRACE=1 |
Print the CPU time spent on GPU effect shapes and distance fields per rebuild |
PHOTOCRAFT_THEME_FILE=tokens.json |
Debug builds only: live design-token overrides, re-read on change |
Live design tokens
{ "card": "#323232", "tab_strip": "#262626", "accent": "#378ef0", "radius": 4 }
Keys are the field names of theme::Tokens (crates/ui-egui/src/theme.rs). Edit and save the file while the app runs to see changes immediately, with no recompile. This is compiled out of release builds.
Driving the app programmatically
Start the app with a private token file. It then accepts authenticated JSON lines on 127.0.0.1:7878:
TOKEN=$(tr -d '\r\n' < .private/control.token)
printf '%s\n' "{\"id\":\"auth\",\"method\":\"auth\",\"params\":{\"token\":\"$TOKEN\"}}" \
'{"id":1,"method":"engine.execute","params":{"command":"layer.newAdjustmentLayer.hueSaturation","params":{"hue":30}}}' \
'{"id":2,"method":"ui.screenshot","params":{"path":"evidence/shot.png"}}' | nc 127.0.0.1 7878
See docs/control-protocol.md for every method. Tips:
- macOS does not render occluded windows.
ui.screenshotraises the window first (focus: trueby default). Useui.focusbefore other visual checks. - Capture only our own window. For native captures (to see the real title bar and traffic lights), capture by window id (
screencapture -l <CGWindowID>), never a screen region, which can grab other apps. - Pointer gestures:
ui.pointertakes events in document coordinates and runs them through the same tool state machine as the mouse.
Headless CLI
apps/photocraft-cli builds the binary photocraft-cli:
cargo run -p photocraft-cli -- convert in.psd out.pcraft # any supported format -> any
cargo run -p photocraft-cli -- info out.pcraft # JSON: size, mode, depth, layer tree
cargo run -p photocraft-cli -- run in.png --cmd layer.new.layer --params '{"name":"Ink"}' \
--cmd filter.blur.gaussian --params '{"radius":3}' --out out.psd
cargo run -p photocraft-cli -- run --new '{"width":800,"height":600}' --cmd document.inspect
cargo run -p photocraft-cli -- batch --actions actions.json --in photos/ --out done/ --format jpg
cargo run -p photocraft-cli -- commands --filter blur # the command registry
actions.json is [{"command": "<id>", "params": {…}}, …], which is the same shape as a recorded action. run prints one JSON line per command result.
Native format (.pcraft)
crates/format defines the lossless native bundle. It is either a ZIP of STORE entries or a directory with the same layout:
manifest.json: the versioned document tree, with migrations informat/src/migrate.rs.tiles/<blake3>.zstandblobs/<blake3>.zst: zstd-compressed objects, content-addressed by the BLAKE3 hash of their uncompressed bytes.thumb.pngandcomposite/preview.png: previews.
Keep one PcraftWriter per open document: re-saving then only compresses and writes tiles that changed, and directory bundles garbage-collect unreferenced objects. format::Autosaver writes snapshots into a recovery directory on a background thread. list_recovery / recover / discard_recovery implement crash recovery.
photocraft-io routes .pcraft through this crate in import/export, detecting it by magic or by extension.
MCP (agents)
photocraft-cli mcp serves MCP on stdio using crates/automation, which is built on rmcp:
- Headless:
photocraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>. It drives an in-process engine session and has no file authority when a root is omitted. - Live app: start
photocraft --control 7878 --control-token-file <private-path> --automation-read-root <dir> --automation-write-root <dir>, then runphotocraft-cli mcp --bridge 127.0.0.1:7878 --control-token-file <private-path>. The desktop process owns the roots. Seedocs/control-protocol.md#mcp-bridge.
Tools:
session_listdoc_open,doc_new,doc_save,doc_export,doc_inspect,doc_render_preview(returns a PNG image),doc_select,doc_closecommand_list,command_run,command_batch(several commands per call)- bridge only:
ui_inspect,ui_screenshot,ui_pointer,ui_menu_invoke,ui_set,control_call
Claude Code (.mcp.json in the repo root, or claude mcp add):
{
"mcpServers": {
"photocraft": {
"command": "/path/to/photocraft/target/release/photocraft-cli",
"args": ["mcp"]
},
"photocraft-live": {
"command": "/path/to/photocraft/target/release/photocraft-cli",
"args": ["mcp", "--bridge", "127.0.0.1:7878", "--control-token-file", "/private/path/photocraft-control.token"]
}
}
}
cargo build --release -p photocraft-cli
claude mcp add photocraft -- "$PWD/target/release/photocraft-cli" mcp
doc_inspect (and the engine command document.inspect) reports the layer tree with kinds,
bounds, masks, selection, effects (effects.items[].kind), smart filters (smartFilters[]), type
text, adjustment settings, channels and history, so agents can verify what they did without a
screenshot. crates/automation/tests/agent_tasks.rs is the reference: ten realistic edit tasks
(title card, colour grade, undo/redo, editable smart blur, masks, saved selections, align,
capability-scoped export, resize/crop, CMYK + native save) driven purely over MCP.
Without MCP, photocraft-cli serve [--port N] keeps a headless session open and answers JSON lines
(see docs/control-protocol.md#headless-server).
A typical agent loop:
doc_open {path}command_list {filter:"blur"}command_run {id:"filter.blur.gaussian", params:{radius:4}}doc_render_previewto check the resultdoc_save {path:"out.pcraft"}
Colour management
crates/cms is our own pure-Rust ICC engine (v2/v4 parsing, matrix/TRC and LUT profiles, all four
intents, black point compensation). It ships CC0 built-in profiles, including a synthetic
"Photocraft Coated CMYK", because Adobe's CMYK profiles are proprietary (see crates/cms/README.md).
- Documents carry an optional embedded ICC profile (
Document::icc_profile);edit.assignProfileandedit.convertToProfilechange it. Mode changes (image.mode.*) convert through cms. - Proof Colors (⌘Y), Proof Setup and Gamut Warning (⇧⌘Y) bake a 3D LUT
(
cms::Lut3d) that the canvas shader applies; the document pixels never change. - Convert colours with
photocraft_cms::transform::cached(src, dst, opts): transforms are cached process-wide and integer buffers use precomputed tables or a device link.
Menu parity
cargo xtask parity compares Photoshop's menu tree (crates/ui-egui/src/menu_catalog.rs) with
the live command registry (menus::is_live) and rewrites docs/parity.md. The test
parity::tests::parity_does_not_regress fails if the live count drops below parity::FLOOR.
Testing strategy
- Unit and property tests in every crate. proptest is used for tile COW, regions and codecs.
- Format crates:
- synthetic generators (
psd::testgen) - byte-exact round trips
- malformed-input sweeps (truncate at every offset)
- fuzz targets (
crates/*/fuzz)
- synthetic generators (
- Real-file corpus in
corpus/(gitignored).corpus/psdholds MIT-licensed test PSDs, listed with their sources incorpus/psd/SOURCES.md, andcargo xtask corpus --downloadfetches PngSuite. Tests skip silently when a corpus is absent. - Composite oracle: a PSD's embedded merged image is compared with our compositor's output. The pass rate is tracked in the roadmap.
- UI: unit tests for widgets and state, plus screenshot checks through the control channel.
Performance notes
- The canvas is presented by a custom WGSL shader (
ui-egui/src/gpu_canvas.rs): mip-mapped/nearest sampling, procedural checkerboard, pixel grid, tiling. Brush strokes upload only their damage rect. - The canvas composites on the GPU (
photocraft-gpu, driven fromgpu_canvas.rs), layer effects included. What the planner can't express returnsUnsupportedand the canvas falls back to the CPU compositor (photocraft-compose, also the reference for export and tests): Multichannel documents, and documents or effect regions over the texture limit. Pieces the GPU can't derive itself are rasterised once on the CPU and cached (compose::masksfor vector masks,compose::shape_splitfor stroked shapes with clipped layers, effect distance fields). Timings of the interactive paths:cargo run --release -p photocraft-ui-egui --example interactive_bench; effects:--example fx_bench(--compare files…for GPU vs CPU); large documents (open, refresh, thumbnails, a filter, a stroke, saves; one operation per run so/usr/bin/time -lgives its peak memory):--example large_image_bench -- --size 14000x14000 --op psd. The app requests the adapter's own texture limit (egui's default is 8192 px; seegpu_canvas::use_adapter_limits); beyond it the CPU fallback composites and uploads in bands (compose::render_bands), and exports, thumbnails and flattening stream bands too, so no full-size float composite is ever held. Rendering fidelity:cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd(the whole PSD oracle table in seconds).ui.inspect→perf.timings.gpuFallbacknames the reason (nullon the GPU path). - Layer effects on the GPU (
gpu/src/fx.rs, kernels ingpu/src/compose.wgsl). Every enabled effect becomes a map program over the layer's effect region (shift, dilate, Gaussian blur, glow ramp, bevel height and shading, contour, stroke band), mirroringcompose::effectsstep by step; the chunked composite then paints through the maps, clipped to that region, and copies the result back into the backdrop in place, so a small text layer costs only its own pixels. The layer's shape (compose::layer_shape) and its distance fields (compose::effects::distance_field: a sequential transform whose tie-breaking a parallel GPU pass can't reproduce bit for bit) come from compose on the CPU, computed in parallel bands. Everything is cached per layer state: an unrelated edit, or an effect's colour or opacity, rebuilds nothing; a brush dab recomputes the touched 256² tiles grown by the effect reach; changing one effect's geometry rebuilds that effect only. Cache budgetgpu::FX_BUDGET(1.5 GB, least recently drawn layers evicted first).PHOTOCRAFT_FX_TRACE=1prints the CPU time of each rebuild. - The canvas grows a stroke's damage rect by the effect reach of the layers around it (
canvas::effect_reach), so effects beyond the dab refresh too (on both paths). - Numbers (7360 × 4912, 8 text layers + one painted layer with drop shadow + stroke + bevel, Hue/Saturation on top; M4 Pro;
cargo run --release -p photocraft-ui-egui --example fx_bench), CPU fallback → GPU: full refresh with warm effect maps 5.7 s → 47 ms; Hue/Saturation tweak above the effects 4.7 s → 31 ms; brush dab on a plain layer 32 → 1.7 ms; brush dab on the effect layer (its maps rebuilt around the dab) 659 → 3.7 ms; first refresh (all maps built) 6.4 s → 0.39 s. With the CPU ~14× oversubscribed by parallel builds (min of 7 runs): 8.1 s → 0.33 s, 9.9 s → 0.28 s, 28 → 2.2 ms, 1.7 s → 89 ms; moving a text layer 7 px 336 → 13 ms.fx_bench --compare corpus/psd/…/*.psdreports the GPU vs CPU difference on real files (30 of the 31 corpus files with effects render on the GPU, worst 0.12/255). - On the CPU path
compose::effect_mapscaches shadow, glow, bevel and satin maps per layer state (LRU, 768 MB budget). - Live adjustment previews on large documents use a downsampled proxy (
ui-egui/src/proxy.rs). ui.inspectreturnsperftimings (UI ms per frame, composite ms, upload ms).- Never scan full surfaces per frame. Cache per document revision (
PhotocraftApp::cached_bounds). An uncachedcontent_bounds()on a 36 MP layer once cost 77 ms per frame.
Web build
apps/photocraft-web runs the same PhotocraftApp in the browser through eframe's web runner. The renderer is wgpu: WebGPU where the browser has it, WebGL2 otherwise. It is Rust only. The only JavaScript is the glue that wasm-bindgen generates.
brew install trunk # or: cargo install trunk --locked
cd apps/photocraft-web
trunk build --release # writes ../../dist/web (index.html, .js glue, .wasm)
trunk serve --release # dev server on http://127.0.0.1:8765
Any static file server works for dist/web, for example python3 -m http.server 8765 run inside that directory. Trunk downloads the matching wasm-bindgen and wasm-opt itself. The release .wasm is about 12.7 MB, or 5.0 MB gzipped. Serve it with compression.
URL flags: ?webgl forces the WebGL2 backend, and ?cpu forces the CPU canvas path.
How the web shell (apps/photocraft-web/src/web.rs) differs from desktop:
- Open uses
rfd::AsyncFileDialog. The bytes arrive asynchronously inServices::inbox, which the app drains every frame. - Save / Save As / Export trigger a browser download of the encoded bytes. The shell does this with a Blob, an object URL and a temporary
<a download>, all created from Rust. There is no save dialog, so the suggested name becomes the download name. - Drag-and-drop:
WebShelltakes the frame'sdropped_filesbefore the app sees them. It reads each file withDroppedFile::bytes_asyncand pushes the bytes into the inbox. - No control server: browsers can't listen on TCP. To automate the web build, drive headless Chrome with
--remote-debugging-port.Page.setInterceptFileChooserDialogplusDOM.setFileInputFilescovers Open,Input.dispatchDragEventwithfilescovers drops, andBrowser.setDownloadBehaviorcaptures downloads. - Headless Chrome on macOS (
--headless=new --enable-unsafe-webgpu) gets a real WebGPU adapter.
Offscreen UI snapshots (no window)
Render the full UI headlessly with the real wgpu canvas, e.g. for design reviews or when the app
window would be occluded (macOS doesn't render occluded windows, so live ui.screenshot must raise
the window and steal focus):
cargo run --release -p photocraft-ui-egui --example snapshot -- \
--out ui.png --size 1440x900 --scale 2 --open photo.jpg \
--script '[["ui.set", {"tool": "type"}], ["ui.menu.invoke", {"id": "image.canvasSize"}]]'
--script is a list of control-protocol calls ([method, params]), applied in order.
Input calls (ui.key, ui.type, ui.click) now reply only after the app has processed the events,
so a following ui.inspect observes their effect.
Rendering fidelity (PSD oracle)
cargo test --release -p photocraft-io --test corpus -- --nocapture compares our composite of every
corpus PSD with Photoshop's own merged image (PASS ≤ 2/255). To dig into one file:
cargo run --release -p photocraft-io --example oracle_diff -- corpus/psd/<file>.psd 0 png /tmp/diff.png
writes ours | Photoshop | a diff heatmap side by side; col, row, worst [n], grid x0 y0 x1 y1 [ch],
layerpx x y and DUMP_FX=1 print samples, the worst pixels, value grids, per-layer pixels and raw
effect descriptors. Findings so far: fill-layer gradients are framed by the layer's mask bounds;
Photoshop's gradient Smoothness is a Catmull-Rom blend and "Perceptual" interpolation is Oklab
(baked into dense stops on import, crates/io/src/gradient_bake.rs); a shape layer's vector
stroke is drawn above its clipped layers; linked effect patterns tile from the layer's fxrp
reference point; stroke distances follow a 5 × 5 chamfer metric (1, √2, √5) seeded at sub-pixel
edge offsets; interior effects keep the layer's alpha; outside strokes blend onto the backdrop with
their own modes, an upper stroke covering lower ones.