mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 22:30:20 -06:00
228 lines
13 KiB
Markdown
228 lines
13 KiB
Markdown
# 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
|
|
|
|
```sh
|
|
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
|
|
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_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 layer-effect map cache (`compose::effect_maps`) |
|
|
| `PHOTOCRAFT_THEME_FILE=tokens.json` | **Debug builds only:** live design-token overrides, re-read on change |
|
|
|
|
### Live design tokens
|
|
|
|
```json
|
|
{ "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`:
|
|
|
|
```sh
|
|
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.screenshot` raises the window first (`focus: true` by default). Use `ui.focus` before 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.pointer` takes 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`:
|
|
|
|
```sh
|
|
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 in `format/src/migrate.rs`.
|
|
- `tiles/<blake3>.zst` and `blobs/<blake3>.zst`: zstd-compressed objects, content-addressed by the BLAKE3 hash of their uncompressed bytes.
|
|
- `thumb.png` and `composite/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 run `photocraft-cli mcp --bridge 127.0.0.1:7878`. See `docs/control-protocol.md#mcp-bridge`.
|
|
|
|
Tools:
|
|
|
|
- `session_list`
|
|
- `doc_open`, `doc_new`, `doc_save`, `doc_export`, `doc_inspect`, `doc_render_preview` (returns a PNG image), `doc_select`, `doc_close`
|
|
- `command_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`):
|
|
|
|
```json
|
|
{
|
|
"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"]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
```sh
|
|
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, layer
|
|
export, resize/crop, CMYK + native save) driven purely over MCP.
|
|
|
|
A typical agent loop:
|
|
|
|
1. `doc_open {path}`
|
|
2. `command_list {filter:"blur"}`
|
|
3. `command_run {id:"filter.blur.gaussian", params:{radius:4}}`
|
|
4. `doc_render_preview` to check the result
|
|
5. `doc_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.assignProfile`
|
|
and `edit.convertToProfile` change 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`](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`)
|
|
- **Real-file corpus** in `corpus/` (gitignored). `corpus/psd` holds MIT-licensed test PSDs, listed with their sources in `corpus/psd/SOURCES.md`, and `cargo xtask corpus --download` fetches 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 from `gpu_canvas.rs`). Anything the GPU planner can't express (layer effects, layers clipped to pass-through groups, documents over the texture limit) returns `Unsupported` and falls back to the CPU compositor (`photocraft-compose`), which is also the reference for export and tests.
|
|
- **Layer effects** are expensive. `compose::effect_maps` caches 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.inspect` returns `perf` timings (UI ms per frame, composite ms, upload ms).
|
|
- Never scan full surfaces per frame. Cache per document revision (`PhotocraftApp::cached_bounds`). An uncached `content_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.
|
|
|
|
```sh
|
|
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 in `Services::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:** `WebShell` takes the frame's `dropped_files` before the app sees them. It reads each file with `DroppedFile::bytes_async` and 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.setInterceptFileChooserDialog` plus `DOM.setFileInputFiles` covers Open, `Input.dispatchDragEvent` with `files` covers drops, and `Browser.setDownloadBehavior` captures 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):
|
|
|
|
```sh
|
|
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:
|
|
|
|
```sh
|
|
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.
|