Media & delivery
V1 separates originals from delivery, and uses two ingest paths depending on media size. Upstream contracts move; verify config against Upstream docs before changing anything here.
Ingest paths
Section titled “Ingest paths”- Small media (PNG frames, small video) — multipart
POST /v1/frame-submissionswith metadata + media bytes in one call. - Large video — JSON
POST /v1/media-uploadsfirst to create an upload session; the agent then uploads directly to Cloudflare Stream and the submission is finalized when processing completes.
The full request and response shapes live in Media ingest and the corresponding api-reference pages.
Source of truth
Section titled “Source of truth”| Media | Original source of truth | Delivery |
|---|---|---|
| PNG frames | Supabase private bucket | Cloudflare Images signed/private variants. |
| Small accepted media | Supabase private bucket when config permits | Cloudflare Images or Stream, depending on type. |
| Large video | Cloudflare Stream primary | Cloudflare Stream signed playback. |
The Supabase bucket path for PNG originals is:
{tenant_id}/{agent_slug}/{channel_slug}/{yyyymmdd}/{frame_submission_id}/original.{ext}Supabase Storage rules
Section titled “Supabase Storage rules”- Bucket
frame-originalsis private. No browser ever holds a signed URL to an original. - Storage RLS policies on
storage.objectsconstrainbucket_id = 'frame-originals'AND object path prefix matches a tenant the caller is a member of. - Edge Functions request signed URLs with
expiresIn ≤ 300seconds when they need to fetch an original to derive a Cloudflare asset. - Uploads use
x-upsert: false; a resubmit creates a newframe_submission_idand a fresh path.
Cloudflare Images
Section titled “Cloudflare Images”- All images are uploaded with
requireSignedURLs: true. - V1 ships these named variants:
thumb-256,card-768,frame-1920,og-1200x630. Variants are declared in project config, not inferred from request shape. - Browser delivery URLs are minted server-side per request (Edge Function) with an
expclaim ≤ 1 hour. - Supabase stores the Cloudflare image ID and variant names; the app must not infer IDs from storage paths.
- If a variant fetch 404s, the UI falls back to the next-larger variant — never to the raw original.
Upstream: serve private images.
Cloudflare Stream
Section titled “Cloudflare Stream”POST /v1/media-uploadscalls Stream’s Direct Creator Uploads API withrequireSignedURLs: true, a configuredmaxDurationSeconds,allowedOriginsmatching the env’s domains, and a webhook target pointing at thestream-webhookEdge Function. Until theapi.ui.plan.aicustom domain is bound (see Wiring Phase 10 step 4), that target is the rawhttps://<project-ref>.supabase.co/functions/v1/stream-webhookURL; afterwards,https://api.ui.plan.ai/webhooks/streamaliases the same Edge Function.- The agent uploads directly to the returned one-shot URL; Stream is the source of truth once that URL is consumed.
- Playback uses Stream signed JWT tokens minted by an Edge Function with
exp ≤ 1h. The HLS manifest URL never embeds a raw video UID without a signature. - The Stream webhook is verified against
CF_STREAM_WEBHOOK_SECRET, then updatesframe_media.statusand emitsframe.media.readyon the ready transition,frame.media.failedon error, andframe.media.status_changedon intermediate state changes (see Realtime operations).
Upstream: direct creator uploads, securing your stream, webhooks.
Stream webhook payload
Section titled “Stream webhook payload”Cloudflare Stream calls the configured webhook URL whenever a video changes lifecycle state. The receiver is an Edge Function at /functions/v1/stream-webhook.
Headers we rely on:
| Header | Use |
|---|---|
Webhook-Signature | time=<unix>,sig1=<hex-hmac> — verify against CF_STREAM_WEBHOOK_SECRET (HMAC-SHA256 of time + "." + body). Reject if now - time > 300s. |
Webhook-Id | Idempotency key. Persist in stream_webhook_events(id pk, received_at) and short-circuit on replay. |
Minimum body fields the receiver consumes (Cloudflare may include more — extra fields are ignored):
{ "uid": "ea95132c15732412d22c1476fa83f27a", "readyToStream": true, "status": { "state": "ready", "errorReasonCode": "", "errorReasonText": "" }, "meta": { "frame_submission_id": "sub_01hyx0p9q2h3m4n5v6r7s8t9u0" }, "duration": 17.4, "input": { "width": 1920, "height": 1080 }}Mapping into our schema:
Cloudflare status.state | frame_media.status | Realtime event |
|---|---|---|
ready | ready | frame.media.ready |
inprogress / queued / pendingupload | media_processing | frame.media.status_changed (debounced) |
error | failed (with failure_reason from status.errorReasonText) | frame.media.failed |
Verification + mapping rules:
- Look up the row by
meta.frame_submission_idfirst (set on Direct Creator Upload creation), then fall back toframe_media.storage_key = uid(Cloudflare Streamuidis the canonical asset identifier for Stream rows — see Supabase SQL plan onframe_media.storage_key). - The handler is idempotent on
Webhook-Id: a replay must produce the same end state and emit no duplicate realtime events. - A 2xx response must be returned only after the DB write commits; on failure return 5xx so Cloudflare retries.
- The handler does not trust client-supplied state; it only reflects
status.statefrom the verified payload.
Upstream contract for the headers and replay semantics: Stream webhooks.
Config
Section titled “Config”Media size, MIME type, duration, and dimension limits live in project config. The API reads config and rejects oversized submissions before processing.