Video Render
Describe a video as JSON and get a finished video back. Use this when the video needs real layout and animation — a chat thread, an app demo, an animated before/after — rather than the cut-and-composite operations the Merge API covers.
You do not write compositions and you do not upload code. The spec is data, which makes it something an LLM can generate and an agent can iterate on against a reference video.
The shape
A spec is a canvas, a list of scenes played back to back, and audio that spans the whole thing. A scene is a stack of layers.
{canvas: { width: 1080, height: 1920, fps: 30, background: '#000000' }, // all optionalaudio: [{ src, volume, fadeIn, fadeOut, loop }],scenes: [{ id, duration, background, layers: [ /* ... */ ] }]}
Install @stack0/video-spec for the full types, validation and worked examples. Validating locally before you POST turns a round trip into a type error.
Layers
| Type | What it is | Owns its length? |
|---|---|---|
| image | A photo or a screenshot: full frame or fitted, cropped, with corners, shadow, a window or phone frame, a plate, an entrance, a highlight, camera keyframes, and later states it swaps to | no |
| video | Video, with trimStart, focus, playbackRate, zoom, punches, grade, shake, a circle shape, blur, dim, muted and volume, and the same placement as image | no |
| solid | Flat colour fill | no |
| caption | Burned-in text with font, stroke, shadow, an in animation (fade, rise, pop, slam, scramble, mask) and an exit | no |
| words | Word-timed captions from a transcript, with the spoken word highlighted | yes |
| flash | A full-frame flash that decays fast, with an optional RGB split | no |
| motif | Brand-coloured rings, field lines, a waveform or a grid, moving on beats | no |
| stat | A number that counts up, with an optional gauge and a label | yes |
| review | A customer review on a glass card, revealed word by word | yes |
| steps | A numbered list of 2 to 4 steps | yes |
| endCard | Logo, call to action, url and handle over a motif. It opens in a wave, or fades in with enter: "fade" | yes |
| gradient | A linear base with up to 4 radial glows that drift across the layer | no |
| counter | A number, m:ss clock or clock time that counts with rolling digits | no |
| cursor | A mouse pointer that glides between points and clicks, with an optional name tag | no |
| lockup | Stacked type whose words appear as they are spoken | no |
| shade | A gradient that darkens one edge so type reads | no |
| slash | A bright diagonal wipe to hide a cut | no |
| logoWall | A grid of logos that pops in on a light panel | yes |
| panels | Footage in side-by-side panels or stacked bands, each with a label | yes |
| appMockup | A phone showing an app: photo loaded, prompt typed, send tapped | yes |
| messageThread | A message thread with typing, compose and delivery | yes |
Every layer takes start and duration in seconds, relative to its scene. A scene's duration is optional: omit it and the scene runs as long as its longest self-timing layer needs. A scene with neither is an error that names the scene.
Image and video layers take crop (a region of the source, e.g. the page inside a browser recording) and camera keyframes (zoom, x, y, rotateX, rotateY, blur), eased with the house curve. The camera replaces zoom and the highlight's push-in. When the picture has visible edges (fit placement, a frame, a plate, or a box smaller than the canvas), the whole picture moves as one, with its corners and shadow. When it covers the canvas, the camera moves the source inside it.
Image and video layers also take overlays: the product in use, drawn on the source in fractions of it, so each one moves with the screen under the camera. cursor is a pointer with a path, clicks and an optional name tag. type types text into a box, with a caret; its size is in the source's pixels, and background covers placeholder text. mark draws a selection across a box. pop springs a picture in over the source. reveal uncovers a box from top to bottom, so a page writes itself. Each overlay shows from at and, with until, fades out.
A scene can set transition (cut, fade, flash, punch, whip or blur) for how it enters. Scenes do not overlap, so a transition does not change the length. canvas.finish adds film grain and a vignette over every scene. An audio track can set duck windows where it drops under a voiceover.
Message thread timing
A thread paces itself, and that is the point. Each message holds for as long as it takes to read. A reply is preceded by a typing bubble. A sent message is preceded by watching it get typed character by character. The last message holds far longer, because it is the payoff.
Messages marked history: true are on screen from the first frame and cost no time — real threads have a past, and one that starts on a blank screen reads as staged. Override a single message with hold in seconds, or the whole block with the scene's duration. A message whose text is exactly [IMAGE] renders the thread's imageSrc.
Render a video
import { Stack0 } from '@stack0/sdk'const stack0 = new Stack0({ apiKey: process.env.STACK0_API_KEY! })const job = await stack0.cdn.createRenderJob({projectSlug: 'my-project',spec: {scenes: [{id: 'hook',duration: 2.5,layers: [{ type: 'image', src: afterImageAssetId, zoom: { from: 1, to: 1.06 } },{ type: 'caption', text: 'i told my mom\nthe kitchen exploded', position: 'upperThird' },],},{id: 'demo',duration: 4,background: '#F4F4F6',layers: [{ type: 'appMockup', photo: beforeImageAssetId, prompt: 'make the kitchen look destroyed' },],},{// No duration: the thread derives its own length from the messagesid: 'payoff',layers: [{type: 'messageThread',recipient: 'Mom',imageSrc: afterImageAssetId,messages: [{ sender: 'recipient', text: 'you home?', history: true },{ sender: 'user', text: '[IMAGE]' },{ sender: 'recipient', text: 'WHAT IS THAT' },],},],},],},output: { format: 'mp4' },webhookUrl: 'https://your-app.com/webhook',})// Known before a single frame rendersconsole.log(job.width, job.height, job.fps, job.durationInFrames)
Media
Every src, photo and imageSrc is either a Stack0 asset id or an absolute https:// URL on an allowed CDN host. Nothing else is accepted.
The renderer is a browser, so any URL in a spec is a URL it will request from inside Stack0's network. Restricting it to assets you own is what stops a spec reaching addresses it should not. The cost is that third-party media has to be uploaded first.
Output options
| Field | Values | Default | Notes |
|---|---|---|---|
| format | mp4, webm, frames | mp4 | mp4 muxes h264, webm muxes vp8. frames renders JPEG stills instead of a video. |
| frames | 1 - 24 times in seconds | With format frames. Each time must be inside the video. The job lists the stills in outputFrames. Each still has filled: the share of the frame with visible detail, 0 to 1. A blank frame reads 0. | |
| frameWidth | 320 - 1920 | 960 | Width of each still in pixels. |
| scale | 0.1 - 4 | 1 | Multiplies the canvas. Cost scales with the square of this. |
| filename | string | render-{jobId}.{format} |
There is no quality or aspect ratio option: the spec's canvas declares width, height and fps.
Films written as code
When a spec cannot say what you want, send a film as Remotion source instead. Stack0 renders it inside a fixed project: an entry, a Root that registers one composition per shape (landscape, vertical, square), and a kit of helpers under src/kit/ for motion, kinetic type, typing, cursors and sound. You send your own files under src/: src/film.tsx exporting Film and DURATION (frames at 30 fps), the modules it imports, and src/media.ts exporting MEDIA.
const job = await stack0.cdn.createFilmRenderJob({projectSlug: 'my-project',film: {files: { 'src/film.tsx': film, 'src/media.ts': media, 'src/scenes/Review.tsx': review },composition: 'vertical',durationSeconds: 30,},// "frames" with output.frames renders stills of a draft; "mp4" renders the film.output: { format: 'mp4', loudness: { integrated: -14 } },})
A film is code, so it runs in an isolated sandbox with no access to your account. It may import only react, remotion and the @remotion packages Stack0 installs (google-fonts, layout-utils, media, motion-blur, noise, paths, shapes, transitions). Its page can load media only from the Stack0 CDN and fonts from Google Fonts. A film that does not compile or bundle fails with the compiler's message in errorMessage. It may run up to the durationSeconds you declare, at most 180 seconds.
Poll, list and cancel
const job = await stack0.cdn.getRenderJob('job_abc123')if (job.status === 'completed' && job.outputAsset) {console.log(job.outputAsset.cdnUrl)}const { jobs, total, hasMore } = await stack0.cdn.listRenderJobs({projectSlug: 'my-project',status: 'processing',})// Stops a render that is already running, not just a queued oneawait stack0.cdn.cancelRenderJob('job_abc123')
Validation errors
A spec is validated before anything is queued, and every problem is reported at once with the path to the field — so a generated spec can be fixed in one pass rather than one round trip at a time.
Fields the spec schema does not know are dropped, not rejected. The job lists them in ignored, as paths like scenes[0].layers[2].zoom, so a caller can see which of its fields had no effect.
Video spec has 2 problems:scenes.1.duration: This scene has no duration and no layer that provides one. Set "duration"in seconds, or add a layer that derives its own length (messageThread, appMockup, words, stat, review, steps, endCard, logoWall or panels).scenes.0.layers.0.src: Media reference points at "example.com", which is not an allowed mediahost (cdn.stack0.dev). Upload the file to Stack0 and reference the asset id you get back.
Limits
Duration
600 s
per render
Structure
60 / 24
scenes, layers per scene
Thread
80
messages per thread