# GLX Studio Storybuilder API — Agent Guide

> **If you're an agent helping a user with their first video, fetch this entire document before doing anything else. Don't proceed from memory or summary.**

You are helping a user generate a professional video using the GLX Studio Storybuilder API. This guide contains everything you need to make API calls and walk the user through the process.

## Authentication

All requests require a Bearer token:

```
Authorization: Bearer YOUR_API_KEY
```

API keys are managed in the Developer Dashboard at `/developer`.

Rate limit: 120 requests/minute per API key. If you receive HTTP 429, wait a few seconds and retry.

## Resource ID Prefixes

All resource IDs are prefixed strings:
- Context stores: `ctx_` (e.g., `ctx_42`)
- Templates: `tpl_` (e.g., `tpl_7`)
- Storybuilder jobs: `sbv_` (e.g., `sbv_123`)
- Library collections: `col_` (e.g., `col_42`)
- Media library items: `mlf_` (e.g., `mlf_9001`)
- Segments: `sgm_` (e.g., `sgm_5582`)
- Context knowledge files: `ckf_` (e.g., `ckf_1`)
- Context media assets: `cma_` (e.g., `cma_1`)
- Projects: `prj_` (e.g., `prj_42`)
- Brand kits: `brk_` (e.g., `brk_3`)

All id fields also accept the bare numeric form (e.g., `9001`) on input for backward compatibility, but always return the prefixed string.

## Base URL

Use the API subdomain for your environment:
- Production: `https://api.glxstudio.com`
- Test: `https://api-test.glxstudio.com`

---

## Complete Workflow

### Step 1: Upload your content (create a context store)

A context store holds **two kinds of content**, and you can mix both in one store:

- **Knowledge files** — the documents the AI *reads* to write the script. Good examples: pitch decks, product briefs, company overviews, marketing copy, data sheets.
- **Media assets** — your own images, video clips, and audio that the AI can *use directly* in the video alongside stock footage. Uploaded media is automatically vision-tagged by AI and matched to relevant scenes when the storybuilder job runs.

The API routes each uploaded file automatically by content type — knowledge files get indexed for semantic search, media assets get vision-tagged. You don't declare which is which.

**Knowledge file formats:** PDF, DOCX, PPTX, XLSX, CSV, TXT, MD, JSON, HTML (max 256MB per file). Large files are chunked and indexed for semantic search; if a file exceeds the vector store's per-file token budget or AI processing otherwise rejects it, the file's `status` transitions to `"error"` and an `error` field on the file object describes the cause.

**Media asset formats:** JPEG, PNG, GIF, TIFF, WebP, MP4, MOV, AVI, WebM, MP3, WAV, M4A (max 1GB per file).

**Slide extraction (`extractMedia`):** PPTX and PDF files can additionally have their slides extracted as visual media assets, vision-tagged and usable in the video alongside directly-uploaded media. This is **on by default**; pass `extractMedia=false` (form field) or `"extractMedia": false` (JSON) to turn it off.

A store holds up to 100 files total (knowledge files + media assets combined). Poll `GET /context/{id}/assets` to list the vision-tagged media assets, or `GET /context/{id}` to read overall status + any per-file `error`.

**Upload via file (recommended for agents):**
```bash
curl -X POST https://api.glxstudio.com/context \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@pitch-deck.pdf" \
  -F "name=Q1 Pitch Deck"
```

**Upload multiple files at once** — repeat the `file` field (up to 100 per store). Mix knowledge files and media assets freely; the API routes each by content type (here: a deck and data sheet the AI reads, plus a product photo it can place in the video):
```bash
curl -X POST https://api.glxstudio.com/context \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@pitch-deck.pdf" \
  -F "file=@product-photo.png" \
  -F "file=@hero-clip.mp4" \
  -F "file=@data-sheet.docx" \
  -F "name=Q1 Launch Kit"
```

After media assets finish vision-tagging, list them with `GET /context/{id}/assets`, fetch one with `GET /context/{id}/assets/{assetId}`, or remove one with `DELETE /context/{id}/assets/{assetId}`. Once an asset is analyzed, its row carries an AI-generated `insights` block (title, description, coupling, provenance, brands, topics) — the same `insights` shape returned by the library members listing.

