10 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 img.jpg # with the JSON control server
cargo test --workspace # everything
cargo xtask ci # fmt + clippy + tests + layers + wasm
cargo xtask stats # tests and lines per crate
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_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_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 --control 7878. It then accepts JSON lines on 127.0.0.1:7878:
printf '%s\n' '{"id":1,"method":"engine.execute","params":{"command":"layer.newAdjustmentLayer.hueSaturation","params":{"hue":30}}}' \
'{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/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. It drives an in-process engine session. - Live app: start
photocraft --control 7878, then runphotocraft-cli mcp --bridge 127.0.0.1:7878. 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- 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"]
}
}
}
cargo build --release -p photocraft-cli
claude mcp add photocraft -- "$PWD/target/release/photocraft-cli" mcp
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"}
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. - Compositing is still CPU (
photocraft-compose). Live adjustment previews on large documents use a downsampled proxy (ui-egui/src/proxy.rs). The GPU compositor is milestone M5. 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 and DUMP_FX=1 print samples 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.