One plane is the editor. The other is optional.

Everything inside the solid boundary runs in your tab. Cut the dashed plane off entirely and what's left is still a complete, working image editor — that isn't a fallback mode, it's the demo everyone gets.

Show the architecture for one kind of user

Everything, including the parts that aren’t built yet.

System map

Browserno server in the edit path

UI plane

React 19 · TypeScript · Vite

  • AppShell — composition and layout only
  • Tool registry — 5 modules registered, routing still in AppShell
  • Session hooks — image · selection · canvas · mask
  • Zustand stores — UI · tool · gallery · annotation · guides

Compute plane

where the pixels are touched

  • Engine worker — the engine and the canvas both live here, off the main thread
  • WASM engine — stamp_tool: kernels, TileBuffer, OpLog
  • Codec worker — WebP/JPEG encode · gallery thumbnails
  • rayon worker pool — tried and dropped: 8–31× slower than the single-threaded kernel
  • WebGPU backend — one blur kernel and a self-test, nothing on the pixel path

Persistence plane

IndexedDB · Dexie

  • originals — content-addressed, immutable
  • opLogs + keyframes
  • gallery manifest
  • renderCache — disposable

Plus localStorage for lightweight prefs. A service worker that precaches the shell and the WASM binary is written and tested, and switched off — it has never been on in a build that shipped.

Networkoptional, additive only

Clerk

identity

Convex

prefs sync · entitlements · gallery

AI proxy → Replicate

the server holds the provider key

Share delivery

ShareViewer

What's in each plane

Client layer — runs entirely in the browser

React App

Vite + React 19 · Netlify static SPA

Canvas Engine

Engine worker · zero-copy blit to an OffscreenCanvas

Zustand State

7 stores · atomic selectors

WASM processing layer — client-side Rust, one binary

core · layer

ImageBuffer · layer stack · composite / mask

paint · effects

Brush / eraser / mask · blur / pixelate / redact

annotations · selection

Live text & shape overlays · magic-wand

stamp · transform

Clone brush · flip / rotate / resize / crop

filters

Brightness · contrast · gaussian blur

drawing · text

Arrows / shapes / bézier · embedded fonts

codec · history

PNG encode (Rust) · undo snapshots

simd

v128/f32x4 kernels · scalar fallback

utils

json · point math · shared helpers

Identity — Clerk

Clerk Auth

identity provider

Demo

anonymous · 12 photos

Free

signed in · 24 photos

Pro

100 photos · coming soon

Convex functions — signed-in only

photoEdits.ts

save / getEdit

ai.ts

dispatch to Replicate

aiJobs.ts

job status (useQuery)

shares.ts

public share links

textHistory.ts

recent texts

stripe.ts

checkout / portal

Storage

IndexedDB (Dexie)

Originals · SHA-256 content-addressed · your machine

Convex File Storage

Edit archives · shares · AI frames · a server

AI — Replicate, Pro only

cjwbw/rembg

background removal

abiruyt/text-extract-ocr

text extract

zylim0702/remove-object

object removal (masked)

Real-ESRGAN

4× upscale

soon

Event handlers — webhooks

Stripe webhook

subscription changes → subscriptions

Replicate webhook

AI complete → ai_jobs.status

Clerk sign-in isn't a webhook here — the client calls users.upsert once Convex's own auth bridge comes up, and that is what actually creates the users row.

Convex database schema

Every table, its fields and its indexes. Flatter than a typical projects → images tree: each row hangs straight off users, keyed by the client's own photoKey string rather than a server-side image id.

users

PK_id
Id<'users'>
UQclerkIdidx
string
emailidx
string?
name
string?
avatarUrl
string?
tier
'free' | 'pro' | 'team'
dailyUsage
number
usageResetAt
number
settings
string?JSON blob, app prefs
settingsHash
string?SHA-256, skips redundant writes
createdAt
number
updatedAt
number

by_clerkIdby_email

subscriptions

PK_id
Id<'subscriptions'>
FKuserIdidx
Id<'users'>
stripeCustomerIdidx
string
UQstripeSubId
string
plan
'pro' | 'team'
status
'active' | 'canceled' | 'past_due'
currentPeriodEnd
number
cancelAtPeriodEnd
boolean

by_userIdby_stripeCustomerIdby_stripeSubId

photo_edits

PK_id
Id<'photo_edits'>
FKuserId
Id<'users'>
photoKey
stringthe editor's own photo id
FKstorageId
Id<'_storage'>binary canvas archive
canvasW
number
canvasH
number
updatedAt
number

by_userId_photoKey

Real per-photo edit persistence path (useEditPersistence.ts)

recent_texts

PK_id
Id<'recent_texts'>
FKuserId
Id<'users'>
text
string
fontSize
number
fontFamily
string?
fontWeight
'normal' | 'bold'
textColor
string
usedAt
number

by_userIdby_userId_usedAt

Text-tool history, per signed-in user

shares

PK_id
Id<'shares'>
tokenidx
stringunguessable, public lookup key
FKuserIdidx
Id<'users'>
FKstorageId
Id<'_storage'>flattened canvas PNG
canvasW
number
canvasH
number
title
string?
views
number
createdAt
number

by_tokenby_userId

Public, no-auth read — anyone with the link can view or download

ai_jobs

PK_id
Id<'ai_jobs'>
FKuserIdidx
Id<'users'>
photoKeyidx
string
type
'rembg' | 'upscale' | 'inpaint' | 'ocr' | 'alt'
status
'pending' | 'running' | 'done' | 'failed'
replicateIdidx
string?
inputStorageId
Id<'_storage'>?
maskStorageId
Id<'_storage'>?inpaint mask
outputStorageId
Id<'_storage'>?
output
JsonValue?non-image result, e.g. OCR text
error
string?
startedAt
number?
completedAt
number?
createdAt
number

by_userIdby_userId_photoKeyby_replicateIdby_status

Keyed by photoKey, not an images row — the Replicate webhook updates status

Entity relationships

  • users 1 ─ 1 subscriptions
  • users 1 ─ ∞ photo_edits
  • users 1 ─ ∞ recent_texts
  • users 1 ─ ∞ shares
  • users 1 ─ ∞ ai_jobs

Real-time

useQuery hooks auto-update when data changes. No polling.

Row-level auth

ctx.auth in mutations, plus query filters for user-scoped data.

File storage

Integrated blob storage for images via storage.getUrl().

Webhooks

Replicate + Stripe post back to convex/http.ts, HMAC-verified.

Why draw it this way

Because the boundary is the product. An editor that needs a server is an editor that can be switched off, rate-limited, or quietly trained on. The dashed plane buys you sync, sharing, and the AI passes — and it is the only place a photo of yours can travel. Demo mode never crosses it.

The map above is drawn by hand from system-architecture.mermaid, the flowchart the repo ships. Take the original and render it wherever you like — Mermaid Live, a VS Code preview, your own docs.

system-architecture.mermaid · Mermaid flowchart TB · 79 lines · the dashed subgraph is the plane you can cut.