Project One

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_KEY lands (#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 private client-media bucket.
  • Never in-request: generation is a minutes-long remote operation, so it runs entirely inside the scheduled cron tick — the HTTP POST /api/videos only 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 draft kind='video' row needing its own approval via POST /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 live GeminiVideoProvider when GEMINI_API_KEY is set, else null — a lazy, keyless-safe accessor mirroring getAnthropic().
  • 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.
  • negativePrompt is 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.)
  • resolution is omitted unless set; 1080p is same-price as 720p on the standard tier — flip via VIDEO_RESOLUTION=1080p after 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:

  1. Collect first (≤ 2 per tick — each moves tens of MB through the function): poll outstanding jobs; done → download + upload to client-media + stamp storage_path + emit video_usage; failed/expired → terminal render_error.
  2. 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_CAP counted from kind='video' rows with submitted_at in 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 after STALE_CLAIM_MS (30 min), bounded by MAX_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).

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-preview explicitly 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