Creatives & Video Animation
Creative decisions (approve/reject an ad image — what the launch branch waits for) and the "Animate this ad" flow that seeds an AI-generated video from an approved static. Video generation never happens in-request: the seed row waits for the scheduled tick, which submits to the video provider budget-capped and collects results across ticks.
| Endpoint | Auth | Rate limit | Body cap |
|---|---|---|---|
POST /api/creatives |
session (live) / public (demo) | 30/min per IP | 1 MB |
POST /api/videos |
session (live) / public (demo) | 10/min per IP + 10/min per instance | 1 MB |
POST /api/creatives
Approve or reject one creative. The decision core never checks kind — this same endpoint approves/rejects draft video rows after generation, not just statics.
Request (JSON)
{ "creativeId": "<uuid>", "decision": "approve" | "reject" } — exact lowercase match on decision.
Responses
| Status | Body |
|---|---|
| 200 (live) | { "ok": true, "status": "approved" | "rejected", "message": "Approved — this one's in the set we'll go live with." | "Noted — I'll leave that one out." } |
| 200 (demo) | same shape + "mode": "demo" for any creativeId (nothing stored) |
| 400 | `{ "ok": false, "error": "Send { creativeId, decision: "approve" |
| 404 (live) | { "ok": false, "error": "not_found" | "already_live" | "update_failed" } — 404 is the catch-all live-failure status; fixed tokens only, raw DB messages are server-logged, never returned (WSTG-ERRH-01) |
| 500 | generic sign-in-again message when the live handler throws — never masked as demo success |
Semantics
- Idempotent-ish, flippable: same decision twice = no-op success; approve↔reject flips are allowed (the launch plan reads statuses at gate time).
status='live'rows refuse withalready_live— running ads change through the approval gate, not here.- A foreign creative is invisible under RLS →
not_found(anti-enumeration). - On approve: fire-and-forget pipeline nudge (response doesn't wait; the cron tick is the guarantee).
- Rejecting a static whose video is still pending cancels that video on the next tick.
- Telemetry
creative_decided— decision/type/placement only, never overlay text.
POST /api/videos
"Animate this ad": seeds a kind='video' draft creative from an approved static. The client approved a still, not synthetic motion — so the generated video gets its own approval later via POST /api/creatives.
Request (JSON)
{ "creativeId": "<id of the approved static>" }
Responses
| Status | Body |
|---|---|
| 200 (live, new) | { "ok": true, "already": false, "message": "On it — I'll animate that ad from your photo. It'll appear here for your okay before anything runs." } |
| 200 (live, duplicate) | { "ok": true, "already": true, "message": "Already on it — that ad's video is in the works." } |
| 200 (demo) | { "ok": true, "mode": "demo", "message": "On it — …" } for any id |
| 400 | { "ok": false, "error": "That ad wasn't found." | "Only image ads can be animated." | "Approve the image ad first — then I can animate it." | "Couldn’t start the video — try again in a moment." } — fixed strings; DB detail is server-logged, never returned (WSTG-ERRH-01) |
| 411 / 413 / 429 / 500 | shared envelopes (conventions) |
Idempotency
Exactly one video per source static, enforced by a partial unique index (creatives_one_video_per_source, migration 0035). A duplicate POST hits the index and returns 200 { "already": true } — double-clicks and retries are safe.
The draft → generated lifecycle
- Seed (this endpoint): inserts
creativesrow —kind: 'video',status: 'draft',source_creative_id,placement: 'story',creative_type: 'video_from_photo',duration_seconds: 8,storage_path: null. NovideoIdis returned — poll the videos reader/screen for progress. - Generate (cron tick): the video pass runs last in the tick, two-phase — collect finished jobs first (bounded polls per tick; done → mp4 uploaded to the private bucket,
storage_pathstamped), then submit new drafts (CAS-claimed so concurrent ticks can't double-submit; budget-capped per tick). - Approve: the finished video appears on the Video screen and goes through
POST /api/creativeslike any other creative.
Gating: even fully configured, generation is a no-op until GEMINI_API_KEY is set — the provider resolves to null and rows wait honestly ("skipped" in tick results).
The generation backend — the Gemini/Veo ("Nano Banana") provider that actually produces the clip: its external Google API contract, config env vars, async lifecycle, and VERIFY-AT-LIVE status are documented in video-generation-provider.md. This page covers only the trigger (/api/videos) and approval (/api/creatives) surface.
Source:
app/api/creatives/route.ts,app/api/videos/route.ts,src/lib/live/creatives.ts,src/lib/video/live.ts,src/lib/video/render-videos.ts,supabase/migrations/0035_video_creatives.sql