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?
imageA 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 tono
videoVideo, with trimStart, focus, playbackRate, zoom, punches, grade, shake, a circle shape, blur, dim, muted and volume, and the same placement as imageno
solidFlat colour fillno
captionBurned-in text with font, stroke, shadow, an in animation (fade, rise, pop, slam, scramble, mask) and an exitno
wordsWord-timed captions from a transcript, with the spoken word highlightedyes
flashA full-frame flash that decays fast, with an optional RGB splitno
motifBrand-coloured rings, field lines, a waveform or a grid, moving on beatsno
statA number that counts up, with an optional gauge and a labelyes
reviewA customer review on a glass card, revealed word by wordyes
stepsA numbered list of 2 to 4 stepsyes
endCardLogo, call to action, url and handle over a motif. It opens in a wave, or fades in with enter: "fade"yes
gradientA linear base with up to 4 radial glows that drift across the layerno
counterA number, m:ss clock or clock time that counts with rolling digitsno
cursorA mouse pointer that glides between points and clicks, with an optional name tagno
lockupStacked type whose words appear as they are spokenno
shadeA gradient that darkens one edge so type readsno
slashA bright diagonal wipe to hide a cutno
logoWallA grid of logos that pops in on a light panelyes
panelsFootage in side-by-side panels or stacked bands, each with a labelyes
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.

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

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, webm, framesmp4mp4 muxes h264, webm muxes vp8. frames renders JPEG stills instead of a video.
frames1 - 24 times in secondsWith 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.
frameWidth320 - 1920960Width of each still in pixels.
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.

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.

typescript
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

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.

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.

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 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 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