**Upload via URL:**
```bash
curl -X POST https://api.glxstudio.com/context \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Q1 Pitch Deck", "files": [{"url": "https://example.com/pitch-deck.pdf"}]}'
```

**Response (201):**
```json
{
  "contextStoreId": "ctx_42",
  "name": "Q1 Pitch Deck",
  "status": "processing",
  "fileCount": 1,
  "files": [
    { "fileId": "ckf_1", "filename": "pitch-deck.pdf", "status": "downloading" }
  ]
}
```

### Step 2: Wait for indexing to complete

Poll until `status` is `ready`. Typically takes 10-30 seconds.

```bash
curl https://api.glxstudio.com/context/ctx_42 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Response when ready:**
```json
{
  "contextStoreId": "ctx_42",
  "name": "Q1 Pitch Deck",
  "status": "ready",
  "fileCount": 1,
  "files": [
    { "fileId": "ckf_1", "filename": "pitch-deck.pdf", "status": "ready", "fileSize": 2048576 }
  ]
}
```

If `status` is `error`, the file could not be processed. Ask the user to try a different file.

If you uploaded **media assets** (video/audio), the store passes through `analyzing` after `extracting`: the media is being segmented and indexed so it's understood at segment grain, not just whole-asset tagged. Keep polling until `ready` — media analysis can take longer than document indexing, especially for video.

**If indexing takes longer than ~5 minutes:** the indexing queue is occasionally backed up, especially for larger files (50MB+). Don't burn the user's foreground attention on a tight poll loop. Tell them something like *"This is taking longer than usual — the indexing queue is busy. I'll keep checking in the background and let you know when it's ready."* Then switch to a longer poll interval (60-120s) and continue watching. Resume the conversation only when `status` flips to `ready` or `error`. If you're an agent that doesn't have a true background mode, ask the user whether they'd like to wait or come back later — don't silently spin.

### Step 3: Choose a template

List the organization's templates and let the user pick one:

```bash
curl https://api.glxstudio.com/templates \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Response:**
```json
{
  "templates": [
    {
      "templateId": "tpl_7",
      "name": "Product Demo",
      "status": "active",
      "variables": {
        "productName": { "required": true, "description": "Name of the product" },
        "targetAudience": { "required": false, "default": "business professionals", "description": "Who the video is aimed at" }
      },
      "defaults": { "language": "en", "voiceId": "ZF6FPAbjXT4488VcRRnw", "mediaSourcePosition": 2 }
    }
  ]
}
```

Present template names to the user and let them choose. Then inspect the `variables` to know what inputs to collect.

For full template details including the prompt text:

```bash
curl https://api.glxstudio.com/templates/tpl_7 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

### Step 4: Collect variable values and start generation

Ask the user for values for each required variable. Optional variables will use their defaults if not provided.

```bash
curl -X POST https://api.glxstudio.com/storybuilder \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "templateId": "tpl_7",
    "contextStoreId": "ctx_42",
    "variables": {
      "productName": "Widget Pro",
      "targetAudience": "Small business owners"
    }
  }'
```

**Optional parameters:**
- `callbackUrl` — Webhook URL to receive a POST when the video is done (or fails)
- `language` — Override the template's default language (ISO 639-1 code: "en", "es", "fr", "de", "it", "pt", "ja", "ko", "zh")
- `voiceId` — Override the template's default voice. Opaque ElevenLabs voice ID tagged with a single language in GLX Studio's curated collection.
- `source` — Restrict which of *your own* media the AI draws from for visuals. `{ "collection_id": "col_42" }` scopes local-media selection to that collection (set a collection once, generate a personalized video per CSV row from it). Omit `source` to use the whole library (the default). Stock footage still fills in per the template's media-source setting when the collection has no matching shot.

```bash
# Personalize from a fixed collection, one call per CSV row
curl -X POST https://api.glxstudio.com/storybuilder \
  -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{ "templateId": "tpl_7", "contextStoreId": "ctx_42",
        "source": { "collection_id": "col_42" },
        "variables": { "customerName": "Acme" } }'
