Video Generation Provider (Gemini / Veo — "Nano Banana")
The backend that turns an approved static into a short story-format video ad. This page documents the provider integration — how the platform talks to Google, how to configure it, and the async lifecycle — as distinct from the consumer HTTP endpoints in creatives-and-videos.md (POST /api/videos to seed, POST /api/creatives to approve). Integrators and operators read this to understand and configure the AI video backend; the trigger/approval surface is the other page.
Naming: "Nano Banana" is Google's image model family. Nothing ships as "nano banana video" — the video path here is Veo 3.1 (production) with Gemini Omni Flash as a v2 candidate, both reached through the first-party Gemini Developer API on one
GEMINI_API_KEY. We use "the Gemini/Veo provider" throughout. (Design record:docs/discovery/2026-07-21-video-generation-integration.md.)
⚠️ VERIFY-AT-LIVE. This integration was built keyless — the request/response shapes below follow the documented Veo long-running-operation REST pattern but have not been exercised against the live API. Field names, the Omni Flash model id, native 1:1 support, and operation-retention timing all need one confirmation run the day a real
GEMINI_API_KEYlands (#111). Treat the wire shapes here as provisional until then.
What it is
- Input: the raw client photo referenced by the winning static — never the baked-text composite (image-to-video models garble rendered text, and the aspect is wrong). The hook text rides the Meta ad's own copy fields as metadata, not pixels.
- Output: a 9:16 story clip, text-free,
~8s, persisted to the privateclient-mediabucket. - Never in-request: generation is a minutes-long remote operation, so it runs entirely inside the scheduled cron tick — the HTTP
POST /api/videosonly seeds a draft row. See the lifecycle below. - Own approval: the client approved a still of themselves, not synthetic motion — the generated video is a
draftkind='video'row needing its own approval viaPOST /api/creatives.
The provider seam
A minimal injected interface (src/lib/video/video-provider.ts) with two implementations at birth — house NotConfigured* pattern. The pass reports whatever provider it was injected with; no vendor name is hardcoded (provider identity is provider.id / provider.model, recorded on the row and in billing telemetry), so a second provider (e.g. fal.ai for Seedance/Kling) is one implementation away.
interface VideoProvider {
readonly id: string; // 'gemini' | 'not_configured' | … (stamped on creatives.provider)
readonly model: string; // e.g. 'veo-3.1-generate-preview' (stamped in video_usage telemetry)
submit(input: VideoSubmitInput): Promise<{ jobId: string }>; // PAID + NON-IDEMPOTENT
poll(jobId: string): Promise<VideoPollResult>;
}
getVideoProvider()returns a liveGeminiVideoProviderwhenGEMINI_API_KEYis set, elsenull— a lazy, keyless-safe accessor mirroringgetAnthropic().- Keyless: rows simply wait (the pass skips them); the demo experience (a canned sample clip on the Video screen) is the reader's demo branch, not a provider.
The external Google API contract (what our code speaks)
Base: https://generativelanguage.googleapis.com/v1beta. Auth is the x-goog-api-key header on every call — never key-in-URL (a key in the download URL would leak into access logs, proxies, and anywhere the URL is stored).
1. Submit — POST /models/{model}:predictLongRunning
Returns a long-running operation name (our jobId), stored on the creative row. {model} is VIDEO_MODEL (default veo-3.1-generate-preview).
Request body (built by buildSubmitBody, exported pure so tests pin the shape without network):
{
"instances": [{
"prompt": "<deterministic motion prompt — camera + subject motion, NO on-screen text>",
"image": { "bytesBase64Encoded": "<base64 of the raw client photo>", "mimeType": "image/jpeg" }
}],
"parameters": {
"aspectRatio": "9:16",
"durationSeconds": 8,
"personGeneration": "allow_adult",
"negativePrompt": "on-screen text, captions, subtitles, logos, watermarks, speech, voiceover",
"resolution": "720p"
}
}
personGeneration: "allow_adult"is sent explicitly and unconditionally — image-to-video with people accepts only this value, and it is also the sole legal value in EU/UK/CH/MENA, so behavior is identical in every region. Photos featuring minors can be safety-blocked; that surfaces as the operation's error at collect time.negativePromptis belt-and-braces — documented on the Vertex Veo surface, ambiguous on the Gemini API surface. It's also phrased into the prompt itself, so if the live API 400s on the parameter, drop it and the bans still hold. (VERIFY-AT-LIVE.)resolutionis omitted unless set;1080pis same-price as720pon the standard tier — flip viaVIDEO_RESOLUTION=1080pafter one keyed 9:16+1080p check.- 1:1 is not native on either model — square placements need a server-side crop (deferred).
2. Poll — GET /{operationName}
The operationName is the exact jobId returned by submit.
| Provider state | Our VideoPollResult |
Row effect |
|---|---|---|
| HTTP 404 | { status: 'expired' } — operation aged out of the ~2-day retention window (PAID but lost) |
terminal render_error, no resubmit |
done: false |
{ status: 'pending' } |
left for the next tick |
done, error present |
{ status: 'failed', error } |
terminal render_error |
done, no video in response |
{ status: 'failed' } |
terminal render_error |
done, video uri present |
{ status: 'done', videoBytes, mime } (after download) |
storage_path stamped, video_usage metered |
The finished clip lives at response.generateVideoResponse.generatedSamples[0].video.uri.
3. Download — GET {uri} (redirect: 'follow')
Same x-goog-api-key header; redirects followed explicitly. Provider retention is ~2 days, so the pass downloads immediately and persists to our storage — the design never relies on the provider URL surviving.
Configuration (environment variables)
| Variable | Purpose | Default / unset behavior |
|---|---|---|
GEMINI_API_KEY |
Enables the live provider (the whole gate) | unset → keyless: video rows wait, UI shows the demo clip |
VIDEO_MODEL |
The Gemini/Veo model id ({model} in the submit path) |
veo-3.1-generate-preview |
VIDEO_RESOLUTION |
720p | 1080p (1080p is same-price on standard tier) |
720p |
VIDEO_DAILY_CLIP_CAP |
Per-instance daily submit ceiling (counted from rows, fail-closed) | 10 |
VIDEO_PRICE_JSON |
Overrides the model→USD/second preview map (JSON object) | built-in map below |
BILLING_MARKUP |
Client markup over raw model cost for the billing preview | 1.5 |
The GEMINI_API_KEY row is mirrored in the master env table in conventions §9; the VIDEO_* and BILLING_MARKUP rows are documented there too.
Model → price preview (USD per second)
Preview defaults from the 2026-07 Gemini pricing page (override via VIDEO_PRICE_JSON; unknown models price at 0). Video is priced per second, never per token — invoice ground truth is the seconds in the video_usage stream, and these dollars are a preview only.
| Model id | $/sec | 8s clip |
|---|---|---|
veo-3.1-generate-preview |
0.40 | $3.20 |
veo-3.1-fast-generate-preview |
0.10 | $0.80 |
veo-3.1-lite-generate-preview |
0.05 | $0.40 |
gemini-omni-flash-preview |
0.10 | $0.80 |
The async lifecycle (the cron pass)
renderPendingVideos (src/lib/video/render-videos.ts) runs after nurture in the tick — a slow video day can never starve customer emails. Two phases, both bounded per tick because they share the 300s cron budget:
- Collect first (≤ 2 per tick — each moves tens of MB through the function): poll outstanding jobs;
done→ download + upload toclient-media+ stampstorage_path+ emitvideo_usage;failed/expired→ terminalrender_error. - Submit second (≤ 2 per tick): the first PAID non-idempotent side effect in the platform, so it's guarded hard:
- Daily cap from rows, not telemetry —
VIDEO_DAILY_CLIP_CAPcounted fromkind='video'rows withsubmitted_atin the last 24h (telemetry is fail-open by design and must never gate spend). - CAS claim before submit — a conditional
UPDATE … WHERE provider_job_id IS NULL AND submitted_at stale, so of two racing ticks exactly one claims. A crash after the claim leaves a stale claim that retries afterSTALE_CLAIM_MS(30 min), bounded byMAX_ATTEMPTS(3). - Tenant-prefix guard on the photo path before the claim — the service-role download bypasses storage RLS, so a photo path escaping
<instanceId>/is refused and never burns an attempt. - A rejected source static cancels its pending video on the next tick (don't spend animating a look the client said no to).
- Daily cap from rows, not telemetry —
video_usage telemetry is features-only: { provider, model, seconds, clips } — never content. getUsageSummary reads it for the per-second cost preview × BILLING_MARKUP.
Compliance notes for live day
- Provenance: Veo output carries Google SynthID (invisible) — clean for paid Meta delivery.
- Meta AI disclosure: Meta's 2026 policy requires the AI-generated flag on AI-created ad video and scans for provenance. When Meta launch wiring lands, the disclosure flag must be a non-optional field in the approval gate — enforce there, not here.
- Consent: the approval card must say in plain language that this is AI-generated motion from the client's photo. Distinct from SynthID; it's an advertiser-side obligation for photorealistic synthetic people.
- Omni Flash caveat:
gemini-omni-flash-previewexplicitly does not support editing images of recognizable people — exactly our input — so Veo 3.1 stays the production path for person-photo animation; Omni Flash is a v2 candidate for non-person b-roll.
Source:
src/lib/video/video-provider.ts,src/lib/video/gemini-video-provider.ts,src/lib/video/render-videos.ts,src/lib/usage/meter.ts(videoPrices,billingMarkup),src/lib/video/README.md,docs/discovery/2026-07-21-video-generation-integration.md