API guide
Everything the studio UI does, it does through this HTTP API β there are no private endpoints. Post a prompt, poll a job, download an mp4.
http://127.0.0.1:8000 β the server binds to loopback by
default and has no authentication, because it is designed to run on your
own machine. Before exposing it to anything else, read
v2 REST API
Going to production.
The shape of a job
A render is asynchronous. POST /api/jobs returns immediately with a job
id; the work happens in a background worker. You poll GET /api/jobs/{id}
until status is terminal, then fetch the video.
A 90-second 1080p render takes roughly 4β6 minutes. Draft quality is about four times faster and is the right choice while you are integrating.
Quickstart
Three ways to do the same thing: create a render and wait for the file.
# 1. create the job
JOB=$(curl -sX POST http://127.0.0.1:8000/api/jobs \
-H 'Content-Type: application/json' \
-d '{
"prompt": "Explain how vaccines train the immune system",
"target_seconds": 90,
"orientation": "horizontal",
"quality": "standard"
}' | jq -r .id)
# 2. poll until it finishes
while :; do
read -r STATUS PROGRESS STAGE <<<"$(curl -s http://127.0.0.1:8000/api/jobs/$JOB \
| jq -r '"\(.status) \(.progress) \(.stage)"')"
echo "$STATUS $PROGRESS $STAGE"
case "$STATUS" in done|failed|cancelled) break;; esac
sleep 3
done
# 3. download it
curl -s -o video.mp4 "http://127.0.0.1:8000/api/jobs/$JOB/video?download=true"
Job lifecycle
status moves forward through these values. Treat anything that is not
terminal as "still working" rather than switching on each one β new stages may be
added.
| Status | Meaning | Progress band |
|---|---|---|
queued | accepted, waiting for a worker | 0% |
planning | reading the source, the model is writing the script | 0β14% |
composing | laying out boards and aligning strokes to words | 14β18% |
preview | capturing one still per scene | 18β25% |
rendering | drawing frames in the browser | 25β97% |
encoding | finalising the mp4 | 97β100% |
done | terminal β the video exists | 100% |
failed | terminal β see error | β |
cancelled | terminal β you asked it to stop | β |
preview_files is populated at about 25%, long before the encode
finishes. If you are building a UI, show those stills immediately β the user
sees what the video will look like within seconds instead of minutes.
Creating a render
POST /api/jobs β JSON body, or multipart when attaching a document.
| Field | Type | Default | Notes |
|---|---|---|---|
prompt | string | β | What the video should explain. Required unless a file is attached. |
target_seconds | int 15β900 | 90 | A target, not a guarantee β narration length decides the real duration. |
tone | string | plain English | Free text, passed to the model. |
format | enum | landscape | See screen formats. |
quality | draft Β· standard Β· high | standard | Draft halves the resolution and encodes fast. |
fps | int 12β60 | 30 | Frame count scales linearly with render time. |
width / height | int | from format | Explicit override; must be given together. |
speech_rate | float 0.6β1.6 | 1.0 | Higher is faster narration, so a shorter video. |
use_images | bool | true | Ignored unless an image provider is configured. |
previews | bool | true | Set false to skip scene stills and save a few seconds. |
provider | string | saved setting | Override the LLM for this job: groq, anthropic, openai, ollama, heuristic. |
style | object | {} | See style & hand. |
The response is the full job object with status: "queued". Unknown
fields are rejected with 422 rather than ignored, so a typo in an
option never silently produces the wrong video.
Screen formats
Aspect ratio is not just an encoder setting β the layout engine composes differently for each shape. Columns collapse into rows on a phone, flow diagrams run downwards instead of across, and the type grid changes so text is sized by whichever edge runs out first. The planner is also told the exact canvas dimensions, so it places elements correctly for that shape.
| orientation | Dimensions | Use for |
|---|---|---|
"horizontal" (default) | 1536 Γ 1024 | YouTube, standard video content, embeds |
"vertical" | 1024 Γ 1536 | TikTok, Instagram Reels, YouTube Shorts |
orientation is the simple control and overrides format.
Use format when you need an exact ratio β widescreen
(1920Γ1080), shorts (1080Γ1920), square,
portrait_45, tablet. Explicit
width/height override both.
Live from GET /api/assets/formats. The draft quality
preset halves each dimension; standard and high render
at the base size.
Style & hand
Everything under style is optional; anything omitted falls back to your saved defaults.
{
"prompt": "How does a heat pump work?",
"format": "mobile",
"style": {
"ink": "#1b1b1b",
"accent": "#e5644d",
"background": "#fbfbf7",
"font": "kalam", // id from GET /api/assets/fonts
"hand": "hand:7628d3fd", // or "default", or "none"
"hand_scale": 1.2,
"stroke_width": 3.0,
"paper_texture": true
}
}
Where drawings come from
An svg element resolves through a chain: the 51-icon built-in
library on a confident match, then a keyword search across roughly 200,000
open line icons that need no account, then SVG the model authored itself,
then a neutral placeholder with a job warning. The search is ranked toward
true stroke collections β tracing a filled glyph only gives you its outline.
GET /api/assets/sources lists
them and reports whether the lookup is on;
DELETE
/api/assets/sources/cache drops everything fetched so far.
Only the concept word is sent β never the prompt or the source document β
and web_assets: false in settings keeps every request local.
Named styles
GET /api/assets/styles returns eleven
complete looks β chalkboard_white, chalkboard_color,
chalkboard_black, whiteboard, modern_minimal,
technical, sharpie, playful,
editorial, illustrations, pencil β plus the
two axes they are built from, which you can set independently:
| Axis | Values |
|---|---|
canvas | paper_dots paper_plain
graph ruled slate craft |
sketch | marker chalk
pencil fineliner brush |
Choosing the hand
| Value | Result |
|---|---|
"default" | the built-in vector hand, tinted with your accent colour |
"none" | ink appears with nothing holding the pen |
"hand:<id>" | an uploaded image β id from GET /api/assets/hands |
Uploading a hand
The image must have a transparent background; an opaque one is rejected rather than accepted and then rendered as a rectangle covering the board. The one thing the file cannot tell us is where the pen tip is, so it is stored per asset as a fraction of the image and can be corrected afterwards.
curl -X POST http://127.0.0.1:8000/api/assets/hands \
-F 'file=@hand.png' -F 'name=My hand' \
-F 'tip_x=0.05' -F 'tip_y=0.03'
# move the tip later (the studio UI does this when you click the picture)
curl -X PATCH http://127.0.0.1:8000/api/assets/hands/7628d3fd \
-H 'Content-Type: application/json' \
-d '{"tip_x": 0.07, "tip_y": 0.04, "scale": 1.1}'
tip_x / tip_y are 0β1 fractions measured from the
top-left of the image. That point is placed exactly on the ink.
Rendering from a document
To attach a PDF, Markdown or text file, send multipart instead of JSON. The
options go in a payload field as a JSON string, and the file in
file. A prompt alongside the document steers how it is used.
curl -X POST http://127.0.0.1:8000/api/jobs \
-F 'payload={"prompt":"Summarise this for a general audience","target_seconds":120,"format":"square"}' \
-F 'file=@paper.pdf'
Accepted: .pdf, .txt, .md,
.rst β up to 25 MB. Text is extracted, de-hyphenated and truncated
to 60,000 characters. A scanned PDF with no text layer fails at once with a clear
message rather than producing an empty video.
Images
The language model never calls your image API. It writes a short visual query as an element's content β βhoneybee on a lavender flowerβ β and the engine resolves that query before rendering. Results are cached by query, a failed lookup degrades to an icon instead of failing the render, and the model never holds your key.
Pointing it at your own API
curl -X PUT http://127.0.0.1:8000/api/settings \
-H 'Content-Type: application/json' \
-d '{
"image_provider": "custom",
"image_custom_url": "https://api.example.com/search?q={query}&limit=1",
"image_custom_path": "results[0].url",
"image_custom_header": "X-Api-Key",
"image_api_key": "sk-..."
}'
# verify it end to end β fetches one real image
curl -X POST 'http://127.0.0.1:8000/api/assets/images/test?query=a%20red%20apple'
| Setting | Notes |
|---|---|
image_custom_url | {query} is replaced with the model's phrase, URL-encoded. |
image_custom_path | Dotted or bracketed path to the image URL: results[0].urls.regular. Leave empty if your endpoint returns image bytes directly. |
image_custom_header | Header carrying the key. Authorization gets a Bearer prefix; anything else is sent verbatim. |
Built-in alternatives: unsplash, pexels (search) and
openai (generation). Or skip the API entirely and upload your own via
POST /api/assets/media β those are matched by name and keyword.
Narration & music
Four ways to give a video a voice. All of them re-time the boards to the audio.
| Source | How |
|---|---|
| Silent (default) | Timings come from a prosody model. tts_provider: "none". |
| Synthesised | tts_provider: "fish" with an optional tts_voice.
Scenes are spoken separately so each duration is exact. |
| Your recording | POST /api/jobs/{id}/audio
with the audio and an optional .srt/.vtt. |
| Webcam | POST /api/jobs/{id}/presenter β
the audio drives the timing, the video becomes an inset. |
# attach a voice recording, with a transcript for exact timing
curl -X POST http://127.0.0.1:8000/api/jobs/$JOB/audio \
-F 'file=@narration.m4a' -F 'transcript=@narration.srt'
# or a webcam take, shown as a circle in the bottom-right corner
curl -X POST http://127.0.0.1:8000/api/jobs/$JOB/presenter \
-F 'file=@take.webm' -F 'shape=circle' -F 'position=bottom_right' -F 'size=0.30'
# then re-render to build it in
curl -X POST http://127.0.0.1:8000/api/jobs/$JOB/rerender \
-H 'Content-Type: application/json' -d '{"revoice": true}'
Without a transcript the recording is split across scenes in proportion to how much each one says; with one, each scene starts when its words are actually spoken. Either way the response reports the duration so you can check it.
Music
Pass music_track (an id from GET /api/assets/music),
music_gain in dB and music_duck. When there is narration
the bed is side-chain compressed against it, so it drops out of the way of speech.
Upload your own with POST
/api/assets/music.
Editing a render
A finished render is not final. The timeline is a document, and every change is a
small validated operation applied to it. Editing never renders β turning
edits into a new mp4 is an explicit /rerender, so a burst of tweaks
costs one render rather than one each.
# move an element, recolour it, and pin its timing β in one request
curl -X POST http://127.0.0.1:8000/api/jobs/$JOB/ops \
-H 'Content-Type: application/json' \
-d '{"ops": [
{"op": "set_box", "scene": "s02", "element": "s02-e03",
"x": 0.12, "y": 0.30, "w": 0.50, "h": 0.14},
{"op": "set_style", "scene": "s02", "element": "s02-e03", "color": "accent"},
{"op": "set_timing", "scene": "s02", "element": "s02-e03",
"start": 1.2, "duration": 1.8}
]}'
The response lists what was applied and what was
rejected, each in plain English, plus the new
revision and the full timeline. A bad operation is reported and
skipped, never fatal β the rest of the batch still applies.
| Operation | Fields |
|---|---|
set_narration | scene, text |
set_text | scene, element, text |
set_box | scene, element, x/y/w/h |
set_timing | scene, element, start, duration β seconds within the scene |
set_style | color, font_scale, emphasis, align, draw, frame |
add_element Β· remove_element | type, text, anchor, optional box |
add_image | scene, text (library name or id) |
set_camera | zoom, center_x, center_y |
set_video_style | ink, accent, background, hand |
remove_scene Β· reorder_scenes | scene Β· order (all ids) |
set_box and set_timing mark the element
locked. A later edit that re-times the whole video β rewriting
narration, say β leaves locked elements exactly where you put them.
Editing by chat
POST /api/jobs/{id}/chat with a
message. The model is shown a compact summary of the timeline β ids,
boxes, times, narration β and returns operations, which are validated and applied
exactly like the ones above. It never rewrites the timeline directly.
curl -X POST http://127.0.0.1:8000/api/jobs/$JOB/chat \
-H 'Content-Type: application/json' \
-d '{"message": "make the heading in scene 2 bigger and cut the last scene"}'
# { "reply": "β¦", "applied": ["s02-e01: size Γ1.6", "removed scene s05 β¦"],
# "rejected": [], "revision": 3, "timeline": { β¦ } }
POST /api/jobs/{id}/chat/image uploads
an image into the conversation; pass scene to drop it straight onto a
board. GET /api/jobs/{id}/chat reads
the history, DELETE clears it.
Live frames
GET /api/jobs/{id}/frame?t=12.5 renders
one frame of the current timeline. It is served from a browser kept warm
per job, so scrubbing costs about 80 ms a frame rather than the two seconds a
cold launch would. Any edit invalidates it automatically.
PUT /api/jobs/{id}/timeline replaces the
whole document if you would rather generate it yourself.
Polling & progress
There are no webhooks. Poll GET /api/jobs/{id} every 2β4 seconds; the
studio UI uses 1.4s. Each response carries a live stage string that
is safe to show a user directly β during rendering it includes a frame counter
and an ETA.
{
"id": "6617b867295749c8b473dfb26a2ccc6d",
"status": "rendering",
"progress": 0.612,
"stage": "rendering frame 457/909 (11.8 fps, ~38s left)",
"title": "How Vaccines Work",
"duration": 30.3,
"scene_count": 4,
"element_count": 13,
"frames": 909,
"preview_files": ["previews/s01.jpg", "previews/s02.jpg"],
"warnings": [],
"logs": [{ "t": 1788555056.38, "level": "info", "msg": "plan: 4 scenes via groq" }]
}
logs is the same stream the studio shows, capped at the last 400
lines. warnings collects soft problems β a repaired anchor, an icon
that fell back to a placeholder, an image lookup that failed. None of them stop a
render, and all of them are worth surfacing.
To stop a job: POST
/api/jobs/{id}/cancel. Cancellation is cooperative β the pipeline
checks between stages and between frames, so it takes effect within a second or
two rather than instantly.
Fetching the video
| Endpoint | Returns |
|---|---|
GET /api/jobs/{id}/video | the mp4. Supports HTTP Range, so it can be used directly as a <video src>. |
GET /api/jobs/{id}/video?download=true | same bytes with a Content-Disposition filename from the title. |
GET /api/jobs/{id}/poster | a still for thumbnails and <video poster>. |
GET /api/jobs/{id}/previews/{name} | one scene preview; names come from preview_files. |
GET /api/jobs/{id}/timeline | the generated timeline JSON. |
Artefacts live under storage/jobs/{id}/ and are deleted with the job.
DELETE /api/jobs/{id} refuses while
a job is still running β cancel first.
Streaming scenes
A whiteboard video is watchable long before it is finished. The board for scene one
is settled the moment its narration has been spoken β nothing in scene four can
change it β so POST
/api/v2/videos/stream hands each scene over as it is made instead of
making the viewer wait for an encode. First scene in a few seconds, rather than the
length of the whole render.
It takes the same body as /generate and returns
text/event-stream. What travels is the timeline, not pixels:
a few kilobytes of JSON per scene against megabytes of H.264, and the receiver
draws it at its own resolution with the same renderer this app uses. There is a
working player at /stream β its source is
web/stream.js, written to be copied.
| Event | Carries |
|---|---|
meta | once, first: canvas size, style, fonts, hand, scene count. |
scene | one per scene, in order: its elements with absolute times, any drawings it is the first to use, and the URL of its voice clip. |
progress | stage and fraction, between scenes. |
done | total duration and scene count. |
error | the stream stops here, with a reason. |
Three things a client has to get right
-
It is a POST, so
EventSourcecannot be used. Parse the events offfetch's body reader β the format is a name line, a data line, and a blank line. - Scene times are final when sent. Each scene is placed after the ones before it and nothing later moves it, so append rather than reconcile.
-
Drawings are sent once, with the first scene that uses them.
Accumulate
assetsacross events; do not replace them.
const res = await fetch('/api/v2/videos/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': KEY },
body: JSON.stringify({ prompt: 'Solve 2x + 6 = 20, showing every step' }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '', meta = null, scenes = [], assets = {};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let cut;
while ((cut = buffer.indexOf('\n\n')) !== -1) {
const block = buffer.slice(0, cut);
buffer = buffer.slice(cut + 2);
const name = block.match(/^event: (.+)$/m)?.[1];
const data = block.match(/^data: (.+)$/m)?.[1];
if (!data) continue;
const payload = JSON.parse(data);
if (name === 'meta') meta = payload;
if (name === 'scene') {
Object.assign(assets, payload.assets); // accumulate, never replace
scenes.push(payload.scene);
// hand it to the renderer and start playing β the rest is still coming
await engine.load({ timeline: { ...meta, scenes }, assets,
hand: meta.hand, fonts: meta.fonts });
new Audio(payload.audio.url).play();
}
}
}
Drawing it yourself
The renderer is served at /renderer/src/engine.js. It is a pure
function of time β engine.seek(t) draws the board exactly as it stands
at t, with no playback state of its own β which is what lets the
encoder screenshot frames out of order, and equally lets you drive it from
requestAnimationFrame against your own clock. Fonts, pictures and
voice clips are all served from the same origin as the stream, so nothing needs
CORS.
Voice clips are at
GET /api/v2/videos/{id}/scenes/{scene_id}/audio and need no API key β
an <audio> element cannot send a header, and requiring one would
mean no client could ever play the sound. The URL carries the random video id, the
same protection the finished mp4 has.
Turning a stream into a file
A streamed job is a finished render that was never encoded: it keeps its timeline,
its per-scene clips and a mixed voice track, so it appears in the library badged
streamed, opens in the editor, and can be encoded whenever you want one β
POST /api/jobs/{id}/rerender with
{"revoice": false}. The narration already exists, so that only draws
and encodes; it does not pay for the voice a second time. Reach for
/generate instead when you knew you wanted a file from the start.
The timeline
The timeline is the contract between the planner and the renderer, and it is a plain JSON document. You can fetch it, edit it, and it fully describes the video β every position, colour and millisecond. Coordinates are normalised 0β1, so the same timeline renders identically at 720p or 4K.
{
"version": "1.0",
"title": "How Vaccines Work",
"width": 1920, "height": 1080, "fps": 30,
"duration": 30.3,
"style": { "ink": "#1b1b1b", "accent": "#e5644d", "hand": "default" },
"scenes": [{
"id": "s02",
"title": "The Setup",
"narration": "A vaccine shows the body a harmless piece of a pathogen.",
"start": 4.07, "duration": 11.55,
"narration_start": 1.28, // the heading is written before anyone speaks
"camera": { "zoom": 1.03, "center": { "x": 0.5, "y": 0.47 } },
"transition": "erase",
"elements": [{
"id": "s02-e03",
"type": "text", // text | svg | shape | arrow | highlight | image
"content": "harmless piece",
"box": { "x": 0.077, "y": 0.42, "w": 0.868, "h": 0.126 },
"draw": "write", // write | fade | pop | wipe | instant
"anchor": "harmless piece", // verbatim narration phrase this lands on
"start": 5.79, "duration": 1.25,
"align": "left", "font_scale": 1.0, "emphasis": false
}]
}]
}
anchor is the interesting field. The model marks each
drawing with a verbatim phrase from its own narration; the aligner converts that
into an absolute time, and starts the stroke slightly before the word so
the ink lands as it is spoken. Anchors that do not appear in the narration are
repaired or dropped, and every repair shows up in warnings.
Because an anchor is tied to the audio, it also decides the order things are drawn
in. order only sequences elements that have no anchor and breaks ties
between elements anchored to the same phrase β an order that
contradicts the anchors is ignored, and said so in warnings. The hand
draws one thing at a time, so a scene with more ink in it than there are words to
cover reports that too, naming the stroke that falls furthest behind.
Errors
Errors are always JSON with a detail field β a string, or a list of validation problems.
| Status | When |
|---|---|
400 | no prompt and no file; unsupported file type; empty upload |
404 | unknown job or asset; artefact not produced yet |
409 | cancelling a finished job, or deleting a running one |
413 | document over 25 MB, or image over 12 MB |
422 | schema validation β unknown field, out-of-range value, bad enum |
416 | malformed Range header on a video request |
POST /api/jobs returns 201 as long as the request was
valid. If the render itself fails, the job reaches status: "failed"
with a human-readable error and a full traceback tail. Check the
status, not just the response code.
Endpoint reference
Live from the running server's OpenAPI schema. The interactive version is at /docs, and the raw schema at /openapi.json.
v2 REST API
Dynamo.sh exposes a second API surface at /api/v2/videos that
follows the widely-used whiteboard-video REST contract, so an existing
integration can point at a self-hosted instance by changing one base URL.
Same paths, same parameter names, same defaults, same validation β including
which combinations return 422.
x-api-key on every v2 request. A missing header returns
403 with the contract's own message. With no keys configured
the instance accepts any non-empty key β fine locally, not for anything
reachable from outside. Add SHA-256 digests to api_keys in
settings to lock it down.
| Method | Path | Purpose |
|---|---|---|
POST | /api/v2/videos/generate | generate from a prompt or script |
GET | /api/v2/videos | list, with limit and offset |
GET | /api/v2/videos/{video_id} | status and metadata |
PATCH | /api/v2/videos/{video_id} | title and visibility only |
DELETE | /api/v2/videos/{video_id} | soft delete |
POST | /api/v2/videos/upload-file | store a file, get URLs for it |
curl -X POST http://127.0.0.1:8000/api/v2/videos/generate \
-H 'x-api-key: YOUR_KEY' -H 'Content-Type: application/json' \
-d '{
"prompt": "Explain how a solar panel turns light into electricity",
"timing": "2",
"video_orientation": "vertical",
"dynamo_video_engine": "dynamo_canvas",
"canvas_style_variant": "technical",
"background_track": "documentary",
"pen_animation_style": "stylus",
"scene_pacing": "fast",
"watermark": true,
"logo_position": "br"
}'
# { "job_id": "...", "video_id": "...", "status": "queued",
# "unsupported_parameters": [] }
What is implemented
All of it, with two exceptions. Every documented parameter is validated and
acted on: engine and style variants, orientation, timing (including
"auto"), pacing, pen style, colour, music, watermark and custom
logo with corner placement, reference documents, custom images with their
description/keep-original/no-animation flags, inserted video clips with
use_ai_audio_at, reference images, own-narration audio or video
with round-overlay and side-by-side layouts, split size, fit and framing
focus, script-only mode, podcast mode, narration and on-screen-text
languages, language variants, visibility and caller-supplied ids.
The two exceptions are reported back in unsupported_parameters on
every response rather than silently ignored:
own_narration_webcam_crop_x, which the contract itself deprecates
in favour of own_narration_focus_position, and
variant_visuals_mode, because every language variant here is a
full independent render β there is no cheaper shared-visuals path to choose.
Differences worth knowing
- upload-file β the contract describes a presigned PUT. This
instance has no object store, so it takes the bytes at step one and returns an
upload_urlthat accepts the same object. A client following the three-step flow works unchanged; one that skips step two also works. - Plan gating β there are no plans or tiers here, so
nothing returns
403fortiming: "15"or for uploads. - Status vocabulary β v2 reports
queued/processing/completed/failed; the native API's finer stages stay available on/api/jobs/{id}.
Going to production
There is no authentication
The server binds to 127.0.0.1 and has no auth, because it is built to
run locally. Anyone who can reach it can read your saved API keys' usage
(not the keys β those are only ever returned masked), spend your model credits, and
delete your renders. If you expose it, put it behind a reverse proxy that handles
authentication, and keep WB_HOST on loopback so the proxy is the only
way in.
Concurrency
Each job drives its own browser instance, which is the memory-hungry part.
WB_MAX_CONCURRENT_JOBS (default 2) caps how many run at once; extra
jobs queue. Raise it only if you have the RAM β budget roughly 500 MB per
concurrent render.
Swapping the infrastructure
| Want | Change |
|---|---|
| Celery + Redis instead of a thread pool | Replace the body of submit() in app/workers/runner.py with task.delay(job_id). Nothing else references the pool. |
| PostgreSQL instead of SQLite | Set WB_DATABASE_URL. The models are plain SQLAlchemy 2.0. |
| Object storage for artefacts | Artefacts are addressed through settings.job_dir(job_id) and served by one module, app/api/files.py. |
Retention
Nothing is cleaned up automatically. A 90-second 1080p render is roughly 15 MB, plus previews and a timeline. Delete jobs you no longer need β that removes the row and the whole job directory.