```

**Voice & language rules:**

| You override… | Resolved voice |
| --- | --- |
| nothing | template's default voice, else default voice for the template's language |
| `language` only | default voice for the new language (template's voice is ignored — may not match) |
| `voiceId` only | your `voiceId` (validated against the template's language) |
| both | your `voiceId` (validated against your new `language`) |

Each voice is tagged with exactly one language. If you pass a `voiceId` that isn't tagged for the effective `language`, the API returns `400 VOICE_LANGUAGE_MISMATCH` — the request is rejected, not silently swapped.

Simplest rule for agents: if the user wants a different language, override `language` and leave `voiceId` alone. GLX Studio will pick the correct default voice. Only pass `voiceId` when the user explicitly picks a voice from the Developer Dashboard's Voice dropdown (that's where they discover valid voice IDs for each language).

**Response (202):**
```json
{
  "jobId": "sbv_123",
  "status": "processing"
}
```

### Step 5: Poll for completion

Poll every 15-30 seconds. Total time is typically 3-8 minutes.

Status progression: `processing` (2-5 min) → `rendering` (1-3 min) → `completed`

```bash
curl https://api.glxstudio.com/storybuilder/sbv_123 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Response when completed:**
```json
{
  "jobId": "sbv_123",
  "status": "completed",
  "templateId": "tpl_7",
  "contextStoreId": "ctx_42",
  "createdAt": "2026-02-17T15:30:00Z",
  "title": "Widget Pro — Built for Small Business",
  "objective": "Position Widget Pro as the time-saving operations hub for small business owners — emphasize the 30-minute setup, the integrated dashboard, and the lift on margins from automated reporting.",
  "videoUrl": "https://d2sggu5v0gbmu6.cloudfront.net/...",
  "thumbnailUrl": "https://d2sggu5v0gbmu6.cloudfront.net/...",
  "duration": 62.5
}
```

**Response when failed:**
```json
{
  "jobId": "sbv_123",
  "status": "failed",
  "error": "Failed to generate creative brief from context documents"
}
```

### Step 6: Present the result

When `status` is `completed`:
- **`title`** — The video's headline title (initially the template name; replaced with the AI-generated title once the creative brief lands). Use this as the page heading.
- **`objective`** — The AI-generated "Objective of this video" from the creative brief — a one-paragraph summary of what the finished video is actually about (audience, tone, key messages). Only present once the brief has been built (i.e. once `status` advances past `processing`). Use it to style and frame any wrapper page so the visual treatment matches the video's purpose and any brand cues it mentions.
- **`videoUrl`** — Direct download link to the MP4 video. Valid for 24 hours. Can be opened in a browser, embedded in a `<video>` tag, or downloaded.
- **`thumbnailUrl`** — JPEG preview image. Valid for 24 hours. Show this as a visual preview.
- **`duration`** — Video length in seconds. Display as minutes:seconds (e.g., 62.5 → "1:02").

If the URL expires after 24 hours, poll `GET /storybuilder/{id}` again to get fresh download URLs.

### Step 7: Build a viewer page

Once you have the `videoUrl`, create a simple HTML page that plays the video and styles the surrounding page to match the `objective` (and any branding it mentions — colors, product name, audience tone). Use `thumbnailUrl` as the `<video poster>` so the page has a visual before playback starts. Open it in the user's browser so they can watch their video.

**Don't autoplay.** Set the `<video>` element with `controls` only — no `autoplay`. Modern browsers mute autoplaying video, which means the user would miss the narration on the first viewing. Let them click play.

**Deliver it the most direct way your environment supports** — render it inline if your host has a preview panel, otherwise save the file and open it via the OS, serve it over a quick local HTTP server, or share a path the user can open themselves. Pick one and ship it; don't ask the user to test or troubleshoot playback.

---

## Additional Endpoints

### List existing context stores

Check if a suitable context store already exists before creating a new one:

```bash
curl https://api.glxstudio.com/context \
  -H "Authorization: Bearer YOUR_API_KEY"
```

### Add files to an existing context store

