Skip to content

Render API

A hosted Cloudflare service that takes a .cm source and returns a rendered single frame or video. The render runs inside a headless Chromium that loads the same viewer used by /editor and the homepage preview, so cloud output matches what authors see locally.

Base URL: https://api.cmotion.org

Machine-readable spec: https://api.cmotion.org/openapi.json (OpenAPI 3.1, served by the Worker itself, CORS-open). Point client generators or agents at this for the structured contract.

The API is async: every job goes through a pending → ready state machine. You POST a source, get back a job_id, poll GET /v1/jobs/<id> until the status flips, then GET the file URL the response gives you.

Sources are expected to pin their runner with runner "<semver>"; at the top (e.g. runner "0.0.1";). Absent → the loader uses the latest available runner. See Grammar § “Runner pin”.

Request body:

{
"source": "runner \"0.0.1\"; use std.shapes.*; scene s() -> Frame { rect(width: 1920px, height: 1080px, fill: #ff3399) }",
"params": {
"fps": 30,
"duration": 6,
"width": 1920,
"height": 1080
}
}
FieldTypeRequiredNotes
sourcestringyesThe .cm source. Max 64 KiB.
params.fpsintegernoDefault 30.
params.durationnumber (seconds)noDefault reads from scene s(duration: Duration = …).
params.widthintegernoDefault 1920.
params.heightintegernoDefault 1080.

Response 202:

{ "job_id": "9b6ace5e-d245-463b-b30e-6f1a7b73030b", "status": "pending", "kind": "video" }

Request body:

{
"source": "runner \"0.0.1\"; …",
"params": { "at": 1.5, "width": 1920, "height": 1080 }
}

params.at is the time in seconds to seek to before screenshotting. Default 0.

Response 202: same shape as /v1/render, with "kind": "frame".

While pending:

{ "job_id": "9b6…", "kind": "frame", "status": "pending", "created_at": 1779279543535 }

When ready:

{
"job_id": "9b6…",
"kind": "frame",
"status": "ready",
"url": "/v1/outputs/9b6ace5e-d245-463b-b30e-6f1a7b73030b.png",
"mime": "image/png",
"created_at": 1779279543535,
"completed_at": 1779279547078
}

On failure (HTTP 500):

{ "job_id": "9b6…", "kind": "frame", "status": "error", "message": "PAR100 unexpected token" }

On unknown id (HTTP 404):

{ "error": "not_found" }

The url field is a path on this same host — prepend https://api.cmotion.org to get the full URL.

GET /v1/outputs/<filename> — fetch the render

Section titled “GET /v1/outputs/<filename> — fetch the render”

Returns the rendered file inline with the appropriate content-type (image/png or video/mp4). Cached for 24 hours.

Returns ok\n. Use for uptime checks.

A typical frame takes 3–6 s end-to-end including container cold-start; a typical video takes about 3 × duration plus a cold-start. Recommended polling interval: 2 s. Files older than 24 h get garbage-collected from R2.

  • One concurrent container instance, ever. Multiple incoming requests share the same render queue — long videos make subsequent requests wait.
  • Max render duration: 5 min per job.
  • Max source size: 64 KiB.
  • No authentication in v0; protected only by Cloudflare’s edge rate limiting.
  • Container output is deleted from R2 after 24 h. Save what you want.

Every renderer version is frozen in git under containers/<version>/. A .cm source pinned to runner "0.0.1"; is guaranteed to produce the same bytes against that runner forever, even after the live stack moves forward to 0.0.2 and beyond. Bumping the runner pin is an explicit opt-in to new behaviour.

Terminal window
SRC='runner "0.0.1";
use std.shapes.*;
scene quick() -> Frame {
let bg = rect(width: 1920px, height: 1080px, fill: oklch(0.10, 0.04, 280));
let fg = rect(width: 300px, height: 300px, fill: #ff3399);
compose [bg, fg]
}'
# Enqueue
JOB=$(curl -sS -X POST https://api.cmotion.org/v1/frame \
-H "content-type: application/json" \
-d "$(jq -n --arg s "$SRC" '{source:$s}')")
ID=$(echo "$JOB" | jq -r .job_id)
# Poll
until RES=$(curl -sS https://api.cmotion.org/v1/jobs/$ID) \
&& STATUS=$(echo "$RES" | jq -r .status) \
&& [ "$STATUS" != "pending" ]; do
sleep 2
done
# Download
URL=$(echo "$RES" | jq -r .url)
curl -sS -o quick.png "https://api.cmotion.org${URL}"
const API = "https://api.cmotion.org";
async function renderFrame(source: string, at = 0): Promise<Blob> {
const enq = await fetch(`${API}/v1/frame`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ source, params: { at } }),
});
if (!enq.ok) throw new Error(`enqueue failed: ${enq.status}`);
const { job_id } = await enq.json();
while (true) {
await new Promise(r => setTimeout(r, 2000));
const res = await fetch(`${API}/v1/jobs/${job_id}`);
const body = await res.json();
if (body.status === "ready") {
const file = await fetch(`${API}${body.url}`);
return file.blob();
}
if (body.status === "error") {
throw new Error(`render failed: ${body.message}`);
}
}
}

message field surfaces upstream diagnostics from the CLI parser (PAR…), lowerer (LWR…), namer (NAM…), and other passes — see Diagnostics for the full namespace table. Render-stage failures surface a short text reason on message with no specific code in v0.

v0. The shape above is stable, but expect additions: asset uploads (POST /v1/assets), an MCP wrapper for one-shot agent use, rate-limit headers, queue-depth in pending responses, authentication. Removal of fields will not happen without a versioned route.