diff --git a/docs/gecko-video-creator.md b/docs/gecko-video-creator.md
new file mode 100644
index 0000000..ec20311
--- /dev/null
+++ b/docs/gecko-video-creator.md
@@ -0,0 +1,1024 @@
+# Gecko Video Creator (GVC) — Product & Data Model Spec
+
+**Status:** Draft for discussion
+**Date:** 2026-10-07
+**Scope:** What GVC is, the JSON document a video is described by (the **VideoSpec**),
+how an AI builds and edits one, and how GVC pulls scripts from Gecko Notes and media
+from GAM. This is a product and data-model spec, not an implementation.
+
+---
+
+## 1. Executive summary
+
+Gecko Notes already turns a note into a narrated MP4 ("Article to video",
+[`backend/app/video/`](../backend/app/video/)). It works, but its idea of what a video
+*is* only exists **implicitly**. The structure is inferred from the note's block layout
+every time it renders: an image starts a segment, the text under it is read aloud, and a
+quote becomes its own shot. The look and feel comes from one global settings object. You
+can't open that structure, edit it, or ask an AI to change one part of it, because it
+never exists as a thing.
+
+**Gecko Video Creator** (`gvc.geckopico.com`) is the third app in the suite. It pulls
+video out of Notes and makes that structure explicit:
+
+- A video is a **JSON document, the VideoSpec**: ordered segments, the narration beats
+ inside them, overlays, any number of audio tracks, and references to assets. The
+ editor UI and the AI both edit this one document, and the renderer reads only this
+ document.
+- **Timing is segment-anchored.** Music, sound effects and overlays attach to segments
+ and to the beats within them, for example "start at *The caravans*, end after *The
+ market*". Narration decides how long things last, and exact seconds are only worked
+ out at render time. Inserting a segment or rewording a line never breaks anything
+ downstream.
+- **GVC owns no media.** A script comes from a note. Footage, clips, music and generated
+ images come from (or land in) GAM. The finished render goes back into GAM as a
+ `gvc_export` asset.
+- **Editing a video means asking for something.** For example: "make a video from my
+ *Salt Road* note", "find footage of a caravan for segment two", "put the theme music
+ from the second chapter to the end of the third and fade it out", "generate an image
+ for the market scene". The AI answers with small, validated edits to the spec, never
+ a wholesale rewrite, and every edit is versioned and can be undone.
+
+**Recommended first step:** make the VideoSpec real **inside Notes first** (Phase 0,
+§10). The current segmenter emits a VideoSpec, and the current renderer consumes one,
+with golden tests proving that output doesn't change. This proves the schema against a
+renderer that already works, before a line of GVC exists. Only after that does the code
+move.
+
+---
+
+## 2. The suite
+
+```mermaid
+flowchart LR
+ subgraph Notes["Gecko Notes · notes.geckopico.com"]
+ N[Notes = scripts
BlockNote JSON]
+ AUTH[Identity provider
gecko_session cookie]
+ end
+ subgraph GAM["Gecko Asset Manager · gam.geckopico.com"]
+ A[(Assets, clips,
transcripts, attribution)]
+ GEN[AI generation
fal.ai]
+ end
+ subgraph GVC["Gecko Video Creator · gvc.geckopico.com"]
+ SPEC[VideoSpec
projects + history]
+ AI[AI editor]
+ R[Renderer
ffmpeg + TTS]
+ end
+ N -->|import script| SPEC
+ A -->|search, pick, clips| SPEC
+ AI -->|validated edits| SPEC
+ SPEC -->|generate requests| GEN
+ GEN -->|new assets| A
+ SPEC --> R
+ R -->|MP4 as gvc_export| A
+ AUTH -.->|SSO| GAM
+ AUTH -.->|SSO| GVC
+```
+
+There are three apps, each deployable on its own, and each owns its own data. They talk
+over APIs and never reach into each other's databases:
+
+| App | Owns | Source of truth for |
+|---|---|---|
+| **Gecko Notes** | notes, users, login | narrative: scripts, drafts, research |
+| **GAM** | the media library | assets, clips, transcripts, attribution, generations |
+| **GVC** | video projects (VideoSpecs) and render jobs | how a video is put together |
+
+**The shared login is already built.** Commit `14b3220` added the suite's single
+sign-on (SSO):
+- a parent-domain `gecko_session` cookie ([`backend/app/routers/auth.py`](../backend/app/routers/auth.py))
+- `GET /api/auth/session`, which lets a sibling app start up already signed in
+- cookie fallback in the auth middleware ([`backend/app/main.py`](../backend/app/main.py))
+- a `?redirect=` login parameter restricted to `*.geckopico.com`
+ ([`frontend/src/views/LoginView.tsx`](../frontend/src/views/LoginView.tsx))
+
+Like GAM, GVC has no registration of its own. It verifies the JWT that Notes issues and
+keeps a shadow user row keyed by the token's `sub`.
+
+**GAM and Notes are connected over APIs, not merged.** GAM stays its own app. Notes
+uses GAM's library through GAM's API and its embeddable picker (`/picker?embed=1`,
+planned in GAM's M9). That gives Notes access to the whole library without Notes
+becoming an asset manager. GVC uses the same picker, so there's one search UI and three
+consumers.
+
+---
+
+## 3. How video export works in Notes today
+
+This section is the baseline the VideoSpec has to reproduce. Everything lives in
+[`backend/app/video/`](../backend/app/video/):
+
+| File | Role |
+|---|---|
+| `segmenter.py` | BlockNote JSON → a flat list of `Shot`s (pure function) |
+| `narration.py` / `pause_markup.py` | chunking, `[pause:…]` markup, TTS, silences, subtitle cues |
+| `options.py` | `RenderOptions`, every setting, one global object |
+| `compose.py` | Pillow drawing: title/chapter cards, quote and code panels, watermark, text overlay |
+| `ffmpeg.py` | filtergraph builders: fit, Ken Burns, waveform, transitions, music ducking |
+| `renderer.py` | the pipeline: segment → narrate → encode each shot → stitch → mix music → chapters/subtitles/poster |
+| `worker.py` / `shot_cache.py` | background job, TTS wiring, inserting the result into the note, shot cache for retries |
+
+### 3.1 How a note becomes shots
+
+The model is **media-led**. Every image or video block starts a new shot, and the
+narrated text **below** it, up to the next media block, is that shot's narration.
+
+- **Image** → a `still` shot. **Text before the first media block** → an "opening" shot
+ over the fallback background.
+- **Video with no audio** → a `video_muted` shot, looped under the narration.
+- **Video with audio** → **two shots**: `video_sound` (the whole clip with its own sound
+ and no narration), then `video_muted` (the same clip looped silently under the text
+ that follows).
+- **Headings** → read inside the current shot, set apart by `heading_pause_ms`. With
+ `chapter_screens` on, they become a chapter **card** shot followed by a continuation
+ shot instead.
+- **Quotes** (with `quotes.enabled`) → their own shot, over the same background, with
+ the quote drawn on screen while it is read. A trailing `— Name` line is detected as
+ the attribution, drawn on screen but not spoken.
+- **Code blocks** → their own shot with a code panel, narrated only if `narrate_code`.
+- **Title card** → a silent card shot at the start (note title + author).
+- **Intro/outro** → `video_sound` bumper shots, with nothing drawn over them and music
+ stopping before them.
+- **Pause markup** such as `[pause]`, `[pause:long]` and `[pause:1.5s]`, plus ellipses
+ and blank lines, controls the gaps in the speech.
+
+The `Shot` dataclass (`segmenter.py:38`) is the closest thing to a data model today:
+`kind`, `background`, `narration`, `card_title`/`card_subtitle`/`card_kind`, `chapter`,
+`quote_text`/`quote_attribution`, `code_text`, `bumper`. Everything else (motion, music,
+transitions, watermark, waveform, voice) is a **global** field on `RenderOptions`.
+
+### 3.2 What that model cannot express
+
+| Limitation | Why it matters for GVC |
+|---|---|
+| Music, transitions, Ken Burns and fit are **global** | You can't have music that starts at chapter 2 and stops at chapter 4, or a dissolve into one segment only |
+| **One** music track | No layered soundtracks, no sound effects |
+| No clip **trim** | A clip always plays whole. You can't use 18 seconds out of a 90-minute interview |
+| No **timed overlays** | A quote can only appear by becoming its own shot. There are no lower thirds, no on-screen text at a moment, no logo sting |
+| No **per-segment voice** or speed | A whole video uses one voice |
+| Structure is **re-inferred every render** | Nothing to edit, version or hand to an AI. The only way to change the video is to change the note |
+| Media must be in the note's own `/media/` | No library search, no generation inside the video workflow |
+
+Four existing defects should be fixed on the way in rather than carried over:
+
+- **The Voice picker does nothing.** `VideoGenModal.tsx` offers one and writes
+ `options.voice`, but no backend code reads it. The voice always comes from the account
+ setting (`video/worker.py:81`).
+- **A heading placed above its image** attaches to the *previous* shot, not the image
+ (pinned by `backend/tests/test_video_segmenter.py`). That's correct for the
+ "text below the picture" model, but surprising.
+- **The frontend and backend clamp differently.** Speed is 0.75–1.5 in the UI and
+ 0.5–2.0 on the server. The longest transition is 2 s versus 3 s. `min_shot_seconds`
+ maxes out at 10 versus 30, and there are a few more.
+- **Some settings have no UI at all:** `fps`, `fallback.angle`, waveform opacity and
+ scrim, watermark opacity and margin, overlay margin and shadow.
+
+---
+
+## 4. Design principles
+
+1. **The VideoSpec is the single source of truth.** The UI, the AI and the importer all
+ produce edits to it, and the renderer consumes only it. A render is a pure function
+ of *(spec, assets, voice)*. That's what makes caching, previews and "why does it look
+ like that?" tractable.
+2. **Timing is segment-anchored, not absolute.** Anything that happens in time (music,
+ sfx, overlays) is anchored to a segment or a beat, plus an optional offset in
+ seconds. Absolute times are computed in a **compile** step at render time and never
+ stored. This is what makes the spec safe for an AI to edit: inserting a segment can't
+ leave a soundtrack ending in the wrong place.
+3. **Assets are referenced, never copied.** The spec holds a registry of asset
+ references (GAM, a Notes media URL, a pending generation, an unresolved search).
+ Segments refer to registry keys, so swapping an asset is one edit.
+4. **IDs are stable and readable.** `s_caravan`, `b2`, `m_theme`, not array indexes.
+ Anchors and AI edits point at IDs, so reordering never retargets anything. IDs are
+ never reused within a project.
+5. **Defaults cascade: project → segment → element.** `style.transition` applies
+ everywhere unless a segment sets `transition_in`. `voice` applies unless a segment
+ overrides it. A block that is absent means "off". There are no `enabled: false`
+ flags to keep in sync.
+6. **The AI edits through operations, not rewrites.** Each change is a small, typed
+ operation (`add_audio_track`, `set_visual`, …) that is validated against the schema
+ and stored as a JSON Patch. Edits are cheap in tokens, can't silently drop parts the
+ AI didn't mean to touch, and appear in history as readable diffs.
+7. **Every change is versioned.** A project has a history like `NoteVersion`. Undo
+ reverts the patch. AI suggestions can be staged and accepted one by one, the same
+ rule GAM applies to enrichment: **reviewed, never applied silently**.
+8. **Importing today's notes is lossless.** Any note that renders in Notes today must
+ import into a VideoSpec that renders the same video (§7). That is the acceptance
+ test for the schema.
+9. **One schema, one set of limits.** The canonical definition is a Pydantic model.
+ JSON Schema and TypeScript types are generated from it, and the UI reads its slider
+ ranges from the schema. That ends the frontend/backend clamp drift.
+
+---
+
+## 5. The VideoSpec (draft schema)
+
+Written as TypeScript-style types because they're the most readable form. The real
+source of truth will be Pydantic (principle 9). Defaults shown are today's
+`RenderOptions` defaults.
+
+### 5.1 Top level
+
+```ts
+interface VideoSpec {
+ spec_version: "0.1";
+ id: string; // "vid_…", the GVC project id
+ title: string;
+ source?: { // where the script came from
+ note_id: string;
+ note_version_id?: string; // the NoteVersion the import read
+ imported_at: string; // ISO 8601, used for re-sync (§9.1)
+ };
+ output: Output;
+ voice: Voice;
+ style: Style; // project-wide look; segments override
+ chrome: Chrome; // drawn over every segment unless it opts out
+ assets: Record; // the asset registry
+ segments: Segment[]; // played in order
+ audio_tracks: AudioTrack[]; // music, sfx, extra voice: any number, may overlap
+ credits?: Credits;
+}
+
+type AssetKey = string; // "a_map", referenced from visuals, overlays, tracks
+
+interface Output {
+ aspect: "16:9" | "9:16" | "1:1"; // "16:9"
+ resolution: "720p" | "1080p" | "4k"; // "1080p"
+ fps: number; // 30 (12–60)
+ quality: "preview" | "standard" | "high"; // "standard"
+ subtitles: "off" | "sidecar" | "soft" | "burn"; // "sidecar"
+ chapters: boolean; // true: embed chapter marks
+ thumbnail: boolean; // true
+}
+
+interface Voice {
+ provider: "auto" | "deepgram" | "fal";
+ voice: string | null; // null = the account's voice; "flux-haley-en", "Aria", …
+ speed: number; // 1.0 (0.5–2.0)
+ expressivity?: number; // Deepgram Flux only, -2…2
+ pauses: {
+ paragraph_ms: number; // 350
+ heading_ms: number; // 800
+ segment_end_ms: number; // 600
+ };
+}
+```
+
+### 5.2 Style and chrome
+
+```ts
+interface Style {
+ background: Background; // shown when a segment has no visual
+ fit: "blur" | "pad" | "crop"; // "blur"
+ motion: Motion; // default Ken Burns for stills
+ transition: Transition; // default cut between segments
+ min_segment_seconds: number; // 2.5
+ cards: {
+ seconds: number; // 3.5
+ motion: boolean; // false: Ken Burns on cards (zoom only)
+ title: { title_pct: number; subtitle_pct: number }; // 6.8 / 2.9
+ chapter: { title_pct: number; subtitle_pct: number };
+ };
+ quote: { position: "top" | "center" | "bottom"; size_pct: number;
+ color: string; accent: string; scrim: number };
+ code: { position: "top" | "center" | "bottom"; size_pct: number;
+ color: string; scrim: number };
+}
+
+type Background =
+ | { type: "gradient"; colors: string[]; angle: number } // ["#1e293b","#0f172a"], 135
+ | { type: "solid"; colors: [string] }
+ | { type: "asset"; asset: AssetKey };
+
+interface Motion {
+ effect: "none" | "zoom_in" | "zoom_out" | "pan_left" | "pan_right"
+ | "pan_up" | "pan_down" | "alternate"; // "alternate" only at project level
+ amount?: number; // 0.12 (0.02–0.5)
+}
+
+interface Transition {
+ style: "none" | "fade" | "fadewhite" // dips: cheap
+ | "dissolve" | "slideleft" | "slideright" | "wipeleft"
+ | "wiperight" | "circleopen" | "smoothleft"; // blends: re-encode
+ duration: number; // 0.6 (0.1–3.0)
+}
+
+type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
+
+interface Chrome { // each block present = on
+ watermark?: { asset?: AssetKey; text?: string; position: Corner; opacity: number;
+ scale_pct: number; caption_pct: number; margin_pct: number };
+ overlay_text?: { mode: "fixed" | "title" | "title_chapter"; text?: string;
+ position: Corner; color: string; size_pct: number;
+ margin_pct: number; shadow: boolean };
+ waveform?: { mode: "line" | "p2p" | "cline" | "point"; color: string;
+ opacity: number; position: "top" | "center" | "bottom";
+ height_pct: number; scrim: number };
+}
+```
+
+### 5.3 Segments, visuals and beats
+
+A **segment** is one stretch of the video with one visual context. There are three
+kinds:
+
+| `kind` | What it is | Duration |
+|---|---|---|
+| `scene` | A visual (image, video or background) with narration beats and overlays over it. Most of a video is scenes. | narration + pauses, at least `min_segment_seconds` |
+| `card` | A title or chapter screen: text over a blurred background, optionally narrated | `max(cards.seconds, narration)` |
+| `clip` | A video played **with its own sound**, with no narration. Intros, outros, interview excerpts. | the clip's (trimmed) length |
+
+```ts
+interface Segment {
+ id: string; // "s_caravan": stable, unique, never reused
+ kind: "scene" | "card" | "clip";
+ role?: "title" | "intro" | "outro"; // affects defaults: intro/outro get chrome: false
+ label?: string; // name shown in the editor
+ chapter?: string; // a chapter mark at this segment's start
+ visual?: Visual; // required for clip; a scene without one uses style.background
+ card?: { variant: "title" | "chapter"; title: string; subtitle?: string };
+ narration?: { beats: Beat[]; voice?: Partial }; // per-segment voice override
+ overlays?: Overlay[];
+ transition_in?: Transition; // overrides style.transition for the cut INTO this segment
+ duration?: { min?: number; fixed?: number }; // default: automatic
+ chrome?: boolean; // false = no watermark, overlay text or waveform
+ source?: { note_id: string; block_ids: string[] }; // provenance, for re-sync
+}
+
+interface Visual {
+ asset: AssetKey;
+ fit?: "blur" | "pad" | "crop";
+ motion?: Motion; // stills only
+ trim?: { in: number; out: number }; // seconds within a video asset
+ audio?: "mute" | "play"; // "play" only on kind: "clip"
+ loop?: boolean; // a video shorter than its segment: loop (default) or hold the last frame
+}
+
+interface Beat { // one spoken unit, usually one block of the note
+ id: string; // "b2": unique within its segment
+ text: string; // supports [pause:…] markup and the ellipsis rules
+ kind?: "prose" | "heading" | "quote" | "code"; // provenance; sets default pauses
+ speak?: boolean; // false = timed but silent (e.g. code with narrate_code off)
+ hold?: number; // seconds a silent beat lasts
+ pause_after_ms?: number; // overrides the default pause after this beat
+}
+```
+
+**Why beats exist.** Today a quote has to become its own shot because the only unit of
+time is the shot. With beats, a segment can say "while beat `b2` is being read, show
+this quote". The picture stays put and the overlay comes and goes. The same mechanism
+gives lower thirds when a person is introduced, a stat on screen as it's spoken, and a
+sound effect on a particular line.
+
+### 5.4 Anchors, overlays and audio tracks
+
+```ts
+type Anchor =
+ | "video_start" | "video_end"
+ | {
+ segment?: string; // omitted inside a segment's overlay = that segment
+ beat?: string; // narrow to one beat within the segment
+ edge: "start" | "end";
+ offset?: number; // seconds; negative = before the edge
+ };
+
+interface Span { from: Anchor; to: Anchor } // default for overlays: the whole segment
+
+type Overlay = { id: string; show?: Span; fade?: number } & (
+ | { type: "quote"; text: string; attribution?: string; style?: Partial