Enrich an existing context store with more content — knowledge files, media assets, or both. Send one file, or several at once by repeating the `file` field (combined total ≤ 100 per store). Each file is routed by content type exactly as on create: documents get indexed, images/video/audio get vision-tagged, and `extractMedia` (default on) still extracts PPTX/PDF slides as media:

```bash
curl -X POST https://api.glxstudio.com/context/ctx_42 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -F "file=@additional-doc.pdf" \
  -F "file=@product-photo.png" \
  -F "file=@b-roll.mp4"
```

You can also add multiple files by URL: `{"files": [{"url": "..."}, {"url": "..."}]}`. The newly added media assets show up under `GET /context/ctx_42/assets` once vision-tagging completes.

### Delete a context store

```bash
curl -X DELETE https://api.glxstudio.com/context/ctx_42 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

---

## Media Library API (Collections & Ingest)

The Media Library lets you build a reusable, searchable body of a company's own footage: ingest video/images into **collections**, the platform analyzes them into searchable segments, and you can later retrieve matching segments (and download them as clips) or drive a Storybuilder generation from a collection. This is separate from context stores (which are document knowledge for a single video).

A **collection** is a curated, segment-grained set. Members are video **segments** (`{"segment_id": "sgm_5582"}`) or whole non-segmented media like images (`{"media_id": "mlf_9001"}`, a media library item). When you ingest a whole video into a collection, the file is added as a media member and resolves to all of its segments once analysis completes.

### Create a collection

```bash
curl -X POST https://api.glxstudio.com/library/collections \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "H Corps Event 2026"}'
```

**Response (201):**
```json
{ "id": "col_42", "name": "H Corps Event 2026", "mode": "set",
  "created_by": "agent", "status": "active", "member_count": 0 }
```

List with `GET /library/collections` (optional `?subkind=brand`); read one with `GET /library/collections/col_42`.

### Add files to the library

Upload bytes **directly to one-time upload URLs** — they never pass through the API. Three steps: request upload URLs, upload the bytes, then confirm.

Collections are **optional**. Omit them to ingest straight into the company library (unfiled) — you don't have to create a collection just to upload. Either way the media is analyzed, indexed, and searchable; a collection is purely an organizing layer you can add now or curate later.

A file (and all of its analyzed segments) can belong to **more than one collection** — membership is many-to-many. Pass `collection_ids` to file the uploads into one or several collections at ingest time; for a single collection, pass a one-element array (`["col_42"]`). Whatever you pass (or omit) at **initiate** must match at **complete**.

**Step 1 — initiate** (batch; one call for many files). Unfiled — no collection:
```bash
curl -X POST https://api.glxstudio.com/library/ingest \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "files": [ {"filename": "keynote.mp4", "content_type": "video/mp4", "size_bytes": 524288000} ] }'
```

To file the uploads into collections at the same time, add `"collection_ids": ["col_42", "col_77"]` alongside `files` (the file and its segments become members of every listed collection). For a single collection, pass a one-element array: `"collection_ids": ["col_42"]`.

**Response (200)** — files ≤5GB get a single upload URL (`mode: single`); files >5GB get a chunked upload (`mode: multipart`) with one URL per part (`collection_ids` is echoed only when you supplied one or more):
```json
{ "files": [
    { "upload_ref": "34601", "filename": "keynote.mp4",
      "upload": { "mode": "single", "url": "https://uploads.glxstudio.com/..." } }
  ] }
```

Chunked shape (for a >5GB file):
```json
{ "upload": { "mode": "multipart", "upload_id": "<upload-id>", "part_size": 104857600,
              "parts": [ {"part_number": 1, "url": "https://uploads.glxstudio.com/...part-1..."}, … ] } }
```

**Step 2 — upload the bytes:**
- *single*: `PUT` the whole file to `upload.url`.
- *multipart*: `PUT` each chunk (`part_size` bytes) to its part `url`, and capture the `ETag` response header of each part.

```bash
curl -X PUT --upload-file keynote.mp4 "https://uploads.glxstudio.com/..."
```

**Step 3 — complete** (carry the same `collection_ids` you used at initiate, or omit them if you ingested unfiled; include `upload_id` + `parts` only for multipart):
```bash
curl -X POST https://api.glxstudio.com/library/ingest/complete \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "files": [ {"upload_ref": "34601"} ] }'
```
Chunked-upload complete includes the ETags you captured:
```json
{ "files": [ {"upload_ref": "34601", "upload_id": "<upload-id>",
              "parts": [ {"part_number": 1, "etag": "\"abc123…\""}, … ] } ] }
