Skip to content

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:

INTAKE → ANALYZE → [GATE] → WRITE → ASSEMBLE → HANDOFF
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)