PRD: NodeTool AI Image Editor
Status: Draft, scoped for Slice 1 + Slice 2. Owner: Image Editor feature team. Last updated: 2026-05-09.
1. Summary
The AI Image Editor is a generation-aware, layer-based image editing surface for NodeTool — a Photoshop-class editor where every layer can be (a) imported, (b) hand-painted, or (c) backed by an embedded NodeTool workflow that generates and re-generates that layer’s pixels on demand. Generated layers remember how they were made and can be inspected, tweaked, regenerated, versioned, and round-tripped to the Node Editor without leaving the image.
The editor surface itself already exists on the sketch-editor-rc branch: WebGPU-composited layers, 12 blend modes, 15 tools (brush, pencil, eraser, fill, gradient, shapes, transform, crop, blur, clone-stamp, eyedropper, text, select, move, segment), pressure-sensitive painting, layer effects (brightness/contrast, hue/saturation, exposure, curves, tonemap, bloom), SAM segmentation, undo/redo, and a serialized document format. Today that editor lives only as a modal inside the SketchNode / ImageEditorNode workflow node.
This PRD does two things:
- Promote the existing sketch editor from a node-modal to a top-level editor alongside the Workflow (canvas), Chain, Timeline, and Chat editors. Same code, new shell, new route, persistent document model.
- Adapt the clip→workflow binding pattern from the Timeline Editor PRD onto layers. Each generated layer is backed by exactly one
Workflowrow (run_mode = "layer"); editing the layer’sInput*nodes in the inspector queues a regeneration; successful generations append to a per-layer version history and replace the layer’s pixel data.
Positioning: NodeTool Image Editor is a generation-aware Photoshop for AI-driven image production. Not “another raster editor.”
This PRD deliberately mirrors the structure, terminology, and reuse boundaries of docs/timeline-editor-prd.md. Where the Timeline says “clip”, the Image Editor says “layer”. Where the Timeline says “track”, the Image Editor says “layer group” or “artboard”. Everything else — clone-on-create, dependency hashing, version pruning, linked vs. variation duplication, workflow round-trip, status badges — is the same pattern.
2. Scope
2.1 In scope (this PRD)
Slice 1 — Top-level editor shell, imported and painted layers
- Standalone route
/sketch/:documentId(top-level entry). - New
image_documenttable andpackages/image-editor/package for pure types and math. - Reuse all existing sketch-editor-rc components (
web/src/components/sketch/*), tools, rendering runtimes (WebGPURuntime+Canvas2DRuntimefallback), and Zustand slices. - Persistent document: layers, canvas size, background, active tool, viewport, undo history snapshot — autosaved (debounced) to the new table.
- Imported-layer flow: drag from
AssetExplorer→ new raster layer withimageReferencepin (existing behavior). - Painted-layer flow: all 15 tools work as today; results saved into the new document model.
- ModePill in
AppHeaderfor the new editor; “Return to Image” pill when round-tripping to Node Editor (same pattern as the Timeline). - The existing in-node modal mode (opening from
SketchNode) is preserved unchanged. The sameSketchEditorcomponent renders in both shells.
Slice 2 — Generation binding (layers as embedded workflows)
- New layer kind
generated.LayerWorkflowBindingaddsworkflowId,selectedOutputNodeId,paramOverrides,dependencyHash,lastGeneratedHash,versions[]. - Inspector gains a
LayerNodeStackpanel (vertical list of bound nodes; selection drives aSelectedLayerNodeStorethat feedsNodePropertyEditor) — the same shape as the Timeline’sGeneratedClipPanel. - Dirty/stale tracking via dependency hash (same hash function as Timeline).
- “Generate Layer” / “Regenerate” wired to
WorkflowRunnerandGlobalWebSocketManager. - Per-layer status badges:
draft,queued,generating,generated,stale,failed,locked,missing. - “Open in Node Editor” round-trip with
?from=sketch:{documentId}:{layerId}. - Three predefined layer templates: Text-to-Image, Inpaint (mask + prompt), Background Remove.
- Per-layer version history (last N successful generations + favorites).
- Workflow lifecycle: clone-on-create with
run_mode = "layer", delete-cascade when last referencing layer is deleted, “Save as Reusable Template” promotes torun_mode = "workflow"with theimage-templatetag. - Linked vs. Variation duplication for generated layers.
- “Generate via Inpaint Here” command on a selection: creates a new generated layer above the active layer, prefilled with the current selection mask + the layer beneath as input image.
2.2 Out of scope
- Slice 3+: whole-document export pipeline (compile layer stack to a workflow), batch variations (matrix of seeds × prompts → new document), A/B compare across generated layers, multi-shot consistency, scripted batch ops, automated content-aware fill via workflows, palette/color-management workflows, RAW import, animation timeline of layers, multi-user collaborative editing, comments/review.
- Realtime generation (typing-time previews); any “live” inpaint that streams partial pixels back.
- A second image-editor implementation. Anything not in
web/src/components/sketch/is out of scope; we are not building a competing canvas.
2.3 Explicit non-goals
- No left sidebar in the standalone shell beyond the existing tool palette.
- No embedded node-graph canvas inside the inspector — only a vertical node stack identical to the Timeline’s.
- No automatic regeneration on edit. Edits mark the layer stale; the user clicks Generate.
- No frame-accurate animation. Layers are still images. Workflows that produce video/audio are not addressable as layers in this PRD.
- No replacement of the existing single-image modal editor at
/assets/edit/:assetId(docs/image-editor.md). That remains a per-asset editor without layers; this PRD’s editor is the multi-layer document editor.
3. Adaptation principles
The same rule that governed the Timeline PRD applies here:
Reuse first. Build only what genuinely doesn’t exist.
The sketch-editor-rc branch already implements the entire painting/compositing/layering surface. The Timeline already implements the entire clip→workflow binding pattern. This PRD is mostly the integration of those two existing systems, not a new build.
| Concern | Reuse | Build |
|---|---|---|
| Layer compositing (GPU) | web/src/components/sketch/rendering/WebGPURuntime.ts (12 blend modes, layer effects pipeline) |
— |
| Layer compositing (fallback) | web/src/components/sketch/rendering/Canvas2DRuntime.ts |
— |
| Painting tools (brush, pencil, eraser, blur, clone, fill, gradient, shapes, text, eyedropper, segment, select, move, transform, crop) | web/src/components/sketch/tools/* and painting/* |
— |
| Layers panel, layer groups, alpha lock, blend mode, opacity, visibility | web/src/components/sketch/SketchLayersPanel.tsx, editor-shell/ConnectedLayersPanel.tsx, state/slices/documentSlice.ts |
— |
| Selection (rect, ellipse, lasso, magic wand) and finalization | web/src/components/sketch/selection/*, state/slices/selectionSlice.ts |
— |
| Layer effects (brightness/contrast, hue/sat, exposure, curves, tonemap, bloom) | existing non-destructive effect chain on Layer.effects |
— |
| Pressure / stabilization / symmetry | painting/StabilizerBuffer.ts, symmetry modes in toolSlice |
— |
| SAM segmentation (FAL + local backends) | web/src/components/sketch/sam/* |
— |
| Undo/redo (max 30 snapshots) | state/slices/historySlice.ts |
— |
| Keyboard shortcut catalog and dispatcher | shortcuts/* |
— |
Per-layer input/output handles on SketchNode |
Layer.exposedAsInput / exposedAsOutput, web/src/components/node/SketchNode/SketchNode.tsx |
— |
| Per-layer workflow | Workflow model — each generated layer is a workflow row with run_mode = "layer" |
— |
| Exposed parameters | Existing *InputNode classes (StringInputNode, IntegerInputNode, FloatInputNode, ImageInputNode, MaskInputNode, SelectInputNode, ImageSizeInputNode, ColorInputNode, LanguageModelInputNode, BooleanInputNode, SeedInputNode) — Input nodes ARE the exposed parameters |
— |
| Inspector frame | web/src/components/Inspector.tsx, InspectedNodeStore |
Slim wrapper that swaps target between layer / layer-bound node |
| Property editing | web/src/components/node/PropertyField.tsx, web/src/components/properties/* |
— |
| Workflow templates | Existing template/preset infrastructure + tags |
Three image-targeted seeded workflows tagged image-template |
| Open in Node Editor | Existing workflow editor at /editor/:workflowId + ?from= query convention |
Just navigate; no remap |
| Execution | WorkflowRunner.run(workflow, paramOverrides), GlobalWebSocketManager, Job |
Layer-scoped subscription, hash-based stale detection |
| Status / errors | StatusStore, ErrorStore, StatusIndicator, WarningBanner |
Layer-status mapping |
| Past outputs | ResultsStore, NodeResultHistoryStore, MediaGenerationStore, Job.outputs |
Layer-version index (jobId + assetId per version) |
| Top bar | AppHeader, ModePills |
Image-Editor pill + scoped action set |
| UI primitives | web/src/components/ui_primitives/* (mandatory; no raw MUI) |
— |
| Data models | Workflow, Job, Asset, Prediction in packages/models |
New ImageDocument only |
tRPC clip lifecycle (clone-on-create, delete-cascade, linked/variation duplicate, template promotion via tag + run_mode flip) |
packages/websocket/src/trpc/routers/timeline.ts — copy the clips.{create, delete, duplicate} logic verbatim, retargeted to layers.* |
— |
The sketch-editor-rc branch is kept in tree. This PRD does not rewrite the sketch editor; it wraps it. The Timeline PRD’s choice to rewrite a 12k-line PR from scratch does not apply here, because the sketch-editor-rc code is already production-quality (94 test files, WebGPU-accelerated, four months of focused work).
4. Architecture
4.1 Packages
- New:
packages/image-editor/— pure types and pure functions, mirroringpackages/timeline/.types.ts—ImageDocument,LayerBinding,LayerVersion,LayerStatus,LayerWorkflowKind. The pixel data, transform, and effects already live inweb/src/components/sketch/types/document.ts; this package adds only the binding/versioning fields, not the full Layer type. The WebLayertype extends from this.dependencyHash.ts— same deterministic hash function aspackages/timeline/src/dependencyHash.ts. Hash inputs:{ workflowId, workflow.updated_at, paramOverrides, currentInputAssetHashes }.seedLayerTemplates.ts— the three seededimage-templateworkflow definitions.- No React, no Zustand, no MUI. Vitest unit tests.
packages/models/—- Reuse
Workflow. Each generated layer references an existingWorkflowrow viaworkflowId. Therun_modefield gains a new value:"workflow"— standalone workflow (existing default; unchanged)."clip"— Timeline-private clone (existing)."sequence"— reserved for Timeline Slice 3 (existing)."layer"— Image-Editor-private clone owned by a single layer. Hidden from the standalone workflow list. New value; no schema migration (column is text)."image"— reserved for a future slice where the entire document compiles to one workflow. Not implemented here. The standalone workflow listing filters torun_mode IN ("workflow", null). Curated layer templates are taggedimage-template. The Add-Generated-Layer menu shows tagged workflows by default with an “All workflows” option to browse the rest.
- Add
image_documenttable — modelled ontimeline_sequence:CREATE TABLE image_document ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, project_id TEXT NOT NULL, workflow_id TEXT, -- reserved for run_mode = "image" in a future slice name TEXT NOT NULL, width INTEGER NOT NULL DEFAULT 1024, height INTEGER NOT NULL DEFAULT 1024, background_color TEXT NOT NULL DEFAULT '#ffffff', document TEXT NOT NULL, -- JSON: layers[], guides[], artboards[], activeLayerId, maskLayerId, viewport thumbnail_asset_id TEXT, -- last flattened thumbnail (debounced) created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX idx_image_document_user ON image_document(user_id); CREATE INDEX idx_image_document_project ON image_document(project_id); CREATE INDEX idx_image_document_updated ON image_document(updated_at);Layers live inside
document(JSON) to keep migrations cheap and to match the JSON-blob pattern already used bytimeline_sequence. Generated assets stay inassettable; per-layer versions reference asset ids.
- Reuse
4.2 Web
- Route:
/sketch/:documentId(standalone). New top-level entry inweb/src/index.tsx. The existing in-node modal (opened fromSketchNode) is unchanged. - Components (additive only — almost everything is reused under
web/src/components/sketch/):web/src/components/sketch/SketchEditorPage.tsx(new) — page shell. ComposesAppHeader(with new ModePill active), the existingConnectedToolbar/ConnectedToolTopBar/SketchCanvasPane/ConnectedLayersPanel/ConnectedContextMenu, plus the newSketchInspectorPanel(Slice 2). No raw MUI.web/src/components/sketch/Inspector/SketchInspector.tsx(new, Slice 2) — root; swaps betweenImportedLayerPanel,PaintedLayerPanel, andGeneratedLayerPanelbased on selection. MirrorsTimelineInspector.tsx.web/src/components/sketch/Inspector/GeneratedLayerPanel.tsx(new, Slice 2) — header (thumbnail, name, type, status, model summary, timestamps),LayerNodeStack,NodePropertyEditor,LayerActions(Generate / Regenerate / Duplicate-as-Variation / Duplicate Linked / Open in Node Editor / Reset Seed / Randomize Seed / Lock / Revert / Save as Template),VersionList. Direct port ofGeneratedClipPanel.tsx.web/src/components/sketch/Inspector/LayerNodeStack.tsx(new, Slice 2) — vertical list of the bound workflow’sInput*nodes; selection drivesSelectedLayerNodeStore. MirrorsNodeStack.tsx.- The existing
SketchToolbar,SketchLayersPanel,SketchModal, all rendering and painting code: no changes. The standalone shell mounts the same components inside a different layout host.
- Stores under
web/src/stores/sketch/(additive):useSketchStore— reuse as-is. Already exists on the branch; already composesdocumentSlice,historySlice,toolSlice,selectionSlice,viewportSlice,uiSlice. Slice 2 addsbindingSliceforparamOverridesmutations and dependency-hash recomputation.SketchDocumentStore(new, thin) — Zustand store that bridgesuseSketchStore(the in-memory document) and the persistedimage_documentrow (id, baseUpdatedAt, last server hash). Owns the autosave debounce, optimistic-concurrency token, and “Return to Sketch” routing param.SketchGenerationStore(new, Slice 2) — per-layer job ids, mapping back toWorkflowRunner/GlobalWebSocketManagersubscriptions. Direct port ofTimelineGenerationStore. Emits status transitions read byLayerNodeStackand the layers panel.SelectedLayerNodeStore(new, Slice 2) — selection state inside the inspector’s node-stack, identical pattern toSelectedClipNodeStore.- All stores follow project rules: typed selectors,
shallowfor multi-value selections, no full-store subscribes.useSketchStorealready does this correctly.
-
Server state: TanStack Query for
imageDocument,layerVersions,assetThumbnails. Keys hierarchical:["sketch", documentId, ...]. - Persistence: tRPC router added to
packages/websocket/src/trpc/routers/sketch.ts, mirroringtimeline.ts:sketch.list(projectId?),sketch.get(id),sketch.create(input),sketch.update(id, document, baseUpdatedAt?),sketch.delete(id).sketch.versions.{ list, append, setFavorite, delete }.sketch.layers.{ create, delete, duplicate }— withmode: "linked" | "variation"on duplicate, and clone-on-create withrun_mode = "layer". Implementation is a copy of the Timeline’sclips.*procedures retargeted at the new table andrun_modevalue.
- AppHeader (
web/src/components/panels/AppHeader.tsx) — add an Image Editor ModePill alongside Editor / Chat / App / Timeline. Wire the “Return to Sketch” pill for?from=sketch:{documentId}:{layerId}round-trips, mirroring?from=timeline:exactly.
4.3 Layer → Workflow binding
Every generated layer is backed by exactly one Workflow row.
- Imported layers keep their existing
imageReference/datamodel.workflowId === null. No node-stack in inspector. - Painted layers are pure raster (
data: string | null, the existing PNG data URL).workflowId === null. No node-stack in inspector. - Generated layers have
workflowIdset. The workflow’s graph contains its ownInput*nodes (StringInputNode,IntegerInputNode,FloatInputNode,ImageInputNode,MaskInputNode,SelectInputNode,ImageSizeInputNode,ColorInputNode,LanguageModelInputNode,SeedInputNode, etc.). The Input nodes ARE the exposed parameters — no parallelExposedParameterdeclaration on the layer. - The layer stores
paramOverrides: Record<inputNodeName, value>— the inputs to feed the workflow on each invocation. The inspector renders onePropertyFieldperInput*node in the bound workflow, sourced from existing node metadata. This is the same path the standalone workflow editor and the Timeline both already use. - The layer stores
selectedOutputNodeId— which terminal node’s output becomes the layer’s pixel data. Workflows with one obvious media-output node default to it; multi-output workflows force a choice on creation. The output node must beImageOutputNode(raster layers) or, for mask layers, may also beMaskOutputNode. Video/audio outputs are not addressable as layer sources. - The layer’s existing
datafield (PNG data URL) is the rendered output — set by the generation pipeline on success. Until first generation,data === nulland the layer composites as transparent (with a “Draft” badge in the layer panel, see §5.5). - The existing
Layer.exposedAsInputandLayer.exposedAsOutputflags continue to govern dynamic handles on the workflow-graphSketchNode. They are independent ofworkflowId(an exposed-input layer can still be generated; its handle just lets a parent workflow override its pixel data at run time).
Lifecycle (verbatim port of the Timeline’s clip lifecycle):
- Creating a generated layer from any standalone workflow (via the Add-Generated-Layer menu, or “Use as layer” from a workflow’s context menu): the editor clones that workflow into a new
run_mode = "layer"row owned by the layer. The clone is independent — editing the source workflow later does not affect existing layers. (Linked-duplicate behavior is opt-in per layer-pair, see below.) - Duplicate as Variation clones the layer’s workflow into another
run_mode = "layer"row. - Duplicate Linked keeps the same
workflowIdfor both layers; both regenerate together. - Save as Reusable Template flips
run_modefrom"layer"to"workflow"and adds theimage-templatetag, promoting the clone into a normal standalone workflow that appears in the curated layer menu. The layer retains its reference. Once promoted, ordinary workflow-listing rules apply. - Deleting a layer with a
run_mode = "layer"workflow: if no other layer references thatworkflowId, the workflow row is deleted. Otherwise (linked duplicates), the row is kept and only the layer reference is removed. Standalone (run_mode = "workflow") sources are never deleted by layer operations. - Open in Node Editor navigates to
/editor/:workflowId?from=sketch:{documentId}:{layerId}. Returning to the editor picks up the newworkflow.updated_at, which automatically marks all referencing layers stale (see §4.4).
4.4 Generation flow
- User edits an
Input*node’s value in the inspector (or, for a generated layer with anImageInputNode, paints/replaces the input image of an upstream layer). useSketchStore.setLayerParamOverride(layerId, inputNodeName, value)(new in Slice 2):- Updates
paramOverrides. - Recomputes the layer’s
dependencyHash(workflowId + workflow.updated_at + paramOverrides + input-asset hashes). - If
dependencyHash !== lastGeneratedHash, sets layerstatus = "stale".
- Updates
- UI updates: layer badge in
SketchLayersPanelshowsStale; the canvas keeps showing the last successful version with a stale overlay (the existing layer-thumbnail border is reused with a warning hue). - User clicks Generate Layer:
WorkflowRunner.run(workflow, { params: paramOverrides })— exactly the same call the standalone editor and the Timeline use.- Subscribe via
GlobalWebSocketManagerkeyed byjobId. - On
NodeUpdate/Prediction/JobUpdateevents,SketchGenerationStoreupdates per-layer status; the inspector’s node-stack reads existingStatusStore/ResultsStorekeyed on the bound workflow’s nodes — no duplication. - On success: append a
LayerVersion { jobId, assetId, hash, … }, setdata = <new PNG data URL>(downloaded once from the job’s output asset),lastGeneratedHash = dependencyHash, layerstatus = "generated". The layer’simageReferenceis also set so re-opens don’t have to re-decode.
- Failure: layer
status = "failed", error pulled from existingErrorStorekeyed by jobId+nodeId; primary action becomes Retry.
There is no “Generate node + downstream” command at the layer level — partial-graph execution is a workflow-runner concern. If a user needs that level of control, they open the workflow in the Node Editor.
4.5 Reuse rules and migration
- No raw MUI imports in any new file. Use primitives from
web/src/components/ui_primitives/*.FlexRow/FlexColumnoverBox sx=when shorthand suffices. - Theme tokens only; no hardcoded colors or spacing.
- All inter-package imports use
@nodetool-ai/<package>. Never import fromdist/. - All new files are TypeScript strict mode; no
any. - Functional React components; typed props.
- The existing sketch-editor-rc code already complies with the above. Do not regress.
5. UX
5.1 Layout (standalone shell)
┌──────────────────────────────────────────────────────────────────────────┐
│ AppHeader [Editor] [Chat] [App] [Timeline] [Image] … Project ▾ │
├──────────────────────────────────────────────────────────────────────────┤
│ Tool │ │ Inspector │
│ palette│ SketchCanvasPane │ (layer-aware) │
│ (left) │ (existing WebGPU/Canvas2D canvas) │ + Layers panel │
│ V C B │ │ │
│ E G T │ │ │
│ R O L │ │ │
│ S X … │ │ │
├──────────────────────────────────────────────────────────────────────────┤
│ ToolTopBar (per-tool options) — existing ConnectedToolTopBar │
└──────────────────────────────────────────────────────────────────────────┘
- The left tool palette and top tool-options bar are the existing
ConnectedToolbarandConnectedToolTopBarfromeditor-shell/. - The right panel is the existing
ConnectedLayersPanelplus a new collapsible inspector (Slice 2). - No bottom panel in Slice 1+2. Status (autosave state, generating count, error count) lives as a slim badge cluster in the AppHeader area, mirroring the Timeline’s choice.
- Modal mode (opened from
SketchNode) is unchanged: same components, fullscreen portal.
5.2 Tool palette and tool top bar
No changes to the existing tools. They are listed here so the PRD is self-contained for review:
- Selection / navigation: Select (rect / ellipse / lasso / magic wand), Move, Transform (free / crop / perspective / warp).
- Painting: Brush, Pencil, Eraser, Blur, Clone Stamp.
- Fill / color: Fill (flood with tolerance), Eyedropper, Gradient (linear / radial).
- Shapes / geometry: Shape (line / rectangle / ellipse / arrow), Crop.
- Adjustment / AI: Adjust (brightness/contrast/hue/sat live preview), Segment (SAM-powered).
All tool settings (brush size, opacity, hardness, pressure curves, tolerance, stroke width, fill color, symmetry mode, ray count) are unchanged.
5.3 Layers panel
The existing SketchLayersPanel already supports CRUD, reorder, visibility, opacity, blend mode, alpha lock, groups, expand/collapse. Slice 2 adds:
- Status badge on each layer row (using existing
StatusIndicatorprimitive):Draft,Queued,Generating,Generated,Stale,Failed,Locked,Missing. Color coding identical to Timeline clip status. - Layer kind icon: bitmap (imported), brush (painted), spark (generated), mask, group folder.
- Add-Generated-Layer menu at the top of the panel, alongside the existing
+ Layerbutton. Lists workflows taggedimage-templatefirst, then “All workflows” expander filtered torun_mode IN ("workflow", null)and terminating inImageOutputNode/MaskOutputNode.
5.4 Inspector
Three states:
- Imported layer selected —
ImportedLayerPanel: name, source asset, transform, opacity, blend mode, alpha lock, effects chain, output handle toggle. Actions: Replace Source, Reveal in Library, Convert to Painted Layer, Convert to Generated Layer (prompted to pick a workflow that can accept this layer’s pixels via anImageInputNode). - Painted layer selected —
PaintedLayerPanel: name, transform, opacity, blend mode, alpha lock, effects chain. Actions: Convert to Generated Layer (turn this raster into the seed image of a chosen workflow’sImageInputNode), Save as Asset. - Generated layer selected —
GeneratedLayerPanel(Slice 2): header (thumbnail, name, type, status, model summary, timestamps);LayerNodeStack;NodePropertyEditorfor the selected node;LayerActions;VersionList. Direct visual port ofGeneratedClipPanel.
Selecting a node in the stack drives SelectedLayerNodeStore, which NodePropertyEditor reads. This mirrors the existing InspectedNodeStore → Inspector → PropertyField pattern and the Timeline’s SelectedClipNodeStore.
5.5 Status badges
| Status | Source | Display |
|---|---|---|
draft |
binding exists, no version yet | dim border, “Draft” |
queued |
job queued | spinner outline |
generating |
JobUpdate.status = running |
progress bar across layer thumbnail |
generated |
latest hash matches | normal |
stale |
hash mismatch | yellow badge “Stale” overlay on thumbnail |
failed |
JobUpdate.status = failed |
red badge, error tooltip |
locked |
user-set | lock icon |
missing |
layer’s last asset id no longer resolvable | gray placeholder thumbnail |
Badges use StatusIndicator and tokenized colors. Same source/display rules as the Timeline.
5.6 New affordance: Generate via Inpaint Here
When a user has an active selection and clicks Generate via Inpaint Here in the Adjustments / Layer menu:
- A new generated layer is created above the active layer.
- Its workflow is the seeded Inpaint template (cloned to
run_mode = "layer"). paramOverridesare pre-populated:image← rasterized snapshot of the layers below the new layer (clipped to selection bounds, with a small padding)mask← the current selection mask, exported via the existingselection/selectionMask.tshelpersprompt← empty (focus moves to it)
- The user types a prompt, hits Generate. The result composites with the current selection as a clip, so the generated pixels only land inside the selection.
This is the single most important Slice 2 affordance for the “Photoshop with AI” positioning. It is what turns the editor from “a layer panel with workflows” into “the natural place to do AI inpainting.” It costs almost nothing on top of Slice 2 because the selection mask, the rasterizer, and the Inpaint template all already exist.
6. Decisions on open questions
- Auto-replace on success. Successful generation replaces the layer’s
dataimmediately; previous version stays inversions[]. Locked layers do not replace. - Cloned workflow per layer, not embedded snapshot. Each generated layer points to its own
Workflowrow withrun_mode = "layer". Cloning happens at layer creation, regardless of whether the source was a curated template or a user’s standalone workflow. Editing one layer’s workflow does not affect others. This matches the Timeline’s choice. - Inspector exposes the bound workflow’s
Input*nodes. No parallel exposed-parameter declaration. The author of a layer-template workflow chooses what’s exposed by whichInput*nodes they place in the graph. - Document format. New
image_documenttable (decided). Documents themselves are not workflows in Slice 1+2; reserved asrun_mode = "image"for a future slice. - Layer pixel storage. Continue the existing
data: string | null(PNG data URL) approach used on the sketch-editor-rc branch. For generated layers, also storeimageReference.uripointing at the asset; the data URL is the cached decode. Documents larger than ~10 MB should be migrated to per-layer asset references in a future slice — flagged as a Risk in §12. - Variants. Stored in
versions[]on the layer (each version ={ jobId, assetId, hash }). Separate-layer variants are produced by Duplicate as Variation, which clones the layer’s workflow into a newrun_mode = "layer"row. - Render All / Flatten. “Flatten visible” already exists on the branch (CPU); add “Re-generate Stale Layers” command that runs the existing job pipeline for every stale layer in dependency order and shows a preflight dialog with Generate Stale / Skip Stale / Cancel. No timeline-style “Render All” export pipeline in Slice 1+2.
- Local vs cloud per node. Backend decides via existing provider routing; UI shows
Local/Cloud/Requires API keyindicators (same as Timeline). - Custom exposed parameters. Out of scope. The layer-template workflow author chooses exposure by which
Input*nodes are placed. - Multi-output layers. Out of scope. One
selectedOutputNodeIdper layer; multi-output workflows force a choice at layer creation. A future slice may introduce “satellite layers” for side outputs (e.g. one workflow run produces both an image and a depth map). - Workflow listing filter. Standalone workflow listings filter to
run_mode IN ("workflow", null)."layer"workflows are visible only inside their owning document. The Add-Generated-Layer menu queriesrun_mode IN ("workflow", null)and prefers entries taggedimage-template, with an “All workflows” expander to browse the rest. - Layer-workflow lifecycle. Deleting the last layer referencing a
run_mode = "layer"workflow deletes that workflow. Promoting via “Save as Reusable Template” flipsrun_modefrom"layer"to"workflow"and adds theimage-templatetag — the row becomes an ordinary standalone workflow. - Relationship to the existing per-asset image editor at
/assets/edit/:assetId. That stays. It is a single-image, single-layer editor for ad-hoc asset edits and does not need workflows. The new/sketch/:documentIdeditor is the multi-layer document editor. The existing in-node modal opened fromSketchNodecontinues to work and shares all components with the standalone shell. - Relationship to
SketchNodein the workflow graph. ASketchNodereferences animage_documentby id. Clicking “Edit” on the node either opens the standalone editor (/sketch/:id) or the modal — controlled by a per-user preference, defaulting to modal for one-handed quick edits and standalone for “open and stay” sessions. The dynamic per-layer input/output handles onSketchNode(exposedAsInput/exposedAsOutput) are unchanged.
7. Data model
type LayerStatus =
| "draft" | "queued" | "generating" | "generated"
| "stale" | "failed" | "locked" | "missing";
type LayerKind = "imported" | "painted" | "generated" | "mask" | "group";
interface ImageDocument {
id: string;
projectId: string;
workflowId?: string; // reserved for run_mode = "image" (future)
name: string;
width: number; // canvas px
height: number;
backgroundColor: string;
layers: Layer[]; // existing sketch-editor-rc Layer, extended (below)
guides: Guide[];
artboards: Artboard[]; // optional Slice 1+; multi-artboard reserved for future
activeLayerId: string;
maskLayerId: string | null;
createdAt: string;
updatedAt: string;
}
// Existing Layer from web/src/components/sketch/types/document.ts is extended with:
interface LayerWorkflowBinding {
kind: LayerKind; // replaces `type: "raster" | "mask" | "group"`; "imported" | "painted" | "generated" merge into the raster slot
workflowId?: string; // run_mode = "layer" for generated layers
selectedOutputNodeId?: string;
paramOverrides?: Record<string, unknown>; // keyed by Input-node name
dependencyHash?: string;
lastGeneratedHash?: string;
status: LayerStatus;
versions: LayerVersion[];
// Existing fields from the sketch-editor-rc Layer remain unchanged:
// id, name, visible, opacity, locked, alphaLock, blendMode, data, transform,
// contentBounds, exposedAsInput, exposedAsOutput, imageReference, parentId,
// collapsed, segmentationMeta, effects.
}
interface LayerVersion {
id: string;
createdAt: string;
jobId: string; // FK to existing Job model
assetId: string; // FK to existing Asset model (the generated PNG)
workflowUpdatedAt: string; // snapshot of workflow.updated_at at generation time
dependencyHash: string;
paramOverridesSnapshot: Record<string, unknown>;
costCredits?: number;
durationMs?: number;
status: "success" | "failed" | "cancelled";
favorite?: boolean; // pinned to prevent pruning
}
interface Guide {
id: string;
axis: "x" | "y";
position: number; // px in document space
}
interface Artboard {
id: string;
name: string;
x: number; y: number;
width: number; height: number;
}
The existing Layer.type enum on sketch-editor-rc ("raster" | "mask" | "group") is widened by introducing kind as the source of truth. A migration on document load splits the existing "raster" into "imported" (if imageReference != null), "painted" (otherwise with non-null data), or "generated" (if workflowId is set — only possible for documents created in Slice 2). New documents in Slice 1 only ever produce imported, painted, mask, group.
8. Curated layer templates (Slice 2)
Three ordinary Workflow rows (run_mode = "workflow") are seeded with the tag image-template so they surface in the Add-Generated-Layer menu by default. Authors publish their own layer templates by adding the image-template tag to any workflow they own. Untagged standalone workflows that produce image output are still selectable via the menu’s “All workflows” expander.
| Seeded workflow | Graph (Input nodes → processing → output) |
|---|---|
| Text-to-Image | StringInputNode("prompt") → TextToImageNode → ImageOutputNodeplus optional StringInputNode("negative_prompt"), IntegerInputNode("steps"), FloatInputNode("cfg"), SeedInputNode("seed"), LanguageModelInputNode("model"), ImageSizeInputNode("size") |
| Inpaint | ImageInputNode("image") + MaskInputNode("mask") + StringInputNode("prompt") + SeedInputNode("seed") → InpaintNode → ImageOutputNode |
| Background Remove | ImageInputNode("image") → RemoveBackgroundNode → ImageOutputNode (output is a transparent PNG; ideal as a layer above the source) |
When a user picks a template, the editor clones it into a new run_mode = "layer" workflow owned by the layer. No template registry; the menu is just a tag-filtered query against the workflow table. Future templates worth seeding (not in this PRD’s scope) are listed in §12 as candidates.
9. Performance targets
- Smooth interaction (60 fps where possible) at 4096×4096 with ≤30 layers on WebGPU; ≤2048×2048 with ≤15 layers on Canvas2D fallback. (The branch already meets these for painting; this PRD does not regress them.)
- Select layer: <100 ms UI response.
- Open document: editor visible <2 s; layer thumbnails hydrate progressively.
- Generation job creation: <500 ms.
- Inspector property edit: immediate local response; hash recompute <16 ms for typical layer.
- Document autosave: debounced 2 s, identical to Timeline.
- Document JSON cap before forced asset-extraction migration: 8 MB (warned), 16 MB (hard limit). See §12.
10. Acceptance criteria
The combined Slice 1 + 2 ships when:
- User can create a new image document and the editor opens at
/sketch/:idwith a default canvas (1024×1024 white) and one painted layer. - The Image Editor ModePill in
AppHeaderis highlighted on/sketch/*. - All 15 existing tools work in the standalone shell exactly as in the modal.
- User can drag assets from
AssetExplorerto create imported layers. - User can rename, reorder, group, hide, lock, alpha-lock, opacity-adjust, and blend-mode-change layers (existing functionality, exercised through the new shell).
- User can undo and redo across painting, layer ops, and (Slice 2) generation, with the existing history slice.
- User can add Text-to-Image, Inpaint, and Background-Remove generated layers from a layer-template menu.
- Selecting a generated layer opens the node-stack inspector; selecting a node shows its exposed params.
- Editing a property marks the layer stale; the canvas keeps showing the previous version with a stale overlay on the layer thumbnail.
- User can generate the selected layer, observe progress on its layer thumbnail, and see the new pixels composite immediately.
- The previous version is preserved in
versions[]and restorable. - “Open in Node Editor” navigates to
/editor/:workflowId?from=sketch:{documentId}:{layerId}. On return, the layer is automatically marked stale ifworkflow.updated_atadvanced; the inspector reflects any added/removedInput*nodes. - Failed generations show error UI on the layer and a Retry action; errors are attached to the failing node.
- Generate via Inpaint Here with an active selection produces a new generated layer pre-bound to the Inpaint template with the right image+mask seeded.
- Document autosave round-trips through the new tRPC router with optimistic-concurrency conflict detection.
- The existing in-node modal mode (opened from
SketchNode) continues to work; both shells render the same components. - No raw MUI imports anywhere in any new file under
web/src/components/sketch/SketchEditorPage.tsx,web/src/components/sketch/Inspector/*, or the new tRPC client surfaces. npm run checkpasses (typecheck + lint + tests) forpackages/image-editor/,packages/models/migrations,packages/websocket/(new tRPC router), andweb/.
11. Rendering
The image editor has two rendering pipelines. Both are simpler than the Timeline’s because the document is a single still image, not a time-axis-driven composition.
11.1 Canvas rendering (in-browser, real-time)
Goal: render the layer stack at the viewport’s zoom and pan, at interactive frame rates, with all paint and effect edits visible immediately.
Approach: Reuse the existing WebGPURuntime and Canvas2DRuntime from the sketch-editor-rc branch. No new compositor. The runtimes already implement:
- Per-layer texture upload with dirty-rect tracking.
- All 12 blend modes.
- The full layer-effects pipeline (brightness/contrast, hue/saturation, exposure, curves, tonemap, bloom).
- WebGPU device-loss fallback to Canvas2D.
- GPU mask compositing for selection ants and committed selections (added on the branch in May 2026).
The standalone shell mounts the same SketchCanvasPane that the modal uses. No additional rendering work is required for Slice 1.
For Slice 2 (generated layers), rendering does not change. A generated layer is a normal raster layer once it has data. Until it does, the runtime composites it as a placeholder (transparent with a “Draft” tile pattern, an extension to Canvas2DRuntime’s placeholder rendering already used by imageReference.uri not-yet-loaded layers).
11.2 Export rendering (flatten + save / send to workflow)
Goal: produce a final PNG (or JPG, WebP) that matches the canvas, plus the existing per-layer export the SketchNode already does.
Strategy: the editor does not ship its own multi-layer export pipeline beyond what the branch already has. The existing flattenVisible() and flattenDocument() (in documentSlice and Canvas2DRuntime’s maskAndExport.ts) cover Slice 1+2:
- Save / Download: flatten visible layers → PNG. Already implemented on the branch.
- Save flatten as Asset: flatten → POST to existing asset endpoint. Already implemented on the branch.
- Per-layer export to
SketchNodeoutputs: existing path. Unchanged. - Mask export: existing
exportMask()writes the designated mask layer. Unchanged.
A future slice can add a graph-compiled export (analogous to the Timeline’s compileExport.ts) that re-runs every generated layer through WorkflowRunner to produce a fully reproducible flat output from current parameters rather than from cached pixels — useful for “lock the final after parameter sweeps” workflows. Not in scope here.
Constraints flowing back into the data model: the LayerVersion.assetId must be a real asset row (not just a data URL) so that future graph-compiled exports can reference the same asset that was used in the canvas. The branch already stores generated bitmaps as data URLs in Layer.data; Slice 2 must additionally upload each generation’s output to the asset table and record assetId. This is one of the few non-trivial new pieces of code (~80 lines, in the success branch of step 5 in §4.4).
12. Risks and mitigations
- Document JSON growth. PNG data URLs for many large layers can blow past SQLite’s recommended row size. Mitigation: cap the document to 8 MB warned / 16 MB hard; for any layer larger than 1 MB, store its bitmap as a separate asset row and reference by
assetIdinstead of inlining a data URL. The branch already hasimageReference.urifor this; Slice 1 must extend autosave to externalize oversized layers. - Workflow run-mode collisions. Adding
"layer"and reserving"image"inrun_moderequires the standalone listing filter to be tightened consistently. Mitigation: introduce a typed enumWorkflowRunModeinpackages/protocol/and update every list/filter call in one PR before this work begins. - Sketch editor not yet merged to main. This PRD assumes
sketch-editor-rclands first. If the merge is delayed, Slice 1’s “promote to top-level” depends on a moving target. Mitigation: merge sketch-editor-rc to main in its current modal-only state first; Slice 1 of this PRD is then additive and small. - WebSocket message volume during generation. Mitigation: subscribe per-layer’s active job id; drop all other traffic at the sketch-store boundary. Same approach the Timeline took.
- Graph-structure drift on Open in Node Editor round-trip. Mitigation: on return, diff node ids; if
selectedOutputNodeIdis gone or no longer anImageOutputNode/MaskOutputNode, force a confirmation dialog before any subsequent generation. - Reuse boundary slipping. Mitigation: PR review checklist enforces “no parallel canvas/inspector/store code outside
web/src/components/sketch/”, “no raw MUI”, “stores via selectors withshallow”. - Naming confusion: sketch vs. image vs. asset editor. Three editors, three concepts. Mitigation: docs page (
docs/image-editor-prd.mdfor this PRD; a follow-up todocs/image-editor.mdto disambiguate the per-asset modal at/assets/edit/:assetId); ModePill label is “Image Editor”; route is/sketch/:documentId(matches code dir); type/table isimage_document. - Inpaint quality / model availability. The seeded Inpaint template must not require a paid API key out of the box. Mitigation: pick a default that runs on the bundled local stack (e.g. an SDXL-Inpaint variant via the existing
runtimeproviders), with an opt-in cloud upgrade path through the existing provider routing.
13. Implementation phases inside this PRD
- P0 — Prerequisites (not part of Slices 1/2 but blocking).
- Merge
sketch-editor-rcto main as-is (modal-only). Theimage-templatetag is unused but harmless. - Introduce
WorkflowRunModeenum and tighten standalone-listing filter torun_mode IN ("workflow", null).
- Merge
-
P1.A —
packages/image-editor/types, dependency hashing, seed-template definitions, tests. No UI. -
P1.B —
image_documentschema, ORM class, tRPC router (sketch.list/get/create/update/deleteandsketch.versions.*), autosave hook. Seed threeimage-template-tagged workflows in a migration. -
P1.C —
SketchEditorPageshell, route registration, ModePill, “Return to Sketch” pill, per-user preference for modal-vs-standalone open fromSketchNode. Connects existing components with the new shell. No new tools. -
P1.D — Imported and painted layer flows wired to the new persistence layer, undo/redo across save boundaries, asset-extraction for oversized layers. No generated layers yet.
-
P2.A —
tRPC sketch.layers.{create, delete, duplicate}(clone-on-create withrun_mode = "layer", delete-cascade, linked/variation duplicate, template promotion via tag +run_modeflip). Direct retargeting of the Timeline’sclips.*procedures. -
P2.B — Inspector node-stack and
NodePropertyEditorreading the bound workflow’sInput*nodes; dirty/stale via dependency hash incl.workflow.updated_at. -
P2.C — Generate / regenerate via
WorkflowRunner.run(workflow, paramOverrides); status propagation through existingStatusStore/ResultsStore/ErrorStorekeyed by jobId; per-layerLayerVersionappend with asset upload. - P2.D — Versions (jobId+assetId per version), restore, Duplicate-as-Variation, Duplicate-Linked, Lock; “Open in Node Editor” round-trip and stale-on-return behavior; “Generate via Inpaint Here” command.
Each phase ends with passing npm run check and a self-contained PR.
Appendix A — Mapping to the Timeline PRD
For reviewers familiar with docs/timeline-editor-prd.md, this PRD is a near-line-for-line translation. The substitution table:
| Timeline | Image Editor |
|---|---|
TimelineSequence |
ImageDocument |
TimelineTrack |
Artboard (optional, multi-artboard reserved) and layer groups (existing) |
TimelineClip |
Layer (extended with LayerWorkflowBinding) |
ClipVersion |
LayerVersion |
run_mode = "clip" |
run_mode = "layer" |
run_mode = "sequence" (reserved) |
run_mode = "image" (reserved) |
dependencyHash |
dependencyHash (same function) |
currentAssetId |
Layer.data data URL + imageReference.uri asset ref |
selectedOutputNodeId (must be media output) |
selectedOutputNodeId (must be ImageOutputNode or MaskOutputNode) |
WorkflowRunner.run(workflow, paramOverrides) |
unchanged |
GlobalWebSocketManager job subscriptions |
unchanged |
TimelineGenerationStore |
SketchGenerationStore |
SelectedClipNodeStore |
SelectedLayerNodeStore |
GeneratedClipPanel |
GeneratedLayerPanel |
NodeStack |
LayerNodeStack |
timeline-template tag |
image-template tag |
/timeline/:sequenceId |
/sketch/:documentId |
?from=timeline:{sequenceId}:{clipId} |
?from=sketch:{documentId}:{layerId} |
timeline_sequence table |
image_document table |
packages/timeline/ |
packages/image-editor/ |
| Slice 3 export (compile to ffmpeg graph) | Future slice (compile to image-op graph) |
Anywhere this PRD is silent, the Timeline PRD’s decisions apply.
Appendix B — Why this approach
A few places this PRD makes a call worth flagging:
- Layers as workflow-backed first-class objects, not a separate “smart layer” overlay, because that is exactly the choice the Timeline made for clips, and the choice has been validated by tRPC, dependency-hash, version-pruning, and round-trip code that already exists in production. Re-implementing the same pattern with different names (e.g. “smart object”, “AI layer”) would split future maintenance.
- Reuse the sketch-editor-rc canvas wholesale rather than adopting a different image library, because the branch already shipped 88k lines including WebGPU compositing, 12 blend modes, the full effects chain, and 94 tests. Replacing it would be the most expensive single thing the team could do, for no user benefit.
run_mode = "layer"instead of generalising to"embedded", because the Timeline already hard-codes"clip"in queries and lifecycle code; following the same precedent (one run-mode per host editor) keeps the listing/filter logic boringly explicit. If a third editor ever embeds workflows, we generalise then.- No per-layer separate canvas thumbnails store, because the existing
Layer.dataPNG data URL already serves as a thumbnail when downsampled in the layers panel. Adding a parallel thumbnail asset only matters once layers go above 1 MB, at which point we externalise the layer to an asset anyway (§12). - Inpaint-Here is a Slice 2 feature, not Slice 3, because it is the headline user value of the editor and costs maybe a day on top of the other Slice 2 work. Pushing it to a later slice would dilute the launch.