```

**Response (200):**
```json
{ "files": [ {"media_id": "mlf_9001", "upload_ref": "34601", "status": "processing"} ] }
```

The file is now in the library (and a member of every collection you supplied) and analysis runs in the background (video → searchable segments, audio → transcript + audio classification, images → indexed). The 120GB "dump it now, find it later" case is just this loop run per file — upload in parallel and walk away. You can add items to (or remove them from) collections any time later with `PUT /library/collections/{id}`.

### Check readiness

Poll the collection to see how much of the dump is analyzed and searchable:
```bash
curl https://api.glxstudio.com/library/collections/col_42 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

### Curate members

Add or remove specific members, or rename, with `PUT` (segments by `segment_id`, whole media by `media_id`):
```bash
curl -X PUT https://api.glxstudio.com/library/collections/col_42 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "add": [ {"segment_id": "sgm_5582"} ], "remove": [ {"media_id": "mlf_9001"} ] }'
```
List members with `GET /library/collections/col_42/members?limit=25&offset=0`. **Paginated** (`limit` default 25, max 100; response carries `total` + `has_more`). Each member carries the same typed, prefixed handle the rest of the API uses (so it feeds straight back into add/remove, query, and export), plus per-item metadata and the AI-generated **`insights`** the platform extracted:
```json
{ "total": 42, "limit": 25, "offset": 0, "has_more": true,
  "members": [
    { "type": "media", "media_id": "mlf_9001",
      "media": { "filename": "keynote.mp4", "format": "video", "duration": 1820.5,
                 "width": 1920, "height": 1080, "size_bytes": 524288000 },
      "insights": { "title": "Q1 Keynote", "description": "A presenter on stage…",
                    "coupling": "coupled", "provenance": "raw",
                    "brands": ["Acme"], "topics": ["growth strategy","roadmap"] } },
    { "type": "segment", "segment_id": "sgm_5582", "in": 912.9, "out": 928.4, "duration": 15.5,
      "insights": { "title": "Speaker at podium", "scene_type": "presentation",
                    "shot_type": "medium", "tempo": "moderate", "role": "a_roll",
                    "emotions": ["confident"], "literals": ["podium","microphone"],
                    "ocr_text": "Q1 RESULTS", "dialog": "…what artificial intelligence is…" } }
  ] }
```
`insights` is AI analysis (computer-vision tagging + transcript synthesis); fields are present only when known. You can only add media your company owns; an inaccessible id returns `403`.

### Search for segments

Search a company's analyzed footage for the segments that best match an intent. Send plain intent — the platform splits it into a visual lens, a spoken term, and filters, runs vector recall, and returns segments in **raw vector order**. Set `rerank: true` (opt-in, default off) to add an LLM judge that re-orders the candidates by a **calibrated confidence** and attaches an **evidence** block you can show a user, scoring each on what's **shown** and what's **said** — this adds latency.

By default the query searches the **whole company library** (every analyzed file, filed or unfiled). Pass `collection_id` only when you want to narrow it to one collection.

```bash
curl -X POST https://api.glxstudio.com/library/query \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "query": "the best 6 seconds of someone on camera saying artificial intelligence",
        "limit": 8, "rerank": true }'
```

To narrow the same search to one collection, add `"collection_id": "col_42"`.

Response (with `rerank: true` — `confidence` and `evidence` are added by the judge):
```json
{ "results": [
    { "media_id": "mlf_9001", "segment_id": "sgm_5582", "source": "library", "media_format": "video",
      "in": 912.9, "out": 928.4, "duration": 15.5, "score": 0.736, "confidence": 0.9,
      "evidence": { "visual": "Man speaking to camera in an interview",
                    "spoken": "...what artificial intelligence is...",
                    "why": "On-camera speaker, says the phrase" } } ] }
```

