Project One

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 with already_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

  1. Seed (this endpoint): inserts creatives row — kind: 'video', status: 'draft', source_creative_id, placement: 'story', creative_type: 'video_from_photo', duration_seconds: 8, storage_path: null. No videoId is returned — poll the videos reader/screen for progress.
  2. 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_path stamped), then submit new drafts (CAS-claimed so concurrent ticks can't double-submit; budget-capped per tick).
  3. Approve: the finished video appears on the Video screen and goes through POST /api/creatives like 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