codai docs
Gateway

Renders

codai Studio renders — turn a HyperFrames HTML composition (with your Studio assets) into an MP4, then poll until it is ready.

A render turns a video described in HTML into an MP4. You send one HyperFrames composition, the gateway queues it on the render worker, and you poll until the video is ready. The public model id is codai-render.

MethodPathResult
POST/v1/studio/renders202 — the render is queued; returns its id and price
GET/v1/studio/renders/{id}Status, progress, and the MP4 asset when it has succeeded

HyperFrames compositions

A composition is a complete HTML document (index.html, up to 2,000,000 characters). It is laid out on a timeline with data-* attributes:

  • the root element has data-composition-id, data-width and data-height (the canvas in pixels);
  • every clip (text, image, video, audio, any element) has data-start and data-duration in seconds and a data-track-index (higher tracks are drawn on top);
  • CSS and JavaScript animations run as written. GSAP is allowed, as are Lottie, Three.js and anime.js — their CDN URLs are served from local copies inside the renderer, so pin a normal CDN URL and it works.

The output lasts until the last clip ends (data-start + data-duration), up to the 180-second limit.

<div data-composition-id="launch" data-width="1920" data-height="1080" style="background:#0b0b10">
  <img class="clip" src="asset:3f2b8c1e-0000-4000-8000-000000000001"
       data-start="0" data-duration="8" data-track-index="0" style="width:100%;height:100%;object-fit:cover" />
  <h1 id="title" class="clip" data-start="1" data-duration="6" data-track-index="1">codai Studio</h1>
  <audio class="clip" src="asset:3f2b8c1e-0000-4000-8000-000000000002"
         data-start="0" data-duration="8" data-track-index="2"></audio>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
  <script>gsap.from('#title', { opacity: 0, y: 40, duration: 1, delay: 1 });</script>
</div>

Using your Studio assets

Reference any asset you own as asset:<asset_id> inside a src, href or CSS url(...) — images you generated, music clips, videos, speech or uploads. Before queueing, the gateway checks that every referenced asset exists, belongs to you and is ready; otherwise the request fails with 400 asset_not_found and nothing is charged. At most 50 distinct assets per render.

For hits, whooshes and other sound effects, import a free CC0 sound from the sound-effects library and reference the resulting asset the same way.

For narrated videos, create the voice-over with POST /v1/audio/speech and { "model": "codai-speech", "store": true, "timestamps": true }: the response's asset.asset_id goes into <audio src="asset:<id>"> and its words ([{ word, start, end }]) give you the timing for captions (see text-to-speech).

variables (optional) is an object of HyperFrames composition variables, for templates that read values at render time.

Request

{
  "model": "codai-render",
  "html": "<div data-composition-id=\"launch\" data-width=\"1920\" data-height=\"1080\">…</div>",
  "aspect_ratio": "16:9",
  "quality": "final",
  "fps": 30,
  "title": "Launch teaser"
}
FieldDefaultMeaning
html—The composition (required).
modelcodai-renderThe only render model.
aspect_ratio16:916:9 (1920×1080), 9:16 (1080×1920) or 1:1 (1080×1080). Must match the root data-width/data-height, otherwise 400.
qualityfinalfinal or draft (see below).
fps3024, 25 or 30; final only.
variables—HyperFrames composition variables.
title—Up to 200 characters, stored with the asset.
ai_labelautomaticcorner, end_card or none — the visible AI label (see below).

Limits

  • 180 seconds of output at most;
  • 1080p at most (the three aspect ratios above);
  • 24, 25 or 30 fps;
  • 3 renders queued or rendering per user at a time — a fourth gets 429 too_many_renders.

Draft vs final

draftfinal
Frame rate15 fps24 / 25 / 30 fps
Resolution540p on the short sidefull 1080p
Loudness passnonormalised to −14 LUFS integrated, −1 dBTP
Pricehalffull

Iterate with draft, then render the version you keep as final.

AI label (EU AI Act art. 50(4))

ai_label burns a visible label into the MP4:

ValueEffect
cornerA small "AI-generated" label (Romanian content: "Generat cu IA") in the bottom-right corner for the whole video.
end_cardA final card of about 2 s with the label.
noneNo label.

When you omit it, the label is corner if the render uses TTS narration (a synthetic voice) or AI-generated images, and none otherwise. An explicit none on a render with TTS narration is rejected with 400 ai_label_required. Every render response (202 and GET) reports the resolved ai_label; the same option exists on creative renders and on batches (ai_label applies to every row render).

EU AI Act art. 50(4). If you publish AI-generated or manipulated video that resembles real people, places or events (a deepfake), you — the deployer — must disclose that it is artificially generated. The label helps you do that; it does not replace your own duty. See the Acceptable Use Policy.

Status and QA