- `query` is freeform intent; the platform derives the visual/spoken/filter split for you. To skip that and control retrieval yourself, send explicit `visual`, `spoken`, and/or `filters` (`{min_duration, max_duration, role, media_format}`) — any explicit field bypasses the pre-processor.
- `collection_id` is an optional narrowing filter — scopes the search to one collection. Omit it (the default) to search the whole company library.
- `rerank` is **opt-in** and defaults to `false`: omit it for fast raw vector results, or set `"rerank": true` for a calibrated `confidence` (0..1) plus an `evidence` block. When reranked, threshold on `confidence`, not the raw `score` — a reranked query with no genuine matches returns few or zero results rather than forcing weak ones. Without rerank, results carry only `score` (raw vector similarity) in vector order.
- `limit` caps results (default 10). `in`/`out` are seconds into the source media. Set `"preprocess": false` to send your `query` straight to the vector.

### Download a segment (export a clip)

To download a matching segment, just pass its `segment_id` from `/library/query` — that's all you need; the platform resolves the segment's in/out, trims it, and returns a downloadable MP4 clip. Results are cached, so re-requesting the same segment is instant.

```bash
curl -X POST https://api.glxstudio.com/library/export_clip \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "clips": [ { "segment_id": "sgm_5582" },
                    { "segment_id": "sgm_5601" } ] }'
```

Response:
```json
{ "clips": [
    { "segment_id": "sgm_5582", "duration": 15.5,
      "status": "ready", "cached": false,
      "url": "https://.../clips/...mp4?...", "expires_in": 10800 } ] }
```

- Identify each clip by its **`segment_id`** (the simplest path — it resolves its own in/out). For a **custom range** that isn't an analyzed segment, pass `{ "media_id", "in", "out" }` instead. Send a single object at the top level or a `clips` array (up to 25).
- `url` is a temporary download link, valid for **3 hours** (`expires_in` seconds), served as an attachment. `cached: true` means it was already trimmed (returned instantly).
- `status` is `ready` (URL present), `source_not_ready` (the media is still being processed), or `failed`. The clip is the proxy (preview) quality.

---

## Callback Webhook

Instead of polling, provide a `callbackUrl` when creating a job. The API will POST to your URL when the video is ready or fails.

**Success payload:**
```json
{
  "jobId": "sbv_123",
  "status": "completed",
  "videoUrl": "https://...",
  "thumbnailUrl": "https://...",
  "duration": 62.5
}
```

**Failure payload:**
```json
{
  "jobId": "sbv_123",
  "status": "failed",
  "error": "Error description"
}
```

Retry policy: 3 attempts with exponential backoff (5s, 30s, 120s). Return HTTP 2xx to acknowledge.

---

## Error Responses

All errors return JSON with `error` and `code` fields:

```json
{ "error": "Context store not found", "code": "NOT_FOUND" }
```

| HTTP Status | Meaning |
|---|---|
| 400 | Bad request — missing fields, invalid IDs, context store not ready, `VOICE_LANGUAGE_MISMATCH` |
| 401 | Unauthorized — missing or invalid Bearer token |
| 404 | Resource not found or does not belong to your organization |
| 429 | Rate limit exceeded — wait and retry |
| 500 | Server error — retry or contact support |

---

## Tips for AI Agents

- **Reuse context stores.** If the user has already uploaded a document, check `GET /context` before creating a new one.
- **Poll conservatively.** Every 15-30 seconds is sufficient. Avoid tight loops.
- **Show progress.** Tell the user generation takes 3-8 minutes. During `processing` the AI is writing the script; during `rendering` the video is being assembled.
- **Handle errors gracefully.** If a context store has `status: "error"`, suggest trying a different file. If a job fails, show the error message and offer to retry.
- **Duration formatting.** Convert seconds to mm:ss for display (e.g., `Math.floor(62.5/60)` + ":" + `Math.floor(62.5%60).toString().padStart(2,'0')` → "1:02").
- **URL expiration.** Video and thumbnail URLs expire after 24 hours. If presenting a result from a previous session, re-poll the job to get fresh URLs.
