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.

Ingested assets are ephemeral: they receive a one-hour retention window (expires_at) and are deduplicated while still valid (same organization and URL). For uploads you control directly, use the Files API and optional expires_at on createFile (up to 30 days).

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.

FieldTypeDescriptionDefault
htmlstringComposition HTML string
widthnumberOutput width in pixelsfrom composition
heightnumberOutput height in pixelsfrom composition
fpsnumberFrames per second (1–120)from composition
duration_msnumberDuration in millisecondsfrom composition
outputobjectOutput format (see below)mp4 / h264 / aac
metadataobjectArbitrary string → string key-value pairs{}
expires_atstringISO 8601 datetime; schedules automatic deletion. Max 30 days aheadnull (permanent)

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

FieldTypeDescription
idstringUUID for this render job
md5string | nullContent hash, if provided at create time
statusstringAlways "created" on successful create
metadataobjectKey-value metadata passed at create time
expires_atstring | nullISO 8601 datetime when the render will be deleted, or null if permanent

Status values

StatusDescription
createdJob created; HTML renders begin processing immediately
queuedBundle uploaded and waiting to initialize
renderingFrames are being captured and encoded
completeRender finished; download is available
failedRender failed

Retention and deletion

By default, renders are retained until you delete them. The expires_at field on the render record is null, which means the render is not scheduled for automatic removal.

Setting a TTL at create time

Pass expires_at as an ISO 8601 datetime when creating the render. The maximum allowed value is 30 days in the future. TTL can only be set at creation time — there is no API to change expires_at on an existing render.

import { createRender } from "@editframe/api";

// Expire in 7 days
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

const render = await createRender(client, {
  html: compositionHtml,
  expires_at: expiresAt.toISOString(), // ISO 8601, max 30 days ahead
});

console.log(render.expires_at); // "2026-05-15T12:00:00.000Z"
expires_atMeaning
null (omitted on create)Permanent — kept until you call deleteRender
ISO 8601 datetimeRender is deleted shortly after this time

expires_at is validated on every call to createRender. Omitting the field, or passing null, means permanent retention. Any other value must be a future ISO 8601 datetime no more than 30 days out, or POST /api/v1/renders returns 400 with one of:

StatuserrorCondition
400invalid_datetimeexpires_at must be a valid ISO 8601 datetime string
400must_be_futureexpires_at must be in the future
400exceeds_max_retentionexpires_at must be within 30 days of the current time

A background job reaps expired renders roughly every minute. Renders that are still queued, rendering, or in recovering state are never deleted out from under an in-flight job — they're skipped until they reach a terminal state.

Deleting a render

To remove a render immediately, call deleteRender with the render's id:

import { deleteRender } from "@editframe/api";

const result = await deleteRender(client, render.id);
console.log(result.success); // true

Deleting a render (via expiry or deleteRender) permanently removes the output file and all intermediate storage for that render. Active renders (status of queued, rendering, or recovering) cannot be deleted — the request fails until the render reaches a terminal state.

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,
  "expires_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`);
ContainerURL
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.