Renders
Create render jobs, track progress, and download the output.
Create a render
import { Client, createRender } from "@editframe/api";
const client = new Client(process.env.EDITFRAME_API_KEY);
const render = await createRender(client, {
html: `<ef-timegroup mode="contain" class="w-[1920px] h-[1080px]">
<ef-video src="https://assets.editframe.com/bars-n-tone.mp4"></ef-video>
</ef-timegroup>`,
});
// { id: "b3d2f1c0-…", md5: null, status: "created", metadata: {} }
Pass your composition as an inline HTML string. The composition runs in a headless browser; the resulting video is encoded and stored. To render a local project, use the cloud-render CLI command instead.
Asset ingest: ef-video, ef-audio, and ef-image elements with https:// sources are automatically downloaded, processed, and stored by the server before rendering begins. Relative or local src paths are not supported in inline HTML renders.
POST https://editframe.com/api/v1/renders
Request body
Width, height, fps, and duration are extracted from the composition automatically — you only need to supply them if you want to override what the composition declares.
| Field | Type | Description | Default |
|---|---|---|---|
html | string | Composition HTML string | — |
width | number | Output width in pixels | from composition |
height | number | Output height in pixels | from composition |
fps | number | Frames per second (1–120) | from composition |
duration_ms | number | Duration in milliseconds | from composition |
output | object | Output format (see below) | mp4 / h264 / aac |
metadata | object | Arbitrary string → string key-value pairs | {} |
Output formats
The output field selects the container and configures encoding. Omit it entirely to get the default MP4 output.
MP4
The only supported video container. Codec values are fixed — "h264" for video and "aac" for audio are the only accepted options.
output: {
container: "mp4",
video: { codec: "h264" },
audio: { codec: "aac" },
}
Still images
Still renders capture a single frame at duration_ms. Three formats are supported.
JPEG — lossy compression, best for photographic content:
output: {
container: "jpeg",
quality: 80, // 1–100; higher = better quality, larger file. Default: 80
}
PNG — lossless, suitable when transparency or pixel-perfect output is needed:
output: {
container: "png",
compression: 80, // 1–100; higher = smaller file, slower encode. Default: 80
transparency: false, // true to preserve the alpha channel. Default: false
}
WebP — modern format with good quality-to-size ratio, supports transparency:
output: {
container: "webp",
quality: 80, // 1–100; controls lossy quality. Default: 80
compression: 4, // 0–6; encoding effort (0 = fastest, 6 = smallest file). Default: 4
transparency: false, // true to preserve the alpha channel. Default: false
}
Bundle upload workflow
For compositions that can't be expressed as a single HTML string — for example, a Vite project with local JS modules — use the two-step bundle upload workflow.
1. Create the render
Call createRender without the html field:
import { createRender, Client } from "@editframe/api";
const client = new Client(process.env.EDITFRAME_API_KEY);
const render = await createRender(client, {});
// { id: "b3d2f1c0-…", status: "created" }
2. Upload the bundle
bundleRender runs a Vite build of your project and returns a ready-to-upload tar stream:
import { uploadRender } from "@editframe/api";
import { bundleRender } from "@editframe/api/resources/renders.bundle";
const tarStream = await bundleRender({ root: "./src", renderData: {} });
await uploadRender(client, render.id, tarStream);
root is the directory containing your index.html and vite.config. renderData is injected as the RENDER_DATA global into the build — pass {} if your composition doesn't use it.
After upload the render transitions to queued and then into the normal rendering pipeline. Progress and download work identically to HTML renders.
Asset references: For best results, ef-video, ef-image, and ef-audio elements should use file-id attributes pointing to pre-uploaded Editframe files — these are fully processed and stored on Editframe's infrastructure before the render starts. https:// URLs also work: the renderer will byte-range read from the original URL at render time, but this is less reliable than pre-uploading.
Bundle format requirements
If you're building the bundle yourself, uploadRender expects a gzip tar stream with index.html at the tarball root (not inside a subdirectory). The build output must be a single self-contained HTML file — all JS and CSS inlined. Using the tar npm package:
import { uploadRender, createReadableStreamFromReadable } from "@editframe/api/node";
import * as tar from "tar";
import { PassThrough } from "node:stream";
// dist/ must contain index.html at its root with all assets inlined
const tarStream = tar.create({ gzip: true, cwd: "/path/to/dist" }, ["."]);
const pass = new PassThrough();
tarStream.pipe(pass);
await uploadRender(client, render.id, createReadableStreamFromReadable(pass));
Response
| Field | Type | Description |
|---|---|---|
id | string | UUID for this render job |
md5 | string | null | Content hash, if provided at create time |
status | string | Always "created" on successful create |
metadata | object | Key-value metadata passed at create time |
Status values
| Status | Description |
|---|---|
created | Job created; HTML renders begin processing immediately |
queued | Bundle uploaded and waiting to initialize |
rendering | Frames are being captured and encoded |
complete | Render finished; download is available |
failed | Render failed |
Track progress
getRenderProgress opens a Server-Sent Events stream at GET /api/v1/renders/:id/progress and returns an async iterator:
import { getRenderProgress } from "@editframe/api";
const progress = await getRenderProgress(client, render.id);
for await (const event of progress) {
if (event.type === "progress") {
console.log(`${Math.round(event.data.progress * 100)}%`);
}
}
// Stream closes automatically when rendering is complete
event.data.progress is a 0–1 fraction. The iterator throws on error.
If you prefer to poll, GET /api/v1/renders/:id returns the current status and is safe to call on an interval.
Get render info
import { getRenderInfo } from "@editframe/api";
const render = await getRenderInfo(client, renderId);
GET https://editframe.com/api/v1/renders/:id
{
"id": "b3d2f1c0-…",
"status": "complete",
"fps": 30,
"width": 1920,
"height": 1080,
"duration_ms": 10000,
"created_at": "2026-05-08T00:00:00Z",
"completed_at": "2026-05-08T00:01:30Z",
"failed_at": null
}
Download
import { downloadRender } from "@editframe/api";
const response = await downloadRender(client, render.id);
const buffer = await response.arrayBuffer();
downloadRender fetches GET /api/v1/renders/:id.mp4. For still image renders, use client.authenticatedFetch with the matching extension:
const response = await client.authenticatedFetch(`/api/v1/renders/${render.id}.jpeg`);
| Container | URL |
|---|---|
mp4 | /api/v1/renders/:id.mp4 |
jpeg | /api/v1/renders/:id.jpeg |
png | /api/v1/renders/:id.png |
webp | /api/v1/renders/:id.webp |
Webhooks
For long jobs, use webhooks to receive a render.completed event instead of polling.