# Renders (/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`.

| 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 [#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.

```html
<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 [#using-your-studio-assets]

Reference any [asset](./assets.mdx) 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](./sfx.mdx) 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](./embeddings-audio.mdx#word-timestamps-and-stored-speech)).

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

## Request [#request]

```json
{
  "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 [#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-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-eu-ai-act-art-504]

`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](./creatives.mdx#preview-and-render) 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](https://codai.ro/acceptable-use).

## Status and QA [#status-and-qa]

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

```json
{
  "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 [#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`.

```json
{
  "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 [#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 [#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](./limits-pricing.mdx)).

## Errors [#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 [#examples]

### curl [#curl]

```bash
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 [#typescript-sdk]

```ts
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:

```ts
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 [#python-sdk]

```python
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:

```python
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)
```
