Video Render

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.

typescript
{
canvas: { width: 1080, height: 1920, fps: 30, background: '#000000' }, // all optional
audio: [{ 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

TypeWhat it isOwns its length?
imageFull-bleed image, with an optional zoom push-inno
videoVideo, with trimStart, muted, volumeno
solidFlat colour fillno
captionBurned-in text with stroke and shadowno
appMockupA phone showing an app: photo loaded, prompt typed, send tappedyes
messageThreadA message thread with typing, compose and deliveryyes

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

typescript
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 messages
id: '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 renders
console.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

FieldValuesDefaultNotes
formatmp4, webmmp4mp4 muxes h264, webm muxes vp8
scale0.1 - 41Multiplies the canvas. Cost scales with the square of this.
filenamestringrender-{jobId}.{format}

There is no quality or aspect ratio option: the spec's canvas declares width, height and fps.

Poll, list and cancel

typescript
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 one
await 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.

text
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 media
host (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