Appearance
Designer
Verified against v1.3.0
The Designer is the native canvas design editor at /media/designer. It is built on react-konva (Konva.js) and reads input from and writes output to the Files library; it never stores its own assets outside /files.
DesignerDoc contract (server-authoritative)
The DesignerDoc JSON is the agent interface, and the server owns it. There is one zod-based source of truth in libraries/nestjs-libraries/src/media/designer-doc/designer-doc.schema.ts; the frontend store and the server renderer both import type from it, while the dependency-free runtime helpers (migrateDoc, createBlankDoc, limits) live in designer-doc.migrate.ts and stay out of the client bundle. The contract discriminates on mode (image ⇒ outputs: DesignerOutput[], video ⇒ outputs: VideoOutput[]; an image output's children is page 1 and optional pages[] hold further pages — video outputs reject pages) and carries version + migrateDoc for forward-compatible upgrades.
DesignerDocService provides validate (lenient, clamps out-of-shape docs), validateStrict (rejects unknown keys), applyOps (mode-aware document transforms), and assignIdsAndNormalize (CSPRNG id minting across children/tracks/clips). DesignService validates every persisted design and template, reconciles Design.width/height from doc.outputs[0], and exposes instantiateTemplate and placeAsset for agent use.
Frontend architecture
All components live under apps/frontend/src/components/media-tools/designer/.
designer.store.ts— a per-mount Zustand store created bycreateDesignerStore(w, h, attribution, fetch?). No module-level singleton (it resets on unmount). It holds the sharedDesignerDoc(mode+outputs[], one output per format; elements aretext | image | shape | icon | path | raster | group | fill | adjustment | symbol), plusselectedIds,currentOutput,currentPage,zoom/viewport, labelled undo/redohistory,clipboard,editFormatOnly(linked vs format-only edits), andplayheadMs/selectedClipfor video mode.canvas.tsx— the KonvaStage. Handles multi-select (shift/⌘ + marquee), group-aware selection, snapping/alignment guides, a customTransformerwith a dimension HUD, wheel-zoom, space-drag pan, and drag-and-drop drops from panels (payload keyapplication/x-designer-element).actions.tsis the single registry behind the menu bar (menu-bar.tsx) and the ⌘K command palette.elements.tsx/layer-render.tsx— element renderers (text/image/shape/…), gradient fills, crop, flip, layer effects (shadow, glow, stroke), and entrance animations.- Panels (
panels/) — templates (with org template folders), my designs, layers, pages, version history, icons, brand, AI (gated on an active org AI provider viauseAiActive), plus the per-type inspectors. controls/— reusable control primitives.fonts.ts— curated OFL fonts +ensureFontLoaded; org custom fonts are uploaded throughPOST /media/fonts/upload(listed byGET /media/fonts, removed byDELETE /media/fonts/:fileId).video-timeline.tsx— the video-mode track/clip timeline; MP4/WebM/GIF/animated-WebP exports render server-side throughPOST /media/designs/render-video.export-dialog.tsx— PNG / JPEG / transparent-PNG / WebP / PDF / SVG plus animated formats, high-respixelRatio, multi-output export, and a create-post flow (export → one draft per selected channel → the composer). Exports are saved to the Files library and the done step also offers per-file download links.collaboration.ts— opt-in real-time collaboration (useCollaboration): a Yjs document synced over the backend's/collaborationWebSocket (apps/backend/src/services/collaboration/collaboration.gateway.ts), with peer cursors fromcollaboration-cursors.tsx.
Cross-origin canvas
Konva's toBlob/toDataURL taints on cross-origin images. Element images load with crossOrigin="anonymous"; for object storage you must enable CORS (see the operations storage guide) or route through the same-origin image proxy (GET /media/designer/proxy).
Backend
Layering is Controller → Service → Repository (only repositories touch Prisma).
- Models:
Design,DesignVersion(version-history snapshots of the whole doc; restore takes anautosafety snapshot first),DesignTemplate,DesignTemplateFolder(org folder tree for templates; nullableDesignTemplate.folderId= unfiled). - CRUD:
DesignController/DesignTemplateController—/media/designs,/media/design-templates. Version history lives under/media/designs/:id/versions(list/create,GET/DELETE /:vid,POST /:vid/restore); template folders under/media/design-templates/folders(CRUD) withPUT /media/design-templates/:id/move.DesignerProxyController—GET /media/designer/proxy(org-bound,safeFetch, fail-closed on non-image, size-capped). - Server-side render (
DesignRenderService, node-canvas):POST /media/designs/render→ PNG or PDF (pdfkit). Text blocks auto-fit and honorverticalAlign; a curated-font load failure retries after a 15-minute TTL (font-loader.service.ts) instead of blacklisting the family for the process. Bulk generation (DesignBulkService):POST /media/designs/bulk-generatesubstitutesper row and renders a batch. Both endpoints validatebody.docbefore rendering. - Video render (
VideoRenderService):POST /media/designs/render-videoenqueues a timeline render;GET /media/designs/render-video/:jobIdreturns status/artifact. Gated onmedia:createandvideo-exports:create. - Document ops / validation:
POST /media/designs/validate(lenient,media:read) returns{ valid, errors? };POST /media/designs/apply-ops(strict,media:create) returns{ doc }after applying a strict-parsed op sequence. Two op behaviors changed with the AI Designer release:updateElementaccepts an optionalscope('shared'propagates the patch to linked elements across outputs,'format-only'/absent keeps the previous single-element behavior), andaddOutputseeds the new output from the primary output's children (reflowed;originIdbackfilled on the primary) instead of appending an empty white canvas — matching the manual Designer's linked-by-default behavior.addOutputalso accepts an optionalseed: falseto keep the pre-change empty-canvas behavior, and appends an unseeded canvas when the doc has no outputs at that point in the sequence (e.g. afterremoveOutput). - AI ops (
AiUserController, throttled, org-authenticated, credit-checked viaAiMediaService):POST /ai/mediawith operationsremove-background,inpaint,upscale,image,video,audio,avatar,bg-remove,tts,stt,alt-text. These delegate toAiMediaService, which routes through the per-org Media provider system. The Designer's generate dialogs pick a provider fromGET /media/generation-providers?operation=(enabled + credentialed providers for that generation type plus the org's resolved default;media:read), and its editor AI actions also callPOST /media/outpaint,POST /media/video-segment-transformandPOST /media/voiceover-script. - Agent seam:
/copilot/agentnow carries the actinguserin the MastrarequestContextso user-attributed tools can fillcreatedById. ThedesignerDesignMastra tool (libraries/nestjs-libraries/src/chat/tools/designer.design.tool.ts) creates/updates designs from aDesignerDoc, template, or op sequence and persists an image preview whenmode === 'image'. - Doc changes need no migration.
Design.docandDesignTemplate.docare PrismaJsonfields; theDesignerDocschema is enforced at the service boundary (DesignVersionandDesignTemplateFolderare additive tables with their own migrations). - Brand kit:
AIBrandProfile.logoFileIds/palette/fontFamiliesare read/written through the brand profile API.
AI Designer chatbot (/media/ai-designer)
The AI Designer is a chat-first, server-rendered design assistant built on top of the Designer foundations. Sessions are single-mode: an all-video-preset channel selection derives format: 'video' on the session (mixed image+video selections are rejected). Video sessions compose and QC with the image pipeline, then the conductor converts each delivered doc to a video-mode doc (util/image-doc-to-video.ts) and, when the org has a video default, generates a scene video per variant's primary output that lands on a video track above the converted still.
- Realtime transport: bespoke Socket.IO namespace
/ai-designerregistered inapps/backend/src/main.ts. Cookie JWT + CSRF handshake, session rooms (session:<id>), monotonicseqordering, clientack, and Redis-backed nonce idempotency for every mutating event (start/message/form:submit/accept:plan/revise); a nonce consumed by a rejected operation (budget/guardrail/limit) — or by a handler that fails unexpectedly, which also emitsinternal_errorwith the nonce — is released so a retry with the same nonce isn't locked out for the TTL. The gateway also enforces per-user rate limits on every mutating event (buckets keyed by user id, so opening fresh sockets doesn't reset the window) plus a per-IP budget on connection attempts (checked before any JWT/DB work — neither the HTTP throttler nor per-event limits cover the handshake itself — and keyed on the transport address: the forgeableX-Forwarded-Forheader is never trusted, matching the HTTP throttler's posture), caps stored sessions at 100 per (org, user), re-checks membership/RBAC/billing every 60s for long-lived sockets (theNOT_SECUREDdev toggle — honoured only whenNODE_ENV=development, like every otherNOT_SECUREDrelaxation — bypasses only the handshake CSRF check, never RBAC or billing), validates session ownership before any handler-side write, boundsform:submitvalues (32 KB serialized / 5 levels deep), and runs all user free-text — chat messages, form values (strings at any nesting depth), and revise instructions — through the org's input guardrail chain (@reaatech/guardrail-chainvia the governanceGuardrailService) exactly once, at the gateway, before it is persisted or dispatched. Errors are emitted as{ code, message, nonce? }; progress as{ kind: 'progress', agent, phase, pct, note }; user-message echoes carry the clientnoncefor optimistic reconciliation. Deployment note: the gateway uses Socket.IO's in-memory adapter and the conductor keeps its pipeline mutex / prompt correlation / circuit breakers in-process — run the backend as a single instance (or behind sticky sessions) until a Redis adapter + shared stores are added. SettingCOLLAB_SINGLE_INSTANCE=falsewithoutCOLLAB_REDIS_ADAPTERemits a startup warning for both/collaborationand/ai-designer.- Reverse proxies: by default (
TRUST_PROXY_HOPSunset) the connect-rate bucket keys on the socket peer address, so all clients behind a reverse proxy share one bucket. SetTRUST_PROXY_HOPSto the exact number of XFF-appending proxies to key the bucket on the Nth-from-right entry ofX-Forwarded-Forinstead — overestimating lands in attacker-controlled left-most XFF entries and makes the buckets spoofable. The same setting also drives the HTTP throttler's per-IP buckets (login/register/enterprise/public-report) and the MCP rate limits. - Stuck sessions: if a session is in
planningorexecutingand untouched for longer thanAI_DESIGNER_STUCK_SESSION_MINUTES(default 15), reconnecting rolls it back toawaiting_planso the user can retry.
- Reverse proxies: by default (
- Session model:
AiDesignerSession+AiDesignerMessage(additive Prisma tables). State machine:intake → planning → awaiting_plan → executing → delivered → revising. Sessions are pruned by the dailyretention-purgecron afterAI_DESIGNER_SESSION_RETENTION_DAYS(default 90; messages cascade), and users can delete their own viaDELETE /ai-designer/sessions/:id.deleteUsertears sessions down explicitly (theuserIdFK is RESTRICT). Intermediate files minted for a session (asset imports stampedmetadata.aiDesigner = 'asset'+sessionId; reference imports stamped'reference') are hidden from the default/fileslisting and soft-deleted on session delete/retention purge unless a delivered design doc still references them. - Agents: six in-process agents registered via
agent-meshv-next (libraries/nestjs-libraries/src/ai-designer/agent-mesh/). The agent registry is bundled TS data (agent-registry.data.ts, validated againstAgentRegistrySchemaat boot) — not a YAML asset, which would not survivenest build— withAI_DESIGNER_AGENT_REGISTRYas an optional directory-of-YAML override (mapped to the package'sAGENT_REGISTRY_DIRat import time). The mesh session/breaker stores default to Redis;AI_DESIGNER_MESH_STORE=postgresopts into the package's Postgres stores (runs its own DDL on a second connection pool — deliberately not the default, and it requires a dedicatedAI_DESIGNER_MESH_DATABASE_URL: never the PrismaDATABASE_URL, where third-party DDL would fail the CI schema-drift gate).agent-mesh-env.shim.ts(first import in every agent-mesh-importing file) tames the package's import-time env handling: it seeds placeholderGOOGLE_CLOUD_PROJECT/API_KEY(Postmill uses only the in-process transport; the package's LLM classifier is never invoked), forces the package's global circuit breaker off (it is keyed by agent id only, so one tenant's provider failures would open it for every org — the conductor's per-(org, agent)breaker is the only breaker; the flag isz.coerce.boolean(), so the empty string is the only disabling value), and stash-and-restores unrelated env values the package's strict schema would otherwiseprocess.exit(1)the backend on (e.g.LOG_LEVEL=verbose,NODE_ENV=staging, a malformedOTEL_EXPORTER_OTLP_ENDPOINT). The restore is synchronous with zero exposure window:agent-mesh-env.stash.tsstashes, the shim then imports the mesh package (whose env parse runs at import), and restores in the same tick — no other module ever evaluates with the stashed vars deleted. Mesh module setup is non-fatal: a failure degrades AI Designer with a logged error, never blocks backend boot.- Conversationalist — elicits intent conversationally (extracting brief fields every turn;
intentmust keep the user's concrete specifics verbatim — event names, offers, schedules — and verbatim copy like coupon codes lands infixedCopy) and parses natural-language revisions. Once the brief is complete it recaps what it understood (includingfixedCopy) exactly once and waits for an explicit green light — the recap is enforced deterministically via the server-ownedbrief.recapShownflag (not by the classifier), so planning never starts before the user confirms the recap. - Art Director — routes the brief through a skill registry (
skills/design-skill.registry.ts: the meme, advertisement, greeting-card, product-promo and announcement genres, a reference-clone meta-skill that wins whenever the brief carries reference cues, and commerce/announcement/social-native/authority/social-proof/seasonal/vertical skill families); each skill carrieslayoutHints(formatTemplates + slotSchema), richer prompts, andrequiredBriefFieldswired into intake, and a low-confidence route asks the user to pick a skill (brief.preferredSkill) instead of planning silently. Emits plan-schema-v2DesignPlan[]variants — plan/slotstyleId, slot kinds (text/image/cta-button/badge/accent-shape), and per-slottexts(the actual headline/subhead/CTA/badge copy, written to the skill's copy rules and naming the brief's real event/offer;brief.fixedCopyappears verbatim) — varying style presets across variants unless the user picked one. Plans describe the primary format only (first selected channel, else first custom size); other formats are adapted later by the conductor, not planned. Custom sizes are keyed uniformly ascustom-${w}x${h}, and a custom-sizes-only start is allowed. - Copywriter — fills text slots per plan/brand voice. Slots the user approved on the plan card arrive as
lockedTexts: returned verbatim and never rewritten; only open slots are written (all-locked skips the model call entirely). - Asset — generates/stocks imagery per output aspect (
slotId:aspectkeys; square/wide/tall size mappings for gpt-image and replicate/flux, stock orientation passthrough, layout-aware prompt suffixes that reserve space for copy); every generated-image prompt also carries a "no text, no words, no letters, no typography, no watermark, no logo" suffix so assets never bake in copy (stock searches don't get it). When the session has a reference image, the conductor stamps it onto background/hero needs (sourceFileId/sourceUrl) and the agent tries the org'smedia/image-to-imagedefault first, falling back image-to-image → text-to-image → stock (only transient failures earn a retry); a run cannot start without amedia/text-to-imagedefault (AiDesignerDefaultsGate). Any stock stand-in is disclosed in the delivery notes unless stock was deliberately requested; the last resort is a generated gradient fallback. - Composer — builds validated
DesignerDocOp[]using server-sideseedCopy/smartReflow/applyLinked; honors the plan's style preset (palette, curated font pairing,typeScale, treatments) and brand fonts through a 6-template layout gallery (hero-fullbleed,split-panel,top-bottom,badge-burst,editorial-sidebar,minimal-centered; older ids alias in) and renders CTA slots as shape+text buttons. Imagery is always edge-to-edge (full-bleed or full-width/full-height bands —minimal-centeredrenders its image as a top band, never a floating framed inset). Per-role type floors keep copy legible at feed-thumbnail size (headline ≥ 6%, subhead/body ≥ 3.2%, badge/CTA ≥ 2.8% ofmin(w,h), clamped up in_typeScalePx), and badge/burst labels auto-fit inside their shape's inner safe area (centered both ways, never spilling). It composes the primary format only; the conductor then seeds each remaining format withaddOutput(linked to the original via sharedoriginIds) and re-resolves per-aspect assets (applyPerOutputAssets).applyFixesaccepts an optionaltargetOutputslist that pins every fix to one format and forcesformat-onlyscope, so per-format critique fixes never propagate onto other outputs. Its revise-op vocabulary coversfontFamily/align/verticalAlign/textStroke/textShadow/addElement/removeElement/setOutputBackground(the revise prompt summarizes each output's current background, and the op replaces an image background when the instruction asks for a color/gradient), and a shared-scopefontSizefix is scaled proportionally per output. After compose and afterreviseByInstruction/applyFixes, a conservative font-size clamp shrinks any flat text whose estimated wrap would overflow its box (same 60%/8px floor as the renderer's own shrink-to-fit, logged when it engages), and a deterministic overlap guard separates text-on-text collisions and re-clamps text spilling outside its containing shape (nudge/shrink with a logged degradation note — it never throws). Uses@reaatech/structured-repair-coreto coerce raw LLM ops and falls back to a safe layout. - Vision Critic — holistic contact-sheet critique with tiered escalation to full-res per-output review; also interprets reference/brand images. Reviews the original before variants are seeded (the beauty gate: up to
MAX_QUALITY_PASSES= 3 passes, the last one critique-only; a variant still flagged for an aesthetic criterion is held back unless it is the only result), then reviews each seeded format variant separately (up to two critique→fix passes per format). Every skill's rubric gets base criteria appended centrally:text_fit(no text overflows its band or the canvas edges),no_baked_in_text,no_framed_imagery,feed_legibility(each output is listed with its concrete 25% feed-scale pixel size),text_accuracy(checked against the expected copy per slot, passed asplans[].texts), andtext_alignment(text centered within its containing shape, no text-on-text collisions, scrim over busy regions).
- Conversationalist — elicits intent conversationally (extracting brief fields every turn;
- Style presets (
styles/): a registry of 8 presets — bold, editorial, minimal, neon, retro, neobrutalism, corporate, refined — each with palette sets, curated font pairings (families must come from the renderer's curated catalog),typeScale, and treatments. The start form has a Style select ("Let AI decide" default); unpicked, the Art Director varies styles across variants. - Conductor (
ai-designer-conductor.service.ts) runs a deterministic first-design pipeline and an agentic revise loop. The plan message is multi-select with accept-all as the default, and each plan's copy slots render as inline-editable inputs — the user fixes the headline/CTA/badge text before approving, andaccept:plancarries the edits (validated server-side against the stored plans: known variantIds and copy-slot ids only, bounded, guardrail-checked) so execution renders exactly the approved copy; every accepted variant executes (plans are never padded with identical duplicates) — the per-variant compose, quality and format-expansion loops run with bounded concurrency (VARIANT_CONCURRENCY= 3, order-preserving, per-variant failure isolation) — plan-stage revise works fromawaiting_planand mergesactiveDesignIds. Backgrounds and focal-point imagery cover-crop per output instead of warping, and asset/composer degradation notes are surfaced to the user in a single chat message. Every agent dispatch carries aBudgetServicecheck, a per-dispatch timeout (AI_DESIGNER_AGENT_TIMEOUT_MS, default 120s), and a per-(org, agent)circuit breaker that half-opens after 60s with a 10-minute failure-count window (one tenant's broken provider never disables other orgs, and stale failure counts decay/prune). Plans shown to the user are persisted on the session brief (lastPlans) so accept executes exactly what was presented; server-owned brief keys (lastPlans,lastDeliveredDesignIds,skillId,pendingReviseTarget,questionsAsked,referenceCues) are stripped fromform:submitvalues before the brief merge (conductor/brief-values.ts, which also drops delivery-form control values likeaction/variantIdso they never pollute the persisted brief), the executed plan list is clamped to the session's requestedvariants(≤10) regardless of what the stored brief contains, plan-requested asset generation is capped at 8 deduped asset needs per run, vision-critique findings are capped at 10 per pass (plans/critiques are LLM-shaped JSON — the caps bound the image-generation and note-fix LLM fan-out; typed fixes without a target slot are skipped rather than applied output-wide), and the persisted brief is bounded (64 KB serialized, slidingquestionsAskedwindow). A per-session mutex rejects concurrent pipelines across all phases (intake/planning as well as accept/revise);cancelaborts the in-flight pipeline via a per-sessionAbortController(checked at every step boundary and raced against in-flight dispatches — a cancel never trips the circuit breaker, and a cancelled planning run can never writeawaiting_planafterwards).accept:planexecutes only fromawaiting_plan, a vision-critique failure degrades to delivering the un-critiqued variants (rendered work is never rolled back), and other failures roll the session back to a recoverable state with a sanitized chat message. The composer returns docs without persisting —AiDesignerSaverServiceis the singleDesignwriter (revise re-saves update the sameDesignrow rather than orphaning one per fix pass). A "change the background" revise on an image-backed design is classifiedkind: 'imagery'and regenerates the background image (the plan's image background, else the primary output's full-bleed hero image element) through the asset agent rather than composer doc ops; a revise whose ops are all filtered out tells the user the change is unsupported instead of re-delivering an unchanged design. - Delivery: rendered previews are written to
/filesviaAiDesignerSaverService(the contact sheet is a transient QC artifact for the vision critic — written to storage but deliberately NOT persisted as aFilerow, so it never appears in the org's Files library);Designrows are created and can be promoted toDesignTemplate. The target folder is resolved once per run: a client-suppliedsaveFolderIdcounts only when the folder belongs to the org, else the config'ssavePath(e.g./campaigns/summer-launch) is resolved find-or-create per segment viaFileService.resolveFolderPath, else the/filesroot. Delivery is conversational — there is no delivery form: the media message (with an Open in Designer handoff to/media/designer?designId=<id>per item) is followed by a plain prompt to say what to change or "looks good" to finish. A whole-message accept phrase ("looks good", "perfect", "done", …, optionally with the "no template" / "don't save" opt-out clause) is matched deterministically before the conversationalist dispatch — an instruction-bearing message ("looks good but make it darker") always goes to the classifier. Chatacceptauto-saves each design of the latest delivery as a reusable template (server-ownedbrief.lastDeliveredDesignIds, set where delivery completes — superseded revisions stay active but are not re-saved); any other free text indelivered/revisingis classified by the conversationalist and applied as a revision, honoring an extracted target design ("…on variant 2"). - Live progress: every phase transition emits a live
agent:progressevent (persisted progress rows survive reload; the frontend also shows an instant "Thinking…" bubble the moment the user sends anything, and a Cancel button inside the progress bubble while a run is in flight — it emits the socketcancelevent and the conductor rolls the session back to its pre-run state). The pipeline composes one original in the primary format per accepted plan, then_expandVariantsseeds each remaining format viaaddOutput(auto-seeded from the original, sharedoriginIds), re-resolves per-aspect assets, and runs up to two per-format vision-critic passes (applyFixespinned to that format) — all persisted to the sameDesignrow.