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.
| Method | Path | Result |
|---|---|---|
POST | /v1/studio/renders | 202 — 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-widthanddata-height(the canvas in pixels); - every clip (text, image, video, audio, any element) has
data-startanddata-durationin seconds and adata-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"
}| Field | Default | Meaning |
|---|---|---|
html | — | The composition (required). |
model | codai-render | The only render model. |
aspect_ratio | 16:9 | 16:9 (1920×1080), 9:16 (1080×1920) or 1:1 (1080×1080). Must match the root data-width/data-height, otherwise 400. |
quality | final | final or draft (see below). |
fps | 30 | 24, 25 or 30; final only. |
variables | — | HyperFrames composition variables. |
title | — | Up to 200 characters, stored with the asset. |
ai_label | automatic | corner, 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
draft | final | |
|---|---|---|
| Frame rate | 15 fps | 24 / 25 / 30 fps |
| Resolution | 540p on the short side | full 1080p |
| Loudness pass | no | normalised to −14 LUFS integrated, −1 dBTP |
| Price | half | full |
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:
| Value | Effect |
|---|---|
corner | A small "AI-generated" label (Romanian content: "Generat cu IA") in the bottom-right corner for the whole video. |
end_card | A final card of about 2 s with the label. |
none | No 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:
| Warning | Meaning |
|---|---|
no_audio | The video has no audio track. |
silent_audio | There is an audio track, but it is silent. |
lufs_off_target | Loudness 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 }
]
}| Field | Notes |
|---|---|
scenes | 1–30 scenes, at most 180 s in total (timeline_too_long). kind must match the asset's MIME type. |
scenes[].id | Optional, ^[a-z0-9-]{1,32}$, defaults to s1…sN. It stays the same across regenerations. |
scenes[].seconds | 2–30 s (default 5). A narrated scene lasts at least the narration plus 0.6 s. |
scenes[].fit | cover (default, crops) or contain (letterboxes). |
scenes[].narration | Synthesized now with narration_defaults.model and billed per call (narration_cost_micro_usd). Not available for EU data-residency accounts (403 residency_unavailable). |
transition | fade (default, 0.5 s crossfade) or none. |
music_asset_id, music_volume | One 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).
| Body | Effect | Cost |
|---|---|---|
{"visual": {"asset_id": "…"}} | Use one of your image or video assets instead. | none |
{"visual": {"prompt": "…"}} on an image scene | New image with codai-image, in the timeline's aspect ratio. | billed now |
{"visual": {"prompt": "…", "seconds": 6}} on a video scene | New 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):
| Quality | Per output second | Minimum |
|---|---|---|
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
| HTTP | code | When |
|---|---|---|
| 400 | bad_request | Invalid body, or aspect_ratio does not match the root data-width/data-height. |
| 400 | asset_not_found | A referenced asset is unknown, not yours, or not ready. |
| 400 | timeline_too_long | A timeline (or a regenerated version) would last more than 180 s. |
| 400 | ai_label_required | ai_label: "none" on a render with TTS narration (a synthetic voice must be labelled). |
| 403 | forbidden | The key is not allowed to use codai-render. |
| 403 | residency_unavailable | Narration or a generated video scene was requested on an EU data-residency account. |
| 402 / 429 | subscription_inactive / rate_limit_exceeded | Inactive subscription or spend cap reached. |
| 429 | too_many_renders | You already have 3 renders queued or rendering. |
| 404 | not_found | Unknown render id, or another user's render. |
| 409 | not_a_timeline | Scene regeneration on a render that has not succeeded or is not a timeline render. |
| 503 | renders_unavailable | Renders 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)Assets
Stored codai Studio media — list, fetch a fresh signed URL, download or delete the images, music and videos you generated.
Documents
codai Studio documents — build decks (PPTX), documents (DOCX) and spreadsheets (XLSX) from a JSON spec, preview them as PDF and PNG, and turn decks into narrated MP4s.