GET /v1/studio/renders/{id} returns queued, rendering (with progress from 0 to 1), succeeded or failed.

{
  "id": "9c1d2e3f-0000-4000-8000-000000000010",
  "object": "studio.render",
  "status": "succeeded",
  "progress": 1,
  "quality": "final",
  "aspect_ratio": "16:9",
  "fps": 30,
  "error": null,
  "qa": { "duration_seconds": 31.2, "width": 1920, "height": 1080, "lufs": -14.1, "warnings": [] },
  "cost_micro_usd": "320000",
  "data": [{ "asset_id": "9c1d2e3f-0000-4000-8000-000000000010", "kind": "render", "url": "https://storage.googleapis.com/…", "mime_type": "video/mp4", "…": "…" }],
  "poster_url": "https://storage.googleapis.com/…poster.jpg…"
}

When it has succeeded, data holds the MP4 as one asset object (kind: "render", signed URL valid for 1 hour) and poster_url a JPEG poster frame. The render's id is also its asset_id, so it shows up in GET /v1/assets?kind=render and you can get a fresh URL later with GET /v1/assets/{id}.

Every render goes through a technical QA gate. A composition that fails linting, comes out longer than the limit, has the wrong dimensions, or is almost entirely black or frozen fails — error is <code>: <message> with codes such as lint_failed, render_failed, too_long, dimension_mismatch, black_video, frozen_video or timeout. Smaller issues do not fail the render; they appear in qa.warnings:

WarningMeaning
no_audioThe video has no audio track.
silent_audioThere is an audio track, but it is silent.
lufs_off_targetLoudness is more than 1 LU away from −14 LUFS after normalisation.

Scene timelines

POST /v1/studio/timelines builds a video from a list of scenes without writing HTML. Each scene is one of your ready image or video assets shown full frame; images get a slow zoom, videos keep their own audio at video_volume.

{
  "title": "Product teaser",
  "aspect_ratio": "16:9",
  "quality": "draft",
  "transition": "fade",
  "music_asset_id": "3f2b8c1e-0000-4000-8000-000000000003",
  "narration_defaults": { "model": "codai-speech", "language": "en-US" },
  "scenes": [
    { "id": "intro", "kind": "image", "asset_id": "3f2b8c1e-0000-4000-8000-000000000001", "seconds": 4,
      "prompt": "A sunrise over a mountain lake", "narration": { "text": "Every morning starts somewhere." } },
    { "kind": "video", "asset_id": "3f2b8c1e-0000-4000-8000-000000000002", "seconds": 6 }
  ]
}
FieldNotes
scenes1–30 scenes, at most 180 s in total (timeline_too_long). kind must match the asset's MIME type.
scenes[].idOptional, ^[a-z0-9-]{1,32}$, defaults to s1…sN. It stays the same across regenerations.
scenes[].seconds2–30 s (default 5). A narrated scene lasts at least the narration plus 0.6 s.
scenes[].fitcover (default, crops) or contain (letterboxes).
scenes[].narrationSynthesized now with narration_defaults.model and billed per call (narration_cost_micro_usd). Not available for EU data-residency accounts (403 residency_unavailable).
transitionfade (default, 0.5 s crossfade) or none.
music_asset_id, music_volumeOne of your audio assets under the whole timeline (volume 0–1, default 0.126).

The response is 202 with id, root_render_id, scene_count, duration_seconds and the price. Poll GET /v1/studio/renders/{id} as for any render; the render is billed the same way, once, when it has succeeded. A timeline render's status also carries timeline (its scenes, each with asset and narration_asset), parent_render_id and root_render_id. Add ?expand=assets to get signed URLs for the scene assets; without it their url is null.

An unknown, foreign, unfinished or wrongly typed asset fails with 400 asset_not_found, and the offending ids are listed in error.details.missing.

Regenerating one scene

POST /v1/studio/renders/{id}/scenes/{sceneId}/regenerate creates a new render with one scene replaced. The source render stays as it is; the new one has parent_render_id set to the source and keeps its root_render_id, so GET /v1/studio/renders?root_render_id=… lists every version. The source must be a succeeded timeline render (else 409 not_a_timeline).

BodyEffectCost
{"visual": {"asset_id": "…"}}Use one of your image or video assets instead.none
{"visual": {"prompt": "…"}} on an image sceneNew image with codai-image, in the timeline's aspect ratio.billed now
{"visual": {"prompt": "…", "seconds": 6}} on a video sceneNew codai-video clip (4, 6 or 8 s, default 8). The render stays queued with stage: "generating_scene" until a poll sees the clip.billed once, when the clip is ready
{"narration": {"text": "…"}}Re-narrate the scene.billed now
{"narration": {"asset_id": "…"}}Use one of your audio assets as the narration.none
{"narration": null}Remove the narration.none

