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 | Full-bleed image, with an optional zoom push-in | no |
| video | Video, with trimStart, muted, volume | no |
| solid | Flat colour fill | no |
| caption | Burned-in text with stroke and shadow | no |
| 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.
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 | mp4 | mp4 muxes h264, webm muxes vp8 |
| 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.
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.
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 messageThread or appMockup layer, which derive their own length.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