# Photocraft Architecture Status: draft v1 (2026-09-30). This plan does not assume a final UI toolkit. The candidates are discussed in [`rust-framework-options.md`](rust-framework-options.md). This plan sets out: - the Cargo workspace, - the rules that keep the engine independent of the UI, - the interfaces a UI has to implement, - how the PSD, compositing, history and automation parts fit together. --- ## 1. Goals and principles **Goals** - A native, portable (macOS / Windows / Linux), fully open-source layered image editor, roughly matching Photoshop's feature surface over time. - **The UI can be swapped** without touching the core: egui today, maybe Slint, Qt or something else later. - **The core compiles to `wasm32`,** which keeps a browser demo possible. - PSD read and write lives in its **own crate that can be published separately.** **Principles** 1. **Engine-first, headless-first.** Every feature is reachable without a GUI: from tests, the CLI and MCP. The GUI is one client of the engine. 2. **Everything is a command.** Each user-visible action has a stable `CommandId` (such as `filter.blur.gaussian`) with typed, serializable parameters. The menu, command palette, shortcuts, recorded actions/macros, CLI, MCP and plugins all dispatch the same commands. 3. **Data describes the UI; toolkits draw it.** Tools produce *overlay primitives* (lines, handles, marching-ants paths) as data. Filter and adjustment dialogs are generated from *parameter schemas*. A new toolkit implements one renderer for each, not about 150 bespoke dialogs. 4. **One algorithm, one parameter struct.** Every algorithm has a CPU reference implementation (Rust + rayon, deterministic). Some also get a GPU implementation (WGSL). Both read the same `#[repr(C)]` `bytemuck::Pod` parameter struct. We never keep hand-synced copies of an algorithm in several languages. 5. **Immutable snapshots, copy-on-write tiles.** Pixel data is stored in `Arc`-shared 256² tiles. Taking a document snapshot is O(layers), which makes undo, background jobs, autosave and UI reads cheap and lock-free. 6. **Determinism.** The same input gives the same output across CPU and GPU (within tolerance), across thread counts, and between preview and export. Noise is hashed from document coordinates. 7. **Pure Rust by default.** C/C++ dependencies are allowed only behind Cargo features, in I/O-edge crates (for example HEIF, or an optional LibRaw). 8. **Clean-room.** We study other editors' *behaviour* only, and we must **not copy proprietary source** (Rust, WGSL, C++ or JS), even where it ships as readable source. Specs come from public format docs (Adobe PSD spec, ISO/ICC), academic papers (PatchMatch, Poisson blending, ARAP) and observed behaviour. Never paste code from another product. ### 1.1 Avoiding GIMP's hole The criticism of GIMP is historically true, and much of it still applies in 2026. **Bit depth.** - GIMP's original core hard-coded 8-bit-per-channel tiles. - High bit depth needed a whole new engine, GEGL. GEGL began around 2000, was introduced as optional in 2.6 (2008), and the port only finished in **GIMP 2.10 (2018)**, "the result of six years of work" on that port alone. - Only then did GIMP get 16/32-bit integer and float processing. **Non-destructive editing.** - Photoshop has had adjustment layers since about 1994. - GIMP got non-destructive *filters* in **3.0 (March 2025)**, about seven years after 2.10 because of the GTK3 port. - It got non-destructive *layer types* (link layers, vector layers) in **3.2 (March 2026)**. **CMYK.** - GIMP 3.x is still "a 100% RGB editor" internally. - CMYK exists only as export (JPEG/TIFF/PSD) and soft-proofing. **PSD.** - Import is still lossy. Photoshop adjustment layers and most layer styles are not carried over faithfully, and text often needs re-typing. - 3.2 converts legacy drop and inner shadows into GIMP filters, and added PSB export. **Root cause:** fundamental properties were **baked into the core as assumptions instead of being data.** Pixel depth, colour model, and "a layer is a pixel buffer" were all hard-coded. Every later feature that touched them needed a rewrite of everything built on top. Sources: [GIMP 2.10 release notes](https://gimp.org/release-notes/gimp-2.10.html), [GEGL on Wikipedia](https://en.wikipedia.org/wiki/GEGL), [GIMP 3.2 release notes](https://www.gimp.org/release-notes/gimp-3.2.html), [LWN on GIMP 3.0 colour](https://lwn.net/Articles/998793/). **Rules we adopt from day one** (cheap now, and each would force a rewrite later): | Property | Rule | Where | |---|---|---| | **Bit depth** | Every surface carries a runtime `PixelFormat { depth: U8\|U16\|F16\|F32, model, alpha }`. Algorithms are written generically over a `Sample` trait and monomorphized per depth, or run on f32 working buffers and convert at tile edges. No `u8` pixel type appears in any public engine API. CI runs the golden suite at 8, 16 and 32f. | `raster`, `color`, `algo` | | **Colour model** | `ColorMode` (RGB, Gray, CMYK, Lab, Indexed, Bitmap, plus multichannel/spot) is in `doc` from the start. Channel count is dynamic (1–N plus alpha plus spot). Only RGB/Gray *rendering* ships first, but the storage, PSD round-trip and conversion paths never assume 3 or 4 channels. | `doc`, `raster`, `psd` | | **Colour management** | Every document has an ICC profile. The compositor takes a working-space descriptor. The display transform is a separate final stage. Blending space (gamma vs linear) is a per-document option. | `color`, `compose` | | **Non-destructive by construction** | `LayerContent` is an enum that includes Adjustment, Fill, Text, Shape and Smart from v1, with smart filters as a stack on any layer. The compositor evaluates a *graph*, not a list of flattened buffers. Destructive ops are just "apply and bake". | `doc`, `compose` | | **File formats** | Formats plug in through `trait ImageDecoder/Encoder` plus a registry with capability flags (depths, modes, layers, alpha, metadata, ICC). Adding a format is one crate feature. It must never need a change in core types. Lossless PSD round-trip preserves unknown blocks. | `codecs`, `io`, `psd` | | **Size** | Tiled, sparse storage behind a `TileStore` trait, with 64-bit coordinates for PSB-scale canvases (300k × 300k). Nothing assumes a document fits in one allocation or one GPU texture. | `raster` | | **UI toolkit** | The core never depends on the toolkit (§3–§4). GIMP spent years porting GTK2 to GTK3, and our UI crate can be replaced without touching the engine. | layering rule 2 | The test is simple: **adding 64-bit float, a new colour model, or a new file format should touch a leaf crate, not the foundation.** We check this in phase 2 by adding a (hidden) CMYK and a 32f code path early, even before they are exposed in the UI. --- ## 2. Workspace layout ```text photocraft/ ├─ Cargo.toml # [workspace], shared deps, lints, profiles ├─ crates/ │ │ ── foundation (no_std-friendly where practical, wasm-safe) ── │ ├─ geom/ photocraft-geom points, rects, affine/perspective, tile coords, bezier (kurbo) │ ├─ cms/ photocraft-cms pure-Rust ICC colour management: profiles, transforms, intents, BPC, soft proof, 3D LUTs │ ├─ color/ photocraft-color pixel formats, color spaces, blend-mode math (scalar reference); conversions via cms │ ├─ raster/ photocraft-raster tiled COW surfaces, masks, mip pyramids, damage regions, pixel iterators │ │ ── document ── │ ├─ doc/ photocraft-doc document model: layer tree, masks, effects, channels, paths, guides, metadata (pure data + serde) │ ├─ ops/ photocraft-ops reversible operations on a doc + transactions + history/undo │ │ ── algorithms (CPU reference impls, rayon, wasm-safe) ── │ ├─ algo/ photocraft-algo adjustments, filters, selection, inpaint/heal, warp/transform, resampling │ ├─ paint/ photocraft-paint brush engine: dabs, dynamics, stroke smoothing, brush presets (.abr import later) │ ├─ text/ photocraft-text font DB trait, shaping + layout (parley/skrifa/harfrust), text-layer rasterization │ ├─ vector/ photocraft-vector shapes, paths, strokes → coverage (vello_cpu / kurbo) │ │ ── rendering ── │ ├─ compose/ photocraft-compose layer tree → DrawOp plan; CPU compositor (reference + export + wasm fallback) │ ├─ gpu/ photocraft-gpu wgpu backend for the DrawOp plan + GPU kernels (WGSL), residency cache, memory budget │ ├─ viewport/ photocraft-viewport camera (zoom/pan/rotate), visible tiles/mips, renders a doc into a wgpu texture; toolkit-agnostic │ │ ── I/O ── │ ├─ psd/ photocraft-psd PSD/PSB read + write; its OWN format-level model; depends on nothing in this workspace │ ├─ adobe-assets/ photocraft-adobe-assets .abr .asl .aco/.ase .grd .pat .csh .atn .cube/.3dl, ACR .xmp presets (standalone, like psd) │ ├─ codecs/ photocraft-codecs png/jpeg/tiff/webp/gif/bmp/avif/jxl (+heif via feature) decode/encode │ ├─ raw/ photocraft-raw clean-room camera RAW decode (DNG, CR2, TIFF/EP) + develop pipeline (standalone, like psd) │ ├─ format/ photocraft-format native document format (.pcraft bundle): manifest + content-addressed tiles │ ├─ io/ photocraft-io import/export orchestration; doc ⇄ PSD mapping; PDF/SVG import (features) │ │ ── intelligence ── │ ├─ ml/ photocraft-ml model registry + InferenceBackend trait (ort native / burn|candle web); SAM2, matting, depth, sky │ ├─ plugins/ photocraft-plugins sandboxed WebAssembly filter plug-ins (wasmi host, limits, ABI v1, registry) │ │ ── the façade every frontend talks to ── │ ├─ tools/ photocraft-tools tool state machines (move, marquee, lasso, brush, clone, gradient, crop, transform, pen, text…) │ ├─ engine/ photocraft-engine Session: open docs, command registry + dispatch, jobs, events, view-models, preferences │ │ ── platform + frontends ── │ ├─ platform/ photocraft-platform traits: file dialogs, clipboard, fonts, tablet input, menus, storage; native + web impls │ ├─ ui-egui/ photocraft-ui-egui the (first) GUI shell: panels, dialogs, canvas widget, theme │ ├─ automation/ photocraft-automation MCP server (rmcp) + JSON-RPC over the command registry │ └─ testkit/ photocraft-testkit golden images, perceptual diff, fixtures, PSD corpus helpers ├─ apps/ │ ├─ photocraft/ desktop binary (winit + wgpu + ui-egui + platform-native) │ ├─ photocraft-cli/ headless batch CLI (open → commands → export), also hosts `mcp` subcommand │ └─ photocraft-web/ wasm32 binary (ui-egui + platform-web), demo ├─ shaders/ (or inside gpu/) WGSL, with generated struct headers ├─ assets/ icons, bundled fonts, default brushes/swatches/presets ├─ fuzz/ cargo-fuzz targets (psd, codecs, format) ├─ xtask/ build tasks: layer-check, shader-gen, corpus fetch, release └─ plan/ this directory ``` **What exists today.** This layout is the target design. Built so far: `geom`, `cms`, `color`, `raster`, `psd`, `codecs`, `doc`, `ops`, `algo`, `paint`, `text`, `vector`, `compose`, `gpu`, `format`, `raw`, `io`, `plugins`, `engine`, `ui-egui`, `automation`, `testkit`, and the three apps. Not yet split out: `viewport` and `tools` live inside `ui-egui` and `engine`; `platform` services are function hooks injected by each app (`ui_egui::Services`); `adobe-assets` and `ml` are not started. **Crate granularity:** start with the crates above. Split `algo` into `-adjust`, `-filters`, `-select`, `-inpaint` and `-warp` once any module passes about 10k lines, or once compile times hurt. Its internal module boundaries should already follow those lines. **Naming:** directories are short and packages are prefixed `photocraft-`. `photocraft-psd` gets a neutral, publishable name (such as `psd-rw`) if we release it to crates.io. --- ## 3. Dependency layering (enforced) ```text L7 apps/* (binaries: wire everything together) L6 ui-egui · automation · platform L5 engine L4 tools · viewport · io · ml · plugins L3 compose · gpu · format L2 ops · algo · paint · text · vector L1 doc L0 geom · cms · color · raster psd, codecs, raw, adobe-assets (standalone, no workspace deps) ``` **Rules** (checked by `cargo xtask layers` in CI, which parses `cargo metadata`): 1. A crate may depend only on crates in **lower** layers. No cycles and no sideways dependencies, except where listed. 2. **Nothing below L6 may depend on any UI toolkit, winit, or a `platform` implementation.** Platform services reach the engine through traits defined in `engine` (or in `platform`'s trait-only core), and are injected at startup. 3. **`photocraft-psd` depends on no workspace crate.** The doc ⇄ PSD mapping lives in `io`. This keeps the PSD crate publishable and reusable by other projects. 4. **wasm gate:** every crate in L0–L5 (except feature-gated native backends) must build for `wasm32-unknown-unknown`. CI runs `cargo build -p photocraft-engine --target wasm32-unknown-unknown --no-default-features --features web`. 5. `gpu` is optional for `engine`. Engine features are `gpu` (default on) and `cpu-only`, and `cpu-only` builds are what the headless CLI and CI tests use. 6. **C dependencies** (libheif, optional LibRaw, pdfium) only behind features, only in `codecs`, `raw` or `io`, and never on by default for the web target. --- ## 4. The UI-swap seam (the most important interface) A frontend needs exactly five things from the core. Keeping these small and data-oriented is what makes the UI replaceable. ### 4.1 `Session`: commands in, events out ```rust // photocraft-engine pub struct Session { /* docs, registry, job pool, prefs, services */ } impl Session { pub fn new(services: Services) -> Self; // platform traits injected pub fn dispatch(&self, doc: Option, cmd: CommandInvocation) -> Result; pub fn registry(&self) -> &CommandRegistry; // metadata for menus/palette/shortcuts pub fn snapshot(&self, doc: DocId) -> Arc; // lock-free read (arc-swap) pub fn events(&self) -> EventReceiver; // DocChanged{damage}, JobProgress, Toast, … pub fn view_models(&self, doc: DocId) -> ViewModels; // layers, history, channels, tool options… } pub struct CommandInvocation { pub id: CommandId, pub params: serde_json::Value /* validated vs schema */ } ``` ### 4.2 `CommandRegistry`: the menu, palette and automation catalogue Each command registers the following: - `id` (`"filter.blur.gaussian"`), `label`, `menu_path` (`["Filter","Blur"]`), and a `default_shortcut`. - `params: schemars::Schema`, plus optional `ui_hints` (slider ranges, units, log scale, grouping). - `enabled(&DocSnapshot, &ToolState) -> bool` and `checked(...) -> Option`. - `preview: PreviewMode` (None / Live / Proxy). This lets dialogs show on-canvas preview generically. - `kind`: Instant / Dialog / Interactive (hands control to a tool, e.g. Free Transform). **Consumers:** - The GUI builds the menu bar, the ⌘K palette and the shortcut editor from it. - `automation` exposes the registry as MCP tools. - `photocraft-cli` exposes `photocraft run --cmd filter.blur.gaussian --param radius=4 in.psd out.png`. - Action recording is simply `Vec`. ### 4.3 Tools: pointer events in, ops and overlays out ```rust // photocraft-tools pub struct PointerEvent { pub pos_doc: Point, pub pressure: f32, pub tilt: Vector, pub twist: f32, pub time_us: u64, pub buttons: Buttons, pub mods: Modifiers, pub kind: PointerKind /* mouse|pen|eraser|touch */ } pub trait Tool { fn id(&self) -> ToolId; fn options_schema(&self) -> Schema; // options bar generated from schema fn on_pointer(&mut self, ev: &PointerEvent, cx: &mut ToolCx) -> ToolResponse; // ToolCx gives snapshot + op sink fn on_key(&mut self, key: Key, cx: &mut ToolCx) -> ToolResponse; fn overlay(&self, view: &ViewTransform) -> Vec; // drawn by any UI fn cursor(&self) -> CursorSpec; } pub enum OverlayPrim { Path{path: BezPath, style: StrokeStyle, space: Space}, MarchingAnts{path: BezPath}, Handle{pos: Point, kind: HandleKind}, BrushOutline{center: Point, radius: f32, ..}, Text{pos: Point, text: String}, Guide{..}, Grid{..} } ``` Coalesced high-rate pen samples come from `platform` and are handed to tools as a batch, so the brush sees every sample even when the UI renders at 60–120 Hz. ### 4.4 Viewport: document to pixels ```rust // photocraft-viewport pub struct Viewport { pub camera: Camera /* zoom, pan, rotation, dpr */, /* tile/mip cache */ } impl Viewport { pub fn render_gpu(&mut self, snap: &DocSnapshot, gpu: &GpuContext, target: &wgpu::TextureView, damage: &Region); pub fn render_cpu(&mut self, snap: &DocSnapshot, target: &mut RgbaImageMut, damage: &Region); // wasm fallback, tests pub fn doc_to_screen(&self) -> Affine; pub fn screen_to_doc(&self) -> Affine; } ``` - Any toolkit that can host a `wgpu::TextureView` (egui paint callbacks, Slint's wgpu texture import, Bevy, a raw winit surface) embeds the canvas the same way. - A toolkit without wgpu can fall back to `render_cpu`. ### 4.5 View-models and schemas - Panels (Layers, History, Channels, Paths, Properties, Navigator, Histogram, Swatches) read plain structs produced from a snapshot, such as `LayersModel { rows: Vec }> }`. Thumbnails are `ThumbHandle`s that the UI resolves to textures through an engine thumbnail cache. - Dialogs are generated from command `params` schemas. A toolkit may register *custom* dialog widgets for a few complex commands: Curves, Levels, Camera Raw, Liquify, Layer Style. **Litmus test for the seam:** `apps/photocraft-cli` and the automation server must be able to do *everything* the GUI does, except pointer painting, which they do by replaying `PointerEvent` streams through tools. --- ## 5. Document model (`photocraft-doc`) ```rust pub struct Document { pub id: DocId, pub size: Size, pub resolution_dpi: f32, pub mode: ColorMode /* Rgb | Gray | Cmyk | Lab | Bitmap | Indexed */, pub depth: Depth /* U8 | U16 | F32 */, pub profile: IccProfileRef, pub root: LayerGroup, // tree pub channels: Vec, // saved selections/spot pub paths: Vec, pub guides: Guides, pub slices: .., pub metadata: Metadata /* XMP, EXIF */, pub selection: Option, // current selection (a raster mask + optional vector outline) } pub struct Layer { pub id: LayerId, pub name: String, pub visible: bool, pub locks: Locks, pub blend: BlendMode, pub opacity: f32, pub fill_opacity: f32, pub clipped: bool, pub mask: Option, pub vector_mask: Option, pub effects: Effects, pub blend_if: BlendIf, pub content: LayerContent } pub enum LayerContent { Raster(TiledSurface), Group(LayerGroup /* children, pass-through or isolated */), Adjustment(Adjustment /* Curves, Levels, HueSat, … as params */), Fill(Fill /* solid | gradient | pattern */), Text(TextLayer /* runs, paragraph styles, warp; cached raster */), Shape(ShapeLayer /* vector paths + fill/stroke */), Smart(SmartObject /* embedded or linked source + transform + smart filters */), } ``` - **Pixels.** `raster::TiledSurface` is a sparse map from `TileCoord` to `Arc>`, with 256×256 tiles. Missing tiles are transparent, or take a fill value. Mutation calls `Arc::make_mut`, so it is copy-on-write. - **Tile generation counters** feed the GPU residency cache and the thumbnail cache. - **Mip pyramids** are derived lazily per layer, for zoomed-out views. - **Big documents.** The tile store is behind a `TileStore` trait: in-memory now, disk-spilling ("scratch disk") later, with LRU eviction under a global memory budget. - **The first milestone supports RGB and Gray, at 8/16/32f.** CMYK and Lab are modelled in the types from day one (so PSD round-trips don't lose them) but are rendered by converting to RGB until later phases. --- ## 6. Operations and history (`photocraft-ops`) - **Every mutation is an `Op`**, such as `SetLayerProps`, `PaintTiles{layer, tiles_before, tiles_after}`, `AddLayer`, `MoveLayer` or `ApplyFilter`. - **A command produces a `Transaction` of ops** with a label ("Gaussian Blur"). - **History stores transactions.** Because tiles are `Arc`-shared, "before" states cost only the tiles that actually changed. Undo means swapping `Arc`s, so no pixel copies are made. - **Brush strokes** accumulate into one transaction per stroke. Tiles are published incrementally with damage regions, so the viewport updates live. - **Snapshots for readers.** After each committed op the engine publishes a new `Arc` through `arc-swap`. The UI, background jobs (filters, AI inference, export) and autosave read snapshots without locks. - **Background jobs** compute from snapshot N and commit as a transaction. If the doc changed meanwhile, the job rebases, meaning it re-targets the same layer id if it still exists, or reports a conflict. - **History features:** a history panel, a memory cap with oldest-first eviction, and named snapshots. The history brush comes later, reading tiles from a past state. --- ## 7. Rendering ### 7.1 Planner (`compose`) `plan(&DocSnapshot, region) -> Vec` flattens the layer tree into a linear instruction stream: - `PushGroup{isolated, blend, opacity}` / `PopGroup` - `BeginClipBase` / `Clipped` - `DrawTiles{layer, blend, opacity, mask}` - `Adjust{program}` - `Effect{kind, params}` - `Fill{...}` Both backends consume the same plan. This is the only place that encodes Photoshop layer semantics: pass-through groups, clipping groups, fill vs opacity, blend-if and knockout. ### 7.2 Backends - **CPU (`compose`).** The reference implementation, done per tile with rayon. It is used for export, tests, the CLI, and the web fallback when WebGPU is unavailable. - **GPU (`gpu`).** wgpu compute and render pipelines that run the same plan. They use: - `rgba16float`/`rgba32float` accumulators, - specialized pipelines per blend family and adjustment (WGSL `override` constants, not one giant `switch`), - a residency cache keyed by `(TileId, generation)`, - cached group and effect results that are invalidated by generation, - one global `Budget` for GPU memory. - **Parity tests.** For every blend mode, adjustment and effect, the CPU and GPU results must match within a tolerance of ≤1/255 for 8-bit output. This runs in CI on a software adapter (wgpu with lavapipe or WARP). ### 7.3 Color - Blending happens in document space by default, which is Photoshop-compatible. A per-document "linear light blending" option is also available. - **Display transform** (`engine/src/display_color.rs`): the canvas is always colour-managed, document profile → monitor profile (relative colorimetric + BPC), cached per (document profile, mode, monitor). On the GPU canvas the transform, plus Proof Colors / Gamut Warning / 32-bit preview, is baked into a 33³ 3D LUT the canvas shader's final pass applies; the CPU canvas runs an 8-bit `photocraft-cms` transform on the composite. When the document profile matches the monitor (sRGB on sRGB) there is no LUT and no transform. Linear composites (EXR/HDR, tagged linear sRGB on import) are stored sRGB-encoded in the 8-bit canvas texture. CMYK documents are read through their embedded CMYK profile (`photocraft_color::convert::with_cmyk_space`, entered by the compositors and composite exports). - **Monitor profile:** Edit › Color Settings › Monitor Profile: `auto` (macOS: the main display's profile, read at launch through `osascript`/AppKit `NSScreen.colorSpace.ICCProfileData`, no FFI; elsewhere sRGB), a built-in RGB profile or an `.icc` path. The profile is not re-read when the window moves to another display. - **HDR/EDR output** (an `rgba16float` surface with an extended-range colorspace) is a later-phase feature. The interfaces already carry `f32` pixels. ### 7.4 Oracle **PSD files contain Photoshop's own merged composite.** Running our compositor on a PSD's layers and diffing against its embedded composite gives a free, large conformance test suite. `testkit` automates this over a PSD corpus. --- ## 8. `photocraft-psd`: standalone PSD/PSB crate **Scope:** faithful, format-level read and write of PSD (v1) and PSB (v2, 64-bit lengths). It has no dependency on our document model. ```text psd/src/ header.rs color mode, depth, size color_mode_data.rs resources/ image resources (1005 resolution, 1036 thumbnail, 1039 ICC, 1060 XMP, 1058 EXIF, slices, guides, …) layers/ layer records, channel image data (raw | RLE/PackBits | ZIP | ZIP+prediction), masks, blending ranges tagged/ additional layer info blocks, one module per key: luni lsct lyid lfx2/lrFX (effects) TySh (text) SoLd/PlLd/lnk2 (smart objects) vmsk/vsms/vogk (vector) adjustment keys (curv levl hue2 blnc selc mixr grdm phfl brit thrs post nvrt expA vibA clrL blwh) SoCo GdFl PtFl (fills), Patt, Txt2, … descriptor.rs ActionDescriptor / OSType parser+writer (used by many tagged blocks) engine_data.rs text "EngineData" (PostScript-like) parser+writer image_data.rs merged composite write.rs serializer (+ RLE/ZIP encoders) ``` - **Lossless round-trip.** Unknown or unsupported tagged blocks are kept as raw bytes and re-emitted unchanged. That way files keep features we don't model yet, like 3D or video. - **Lazy decode.** The parser indexes channel data and decodes it only on request. This makes opening a 2 GB PSB fast and memory-bounded. - **The merged composite on write is supplied by the caller** as an image buffer. `io` renders it with `compose`, so `psd` never needs a compositor. - **Mapping lives in `io::psd`.** It converts `psd::File` into `doc::Document` and back, with fidelity levels: 1. Flattened image. 2. Raster layers, masks, blend/opacity/fill, visibility, names. 3. Groups (including pass-through), clipping, blend-if, adjustment and fill layers. 4. Layer effects (lfx2). 5. Text layers: shaped with `text`; the original descriptors are kept for round-trip. 6. Smart objects: embedded and linked. 7. Vector masks and shape layers. - **Testing:** - Round-trip byte-stability for unmodified files. - Open → save → open equality. - Composite-oracle diffs (§7.4). - `cargo-fuzz` on the parser. - A corpus from MIT/BSD-licensed test sets (ag-psd, psd-tools), fetched by `xtask`, not committed. - **Existing crates:** `psd` (read-only, limited) and the new `ag-psd` Rust port. We evaluate them in spike week 1, then either depend on one, fork it, or write our own. Given how central PSD is, writing our own behind a stable API is likely. --- ## 9. Native file format (`photocraft-format`) - **Format:** a `.pcraft` file is a zip (store mode) or directory bundle. - `manifest.json`: a versioned document tree with serde, including `format_version` and migrations. - `tiles/.zst`: content-addressed tiles, zstd-compressed. - `thumb.png` and `composite/` mips for quick look and previews. - **Saving is incremental.** Tiles are content-addressed, so a save writes only new tiles plus the manifest, and garbage-collects unreferenced ones. - **Autosave and crash recovery** reuse the same writer into a recovery directory, reading from a snapshot on a background thread. - **PSD is the interchange format,** and `.pcraft` is the lossless native one. --- ## 10. Algorithms (`algo`, `paint`, `text`, `vector`, `ml`) | Area | Contents (first pass) | GPU kernel? | |---|---|---| | Adjustments | levels, curves, brightness/contrast, exposure, vibrance, hue/sat, color balance, B&W, photo filter, channel mixer, gradient map, selective color, invert/posterize/threshold, 3D LUT | yes (LUT + small kernels) | | Filters | gaussian/box/motion/radial/surface/lens blur, unsharp/smart sharpen, high pass, noise add/reduce/median/dust, distort family (twirl, wave, polar, spherize, displace), stylize, render (clouds, lens flare), pixelate | many | | Selection | marquee/lasso geometry → mask, magic wand (flood fill tolerance), color range, quick select (graph cut / superpixels), feather, grow/shrink, refine edge | some | | Inpaint/heal | spot heal, healing brush (Poisson), content-aware fill (PatchMatch + multiscale EM) | later | | Warp | free transform (affine/perspective/warp mesh), liquify, puppet (ARAP), lens correction, resampling (Lanczos/bicubic) | yes | | Paint (`paint`) | dab generation (hardness, roundness, angle, spacing, scatter), dynamics from pressure/tilt/velocity, smoothing (pulled string / lazy mouse), wet edges, airbrush, eraser, clone/heal sources, blend modes per stroke, `.abr` import | CPU first, GPU later | | Text (`text`) | font DB trait (system fonts native, bundled fonts on web), parley layout, per-run styles, paragraph options, warp; renders to a raster cache | — | | Vector (`vector`) | shapes, bezier paths, pen tool geometry, strokes (dash, align, caps), boolean ops → coverage via vello_cpu | — | | ML (`ml`) | `InferenceBackend` trait; tasks: `segment_point/box` (SAM2), `matte_subject` (background removal), `depth`, `sky`; model registry with SHA-256 verification and on-demand download | backend-dependent | **Rules for every algorithm:** - **Signature:** a pure function `fn(input: &SurfaceView, params: &P, region: Rect, cancel: &CancelToken) -> Surface`. - **Params:** `P: Pod + JsonSchema + Serialize + Deserialize`. - **Registration:** registered with the command registry by the engine, not by the algo crate itself, so `algo` stays UI- and engine-agnostic. - **Tiles:** tile-aware, requesting an input halo of `radius` pixels. - **Testing:** golden-image tests in `testkit`, plus benchmarks with criterion. --- ## 11. Engine runtime model ```text UI thread ──dispatch()──► Engine thread (owns mutable docs, applies ops, publishes snapshots) ▲ │ spawns │ events (damage, progress)│ │ ▼ │ Job pool (rayon): filters, AI, export, autosave — read snapshots, return transactions │ Render (UI thread or dedicated): Viewport::render_gpu(snapshot) into toolkit texture ``` - **Pen input path for low latency:** platform coalesced samples → tool (on the engine thread) → dab rasterization into COW tiles → damage published → viewport re-composites only the damaged tiles. Target: the stroke appears within the next frame. - **Cancellation:** every job takes a `CancelToken`, and the UI can cancel any job with progress. - **Services injected into `Session`:** - `FileSystem` (native fs / web File System Access / in-memory for tests) - `Clipboard`, `FontSource`, `ModelStore` - `Clock`, `Notifier` --- ## 12. Automation and extensibility - **`automation`:** an MCP server built on `rmcp`. It exposes: - `session.list`, `doc.open`, `doc.save`, `doc.export`, `doc.inspect` (layer tree as JSON), `doc.render_preview` (PNG). - `command.list` and `command.run(id, params)`, both generated from the registry. - Stdio for agent CLIs, and optionally loopback TCP with a token so it can attach to a running GUI. - `AuthorizedWorkspace`, which holds independent read and write directory capabilities. Remote paths are validated relative names; engine commands that still require ambient filesystem access fail closed at the automation boundary. - **Actions:** recorded `Vec`, replayable in batch (File → Automate → Batch). - **Scripting (later):** embed a scripting language over the same registry. Options are Rhai, or Lua via mlua (C). JS via QuickJS is possible if we want Photoshop-script familiarity. - **Plugins:** sandboxed WebAssembly filter plug-ins (`photocraft-plugins`, L4) run by `wasmi`, a pure-Rust interpreter, with fuel, memory, stack and wall-time limits and no host imports. They are driven by the `plugin.*` commands and listed under Filter › Plug-ins; the ABI is in [`plugins.md`](plugins.md). Native Photoshop `.8BF`/CEP/UXP hosting is out of scope (it needs unsafe FFI and can't run on the web). Panel plug-ins are later. --- ## 13. Testing and quality | Layer | What | |---|---| | Unit | per crate; algorithms against small hand-computed fixtures | | Golden images | `testkit::assert_image_eq(actual, "golden/name.png", tol)`; goldens regenerated by `xtask bless` | | CPU/GPU parity | every GPU kernel vs its CPU reference, software adapter in CI | | PSD | round-trip, composite oracle, fuzzing | | Engine | command-level tests: open fixture → dispatch commands → assert snapshot/render; action replays | | UI | headless egui tests (egui_kittest) for panels; kept thin because the UI is thin | | Perf | criterion benches + budget checks in CI (composite 4k² 20 layers, brush dab throughput, open 100 MP) | | Portability | CI matrix: macOS arm64, Windows x64, Linux x64, wasm32 build check | **Performance targets for v1:** - Stroke-to-pixel within one frame at 120 Hz. - Pan and zoom at display refresh on a 100 MP / 30-layer document. - Open a 100 MP JPEG in under 1.5 s. - 20k×20k 16-bit documents on a 16 GB machine. --- ## 14. Cargo workspace sketch ```toml [workspace] resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] edition = "2024" license = "MIT OR Apache-2.0" # decision pending, see §16 rust-version = "1.90" [workspace.dependencies] # foundation bytemuck = { version = "1", features = ["derive"] } kurbo = "*" # pin at scaffold time glam = "*" smallvec = "1" arc-swap = "1" rayon = "1" thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = "1" blake3 = "1" zstd = "*" # native; ruzstd for wasm decode # color / codecs # ICC: our own photocraft-cms (pure Rust) image = { version = "*", default-features = false } jxl-oxide = "*" # gpu wgpu = "*" # one version shared by gpu, viewport, ui # text / vector parley = "*" skrifa = "*" vello_cpu = "*" # ml ort = { version = "*", optional = true } # automation rmcp = "*" [workspace.lints.rust] unsafe_code = "deny" # allowed per-crate with justification (gpu, platform) [profile.release] lto = "thin" codegen-units = 1 [profile.dev.package."*"] opt-level = 2 # image code is unusable at opt-level 0 ``` Versions get pinned when we scaffold, using the numbers in `rust-framework-options.md`. --- ## 15. Phased roadmap Each phase ends with a demoable build and green CI on all targets. Phases 8–10 (colour/print pro, photographer/designer pro, frontier) and the per-feature phase assignments are in [`photoshop-parity.md`](photoshop-parity.md). | Phase | Scope | Done when | |---|---|---| | **0. Skeleton** (1–2 wk) | workspace, all crates stubbed, layering check, CI matrix incl. wasm, testkit, xtask | `cargo xtask ci` green on 3 OS + wasm | | **1. Viewer** | raster doc, tiles, codecs (png/jpeg/tiff/webp), viewport pan/zoom/rotate on GPU + CPU fallback, `.pcraft` save/open, CLI `convert` | open a 100 MP JPEG, pan at refresh rate, save/reopen losslessly | | **2. Layers** | layer tree, all 27 blend modes, opacity/fill, masks, groups, clipping; planner + CPU/GPU compositors + parity tests; history; **PSD read** fidelity 1–3 | composite-oracle suite ≥ 95% within tolerance on the corpus | | **3. Paint and select** | platform tablet input, brush engine + presets, eraser, marquee/lasso/wand, move, free transform, crop | draw with pen pressure at <1 frame latency; selection-restricted painting | | **4. Adjust and filter** | adjustment layers (first 10), first 20 filters with schema-generated dialogs + live preview; **PSD write**; actions recording | PSD open → edit → save opens correctly in Photoshop/Photopea | | **5. Text, vector, styles** | text layers, shapes/pen/paths, layer effects (drop shadow, stroke, glows, overlays, bevel) | PSD text/shape/effects round-trip at fidelity 4–5 | | **6. Smart features** | ML backend + SAM2 select subject/object, background removal, content-aware fill, healing, RAW develop | select subject + remove background on a portrait in <3 s | | **7. Automation and web** | MCP server + CLI parity, batch; `photocraft-web` demo | an agent edits a PSD through MCP; the web demo opens a PSD, paints and filters | --- ## 16. Open decisions 1. **UI toolkit.** The leading option is egui on winit + wgpu. Bevy, Slint and Qt are the alternatives (see `rust-framework-options.md`). This plan works with any of them. 2. **License.** MIT/Apache-2.0 is maximally reusable. GPL-3.0 is the Krita/GIMP model and protects against proprietary forks. Note that some candidate deps are LGPL (rawler) or GPL-or-commercial (Slint), and MIT/Apache would constrain which of them we can use. 3. **Project name.** `photocraft` (the repo name) is assumed. 4. **PSD crate.** Build our own, or adopt or fork `ag-psd`/`psd`. Decide after the week-1 spike. 5. **Default working depth and blending.** 8-bit + gamma-space blending matches Photoshop. 16-bit/float + linear is the modern default. The proposal is to follow Photoshop by default and make the other a per-document option. 6. **Scope of Photoshop plugin compatibility.** Decided: no native `.8BF` hosting; a sandboxed WebAssembly plug-in API instead (see [`plugins.md`](plugins.md)).