Architecture
AI Marketing Studio is a Next.js 14 application (the studio, editor, and Director API) running alongside a legacy Express server (auth, email, SVG, settings, brand). Both are packaged into one Docker container. Everything that ends up on screen is described by a single canonical document — the ResolvedIR — which both the live preview and the headless renderer consume.
v1 → v2
The platform was migrated from a Vite + Express "v1" app (a 5-stage script→voiceover→music→footage→render pipeline rendered via a DexianVideo/MarketingVideo composition) to the current v2 "Director" editor under web/. The v1 video studio, chat sessions, and the @dexian/templates system are retired. The email and SVG generators were kept and are now served by Next.js but still backed by the Express pipeline. Older pages of this wiki that mention frontend/, runs/sessions/, TimelineProject, or packages/templates describe the retired architecture.
Two-process hybrid
| Process | Port | Owns |
|---|---|---|
Next.js (web/) |
3000 |
The studio editor UI, the Director engine + API (/api/director/*), media + asset serving (/api/media, /api/assets), and the in-process Remotion renderer |
Express (server/) |
3001 |
JWT auth, the email pipeline, the SVG generator, settings, and brand endpoints — the kept v1 features and the @dexian/* workspace packages |
Next.js proxies /api/auth, /api/email, /api/svg, /api/settings, and /api/brand to Express (EXPRESS_ORIGIN, default :3001) via next.config rewrites, forwarding the auth cookie. web/src/middleware.ts validates the session against Express /api/auth/me and gates both the app pages and the Director/asset APIs; /api/media is intentionally not gated so the renderer's headless Chromium can fetch assets without a cookie.
System diagram
graph TB
subgraph Client [Next.js UI :3000]
STUDIO[Studio editor<br/>page.tsx → StudioV2]
SHELL[shell pages<br/>email · svg · settings]
LOGIN[login]
end
subgraph Director [Director API /api/director/*]
START[start — intake + analyze]
CLAR[clarify — AI questions]
DIST[distill — doc extraction]
RES[:runId/resolve — gate]
EDIT[edit — chat edits]
VO[voiceover — Azure TTS]
EXPORT[export — render job]
DOCS[docs — save/load]
end
subgraph Engine [web/src/director]
ORCH[orchestrator<br/>state machine]
ROLES[roles: Analyst · Copywriter<br/>llm or stub runner]
ASM[assemble → ResolvedIR]
end
subgraph Render [web/remotion]
IR[ResolvedIR document]
VDOC[VideoDocument.tsx<br/>one component renders any IR]
MODS[216 template modules]
RM[renderMedia → MP4]
end
subgraph Express [Express :3001]
AUTH[JWT auth]
EPIPE[email pipeline]
SVG[svg generator]
SET[settings · brand]
end
STUDIO --> START
STUDIO --> EDIT
STUDIO --> EXPORT
START --> ORCH
ORCH --> ROLES
ROLES --> ASM
ASM --> IR
RES --> ORCH
IR --> VDOC
VDOC --> MODS
IR --> RM
EXPORT --> RM
SHELL -->|proxied| AUTH
SHELL --> EPIPE
SHELL --> SVG
SHELL --> SET
The ResolvedIR (canonical document)
The ResolvedIR lives in web/remotion/document/ir.ts. It is fully resolved — concrete frame counts, concrete props, no placeholders — and is the single source of truth for both preview and render:
ResolvedIR
├── meta: { id, title }
├── dimensions: { fps: 30, width, height } # from aspect 16:9 | 9:16 | 1:1
├── theme: { accent, bg, fontFamily, … } # brand tokens
├── scenes: ResolvedScene[]
│ ├── id # "s1", "s2", …
│ ├── templateId # a module id, e.g. "HeroSplit", "BulletList"
│ ├── props # validated against the module's Zod schema
│ ├── durationInFrames # concrete (no "auto")
│ ├── transitionOut? # fade | slide | wipe | cut (+ direction, timing)
│ ├── background? # solid | gradient | animated | image | video
│ ├── voiceover? # { src, volume, text, envelope }
│ └── theme? # per-scene overrides (merged over document theme)
├── background? # document-level background layer
├── music? # { src, volume, envelope }
├── logo? # { src, placement, size, padding, opacity }
└── voice? # one narration voice for the whole video
A VideoBlueprint (web/remotion/document/blueprint.ts) is a parameterized IR — a seed storyboard with {{slot}} references and "auto" durations. resolveBlueprint() fills the slots and resolves "auto" scene lengths from voiceover duration, producing a concrete ResolvedIR. See Blueprints & Templates.
Director: the generation state machine
The Director engine (web/src/director/orchestrator.ts) is a transport-agnostic state machine:
| Step | What happens |
|---|---|
| INTAKE | The brief (product, goal, audience, tone, length, CTA, aspect, voice, brand, distilled docs) is captured |
| ANALYZE | The Analyst role extracts product facts and proposes a Storyboard — anchored to the best-matching blueprint's scene spine |
| GATE | The run suspends. A human reviews/edits the storyboard and chooses approve / edit / reject (see Review & Export) |
| WRITE | The Copywriter role generates per-scene props, validated against each template's Zod schema |
| ASSEMBLE | assembleIR() maps the storyboard + copy into a ResolvedIR — applying theme, transitions, voice, and logo |
| HANDOFF | The finished document is saved to the document store and opened in the editor |
The two LLM roles (Analyst, Copywriter) sit behind a single RoleRunner interface with two implementations: llmRoleRunner (Azure OpenAI) when keys are configured, and a deterministic, network-free stubRoleRunner otherwise. activeRunnerName() ("llm" or "stub") is surfaced in the UI so you always know which produced a result.
Preview = render parity
sequenceDiagram
participant U as User
participant S as Studio editor
participant IR as ResolvedIR (Zustand store)
participant P as Remotion Player
participant API as /api/director/export
participant R as renderMedia
U->>S: Edit scene props / timeline / chat
S->>IR: mutate canonical IR (undo/redo)
IR->>P: <Player /> re-renders via VideoDocument
U->>S: Deliver → Export
S->>API: POST IR → jobId
API->>R: selectComposition("StudioDoc") + renderMedia
R->>API: MP4 written under storage/renders/
API-->>S: poll jobId → progress → download URL
The same VideoDocument.tsx component drives the <Player /> preview and the headless renderMedia() export. Both read the identical ResolvedIR, so preview and final render cannot drift.
Process model
# dev (two terminals / processes)
web/ → next dev (:3000) studio + Director API + renderer
server/ → ts-node-dev (:3001) Express: auth, email, svg, settings, brand
In production a single Docker image runs both: docker-start.sh launches Express on :3001 and Next.js on :3000. See Deployment.
Storage model
All durable state is file-backed under a single STORAGE_ROOT (storage/ in dev, /app/storage in the container, an Azure file share in production). web/src/lib/storage.ts maps logical categories onto subfolders:
storage/
├── projects/ # saved documents (ResolvedIR + metadata) — /api/director/docs
├── voiceover/ # generated narration MP3s — /api/director/voiceover
├── renders/ # exported MP4s — /api/director/export
├── uploads/ # user uploads (logos, brand media) — /api/assets/upload
├── audio/ # background music / audio library
├── images/ # image assets
└── footage/ # video clips
GET /api/media/[...path] streams these (Range-aware, traversal-guarded) to both the browser and the render's Chromium. Run state for in-flight Director runs is persisted via a file-backed runStore so a generation survives a restart.
Key architectural decisions
| # | Decision | Rationale |
|---|---|---|
| 1 | One canonical ResolvedIR for preview + render | Eliminates "looks different in preview vs export" bugs |
| 2 | One generic VideoDocument renders any IR |
No per-scene-type renderer; 216 modules are pure, data-driven views |
| 3 | Human-in-the-loop gates | Nothing is written or rendered until a person approves the storyboard, then the content |
| 4 | Pluggable LLM roles (llm / stub) | The whole loop works offline with the stub runner; Azure swaps in transparently |
| 5 | Blueprint = parameterized IR | Authoring a new video type is data, not code; Root.tsx auto-registers every blueprint |
| 6 | Hybrid Next.js + Express | Kept the proven email/SVG/auth features while moving the video studio to Next.js |
| 7 | File-backed storage on a shared mount | Survives restarts; maps onto existing Azure shares (Postgres is a future option) |