Omitting prompt or text reuses the scene's stored one. The 202 response returns scene_cost_micro_usd (generation charged so far) next to the render price; the new render itself is billed when it has succeeded. Video and narration are not available for EU data-residency accounts.

GET /v1/studio/renders lists your renders newest first (before, limit 1–100, root_render_id), without polling them and without signed URLs.

Pricing

Billed once, on the first poll that sees the finished render, by output second (rounded up to whole seconds; up to 50 ms of encoder padding is never billed):

QualityPer output secondMinimum
final$0.01$0.05
draft$0.005$0.025

A 31.2 s final render costs 32 × $0.01 = $0.32. Failed renders cost nothing. The 202 response echoes the price as price_micro_usd_per_second and minimum_micro_usd; the charge appears as cost_micro_usd once the render has succeeded. Wallet accounts pay the same amount converted like other media (see Limits & pricing).

Errors

HTTPcodeWhen
400bad_requestInvalid body, or aspect_ratio does not match the root data-width/data-height.
400asset_not_foundA referenced asset is unknown, not yours, or not ready.
400timeline_too_longA timeline (or a regenerated version) would last more than 180 s.
400ai_label_requiredai_label: "none" on a render with TTS narration (a synthetic voice must be labelled).
403forbiddenThe key is not allowed to use codai-render.
403residency_unavailableNarration or a generated video scene was requested on an EU data-residency account.
402 / 429subscription_inactive / rate_limit_exceededInactive subscription or spend cap reached.
429too_many_rendersYou already have 3 renders queued or rendering.
404not_foundUnknown render id, or another user's render.
409not_a_timelineScene regeneration on a render that has not succeeded or is not a timeline render.
503renders_unavailableRenders are not configured on this gateway.

Examples

curl

ID=$(curl -s https://ai.codai.ro/v1/studio/renders \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d @render.json | jq -r .id)

while :; do
  R=$(curl -s "https://ai.codai.ro/v1/studio/renders/$ID" -H "Authorization: Bearer $CODAI_API_KEY")
  S=$(echo "$R" | jq -r .status)
  echo "$S $(echo "$R" | jq -r .progress)"
  [ "$S" = succeeded ] || [ "$S" = failed ] && break
  sleep 5
done
curl -L -o render.mp4 "$(echo "$R" | jq -r '.data[0].url')"

TypeScript SDK

import { readFile } from 'node:fs/promises';
import { Codai } from 'codai-sdk';
const client = new Codai({ apiKey: process.env.CODAI_API_KEY! });

const html = await readFile('index.html', 'utf8');
const r = await client.renders.render(
  { html, aspect_ratio: '16:9', quality: 'draft' },
  { pollIntervalMs: 5000, timeoutMs: 15 * 60_000 },
);
console.log(r.asset.url, r.qa?.warnings, r.costMicroUsd);

client.renders.create(body) and client.renders.retrieve(id) are the two raw calls; render() polls until succeeded and throws on failed or timeout.

Timelines and scene regeneration:

const job = await client.renders.createTimeline({
  quality: 'draft',
  scenes: [
    { id: 'intro', kind: 'image', asset_id: imageId, seconds: 4, narration: { text: 'Hello.' } },
    { kind: 'video', asset_id: videoId },
  ],
});
// …poll client.renders.retrieve(job.id) until succeeded, then:
const v2 = await client.renders.regenerateScene(job.id, 'intro', { visual: { prompt: 'The same lake at dusk' } });
const versions = await client.renders.list({ rootRenderId: job.root_render_id });
const detail = await client.renders.retrieve(v2.id, { expandAssets: true });

Python SDK

import os
from pathlib import Path
from codai import Codai

client = Codai(api_key=os.environ["CODAI_API_KEY"])
r = client.renders.render(
    {"html": Path("index.html").read_text(encoding="utf-8"), "aspect_ratio": "9:16", "quality": "final"},
    poll_interval=5,
    timeout=900,
)
print(r.asset["url"], r.qa["warnings"], r.cost_micro_usd)

render() raises RuntimeError when the render failed and TimeoutError when it does not finish in time; create() and retrieve() return the raw JSON.

Timelines and scene regeneration return the raw JSON too:

job = client.renders.create_timeline({
  "quality": "draft",
  "scenes": [
    {"id": "intro", "kind": "image", "asset_id": image_id, "seconds": 4, "narration": {"text": "Hello."}},
    {"kind": "video", "asset_id": video_id},
  ],
})
# …poll client.renders.retrieve(job["id"]) until succeeded, then:
v2 = client.renders.regenerate_scene(job["id"], "intro", {"narration": None})
versions = client.renders.list(root_render_id=job["root_render_id"])
detail = client.renders.retrieve(v2["id"], expand_assets=True)

On this page