Video edits
A video is described as JSON: a timeline of tracks, each holding clips, each wrapping an asset. Send it to the render endpoint and the engine composites and encodes it.
Structure#
{
"timeline": {
"background": "#000000",
"tracks": [
{
"clips": [
{
"asset": { "type": "title", "text": "Hello", "style": "minimal" },
"start": 0,
"length": 4
}
]
}
]
},
"output": { "format": "mp4", "resolution": "hd" }
}Track order is inverted
tracks[0] is the topmost layer, not the bottom one. This matches this edit format, and we kept it deliberately — silently inverting existing edits would be worse than the surprise. Put your background on the last track.Timing#
| Value | Meaning |
|---|---|
start: 2.5 | Seconds from the start of the timeline |
start: "auto" | Immediately after the previous clip on the same track |
length: 4 | Seconds on screen |
length: "auto" | The asset’s natural duration (5s for stills) |
length: "end" | Run to the end of the timeline |
The timeline’s duration is the furthest start + length across all tracks. There is no separate duration field to keep in sync.
Asset types#
| Type | Purpose | Key properties |
|---|---|---|
video | A video clip | src, trim, volume, speed, reverse, crop, chromaKey |
image | A still | src, crop, chromaKey |
text | Fully controlled text | text, font, stroke, shadow, background, alignment, gradient, animation |
title | Preset-styled title | text, style, size, position — plus font, alignment, stroke and shadow to override any part of the preset |
html | Arbitrary HTML/CSS | html, css, width, height |
shape | Rectangle, circle, star… | shape, fill, stroke, borderRadius, points |
svg | SVG file or inline markup | src or markup |
lottie | Lottie animation | src, speed, loop, colorOverrides |
audio | Audio track | src, trim, volume, effect, speed, reverse |
caption | Burned-in captions | cues, src or script + audio, font, background, karaoke |
chart | Data-driven chart, table or widget | chart, data, table, value, colors, animation |
luma | Luminance matte | src, invert (inherited by default), fit, trim |
marquee | Scrolling ticker text | text, direction, speed or cycles, font, background, label, separator |
datetime | A date or time, filled in at render | mode, value, offset, format, timezone, locale, binding |
countdown | A timer to or from a moment | to or from, direction, units, layout, labels, segment, urgency, expired |
luma clip is never drawn. It cuts the clip it is paired with into a shape, for as long as their windows overlap. The white parts of the matte are what show through, grey giving partial opacity — the same luminance-matte convention as After Effects and CSS mask-mode: luminance. Set invert: true for a matte authored the other way round, such as a black disc on a white field. Pair it by putting the matte on the same track as the clip it cuts (the usual way), or on the track above it. A matte with nothing to cut is dropped rather than painted.invert has its polarity read off its own pixels, so a template authored either way round renders as its designer drew it. Every decision is written to the render log.invert wins; failing that its track’s lumaPolarity; failing that the timeline’s; and only when nobody has said anything is the matte measured. A stated convention is an instruction — nothing is inferred over the top of it, and no measurement runs at all.| Level | Field | Use it for |
|---|---|---|
| Clip | asset.invert | The one matte that differs from everything around it. |
| Track | lumaPolarity | A set of mattes sharing a convention. This is the fix for an edit that mixes sources: put each set on its own layer and say which way round it was drawn. |
| Timeline | lumaPolarity | A whole library authored one way round. One field, every matte in the edit, no guessing anywhere. |
| Value | Meaning |
|---|---|
absent | Inherit from the level above, and measure if nobody stated one. The default. |
"auto" | Measure this one, ignoring any convention stated further out. How you exempt a single asset. |
true | Dark keeps. Stated, never second-guessed. On a track or timeline, spelled "dark". |
false | White keeps. The opt-out for a matte that really is meant to punch a hole. On a track or timeline, spelled "white". |
lumaPolarity on the timeline — or per layer, if the edit mixes conventions — and the guessing stops entirely.fit to "cover" or "contain"to keep the matte’s own aspect instead — which is what stops a square avatar stencil arriving as an oval on a portrait render. What a "contain" matte does not reach is kept, not cut away.LUMA_AUTO_INVERT=false on the engine to switch inference off entirely, so a matte that states nothing is held to the literal standard rather than measured. Stated polarities are unaffected — invert and lumaPolarity are instructions, not guesses, and are still obeyed. The studio follows the same setting through NEXT_PUBLIC_LUMA_AUTO_INVERT, so the preview never self-corrects when the render will not.mask: wherever either the clip or the matte is transparent, the result is transparent.video may carry transparency: a WebM whose VP8/VP9 stream has an alpha layer is decoded with it intact, so overlays like falling snow or a lower third composite over the design rather than as a black rectangle. MOV/ProRes 4444 and animated PNG work the same way.font.weight takes the CSS numbers — 100 Thin, 300 Light, 400 Regular, 500 Medium, 600 Semi Bold, 700 Bold, 800 Extra Bold, 900 Black. The render requests that exact cut from the font, so Extra Bold is the face the type designer drew rather than a Regular thickened by the browser. A family that does not ship the weight you asked for falls back to the nearest one it does ship.Speed and reverse#
A video or audio clip plays at whatever rate speed names, forwards or — with reverse — backwards. Both retime the picture and the sound together: a clip at 2sounds fast rather than merely ending early, and a reversed clip’s audio runs backwards with it.
{
"asset": {
"type": "video",
"src": "https://cdn.acme.com/dunk.mp4",
"speed": 0.5
},
"start": 0,
"length": 6
}The clip’s length does not change. A rate decides how much SOURCE fills the window you asked for: four seconds at 2 reads eight seconds of footage, and at 0.5 reads two. Ask for more than the file holds and the clip runs out of picture — so when the source is the thing that should decide, use "length": "auto", which resolves to the playable length at the rate you set.
reverse plays that same window backwards: trim seconds in from the head, length × speed seconds of source, last frame first. Reversing a clip changes what it shows, never where it sits or how long it runs — so { "reverse": true, "speed": 0.5 } is a slow rewind.
| Property | Values |
|---|---|
speed | 0.25–4. Default 1. Picture and sound are both retimed. |
reverse | true / false. Default false. Plays the clip’s window backwards, audio included. |
2 chains several tempo stages to retime the audio, and at 4 percussive material starts to smear. Mute the clip if only the picture needs the rate.Fit, position and offset#
| Property | Values |
|---|---|
fit | cover (default) and crop scale the asset, aspect preserved, until it covers its box; the excess overflows and is cropped by the frame, not by the box. contain fits the whole asset inside the box. none leaves it at its native pixel size. |
position | Nine anchors: top, topLeft, center, bottomRight… |
offset | Fractions of the frame: { x: 0.1, y: -0.2 }. Positive y is up. |
scale | Multiplier on the box the fit is computed against, so position anchors the scaled size: right at scale: 0.5 is a half-size clip flush with the right edge. |
width / height | Explicit box in output pixels, overriding the asset’s own size. An explicit box is a window: the picture is cut to it rather than overflowing, as it is under a mask or a luma matte. |
opacity | 0–1 |
adjustments | Brightness, contrast, saturation and hue, applied after filter. brightness is additive, −1 to 1, and contrast / saturation are multipliers where 1 leaves the clip alone; hue is degrees. Everything at its neutral value emits no filter, so an untouched clip costs nothing. |
transform | rotate, skew, flip |
blendMode | multiply, screen, overlay, softLight… |
mask | rectangle, circle or ellipse, with borderRadius and feather |
enabled | false drops the clip from the render, picture and audio both. Set it on a track to drop every clip on that layer. |
enabled: false keeps its slot: later clips using start: "auto"chain from where it ends, and the timeline’s duration is unchanged. Hiding a layer to see what is underneath never reshuffles the rest of the edit, and turning it back on restores the frame exactly. Absent means visible, so nothing written before this existed behaves differently.Chroma key#
A video or an image may carry a chromaKey, which knocks a solid backdrop out of it so the tracks underneath show through. The key runs on the source at its own resolution, beforethe clip is fitted into its box — which matters, because scaling a green screen down is what smears the screen colour into the subject’s edge and creates a fringe no threshold can classify.
| Property | Range | What it does |
|---|---|---|
color | hex | The backdrop colour to remove |
preset | green, blue, white, black | A named screen colour, used when color is absent |
mode | chroma (default), color | Match on hue and ignore lighting, or compare raw RGB |
threshold | 0–255, default 40 | How close a colour must be to the screen to go |
halo | 0–255, default 20 | Softness of the cut — partial alpha at the edge |
spill | 0–1, default 0 | Drain the screen’s colour cast off the subject |
shrink | 0–8 source px | Eat the matte back to lose a coloured fringe |
edgeBlur | 0–20 source px | Feather the edge so it is not a hard stencil |
showMatte | boolean | Draw the matte instead of the picture, to tune the key |
enabled | boolean | Set false to keep the tuning but stop applying it |
{
"asset": {
"type": "video",
"src": "https://cdn.acme.com/presenter-greenscreen.mp4",
"chromaKey": {
"color": "#00B140",
"threshold": 60,
"halo": 24,
"spill": 0.6,
"edgeBlur": 2
}
},
"start": 0,
"length": 6,
"fit": "contain",
"position": "bottomRight",
"scale": 0.7
}mode: "chroma" compares hue and ignores brightness, so an unevenly lit screen still keys cleanly — that is what a filmed backdrop needs. mode: "color" compares raw RGB and is exact, which suits flat-coloured renders and screen recordings and is wrong for anything shot on a set.threshold until they disappear eats the subject with them. spill recolours them instead, which is the only thing that clears a green rim off a shoulder.threshold, halo, shrink and edgeBlur all preview; the one stage that differs is spill, which the preview approximates and the render computes from a full spill map. Previewing a key needs the source to be served with CORS headers; without them the studio shows the clip unkeyed and the render is unaffected.Transitions, effects and keyframes#
Transitions
Applied at a clip’s edges. Append Fast or Slow to any name to scale its duration — fadeFast, carouselLeftSlow. A slide travels the width of the whole frame unless you tell it otherwise.
"transition": { "in": "slideUp", "out": "fadeFast" }
// fade, reveal
// wipeLeft, wipeRight, wipeUp, wipeDown
// slideLeft, slideRight, slideUp, slideDown
// carouselLeft, carouselRight, carouselUp, carouselDown
// shuffleTopRight, shuffleRightBottom, shuffleBottomLeft, …
// zoom, zoomIn, zoomOut
// blur, glitch, whipLeft, whipRightEffects
Continuous motion for the clip’s whole life. Every one of these is evaluated by the render graph rather than painted frame by frame, so none of them costs anything extra. The oscillating ones are sized against the frame unless you tell them otherwise.
"effect": "zoomInSlow"
// Ken Burns — a slow push, pull or drift
// zoomIn, zoomOut
// slideLeft, slideRight, slideUp, slideDown
// panLeft, panRight, panUp, panDown
// Rotation about the clip's own centre
// rotateCW, rotateCCW
// Oscillations that loop while the clip is on screen
// shake handheld jitter
// float a slow vertical bob
// pulse a breathing scale
// Focus pulls
// blurIn, blurOut
// each accepts a Fast or Slow suffixHow far the motion travels
A slide is a displacement of one container width, and a shake or float an amplitude measured against one container height. That container is the whole frame unless you say otherwise — which is right for a full-bleed photo and wrong for everything smaller: on a 1920-wide frame a slideLeft on a 300px badge starts a whole screen away and crosses the entire picture to reach its mark.
| container | Motion is measured against |
|---|---|
"frame" | The whole output frame. The default, and what every clip did before this existed. |
"clip" | The clip’s own box — so an element travels its own width rather than the video’s. |
{ width, height } | An exact size in output pixels. Either axis may be left out and falls back to the frame’s. |
{
"asset": { "type": "text", "text": "NEW" },
"width": 300,
"height": 120,
"position": "topRight",
"transition": {
"in": "slideLeft",
"out": "slideLeft",
"container": "clip"
}
}
// Without "container" this starts 1920px off to the right
// and travels the whole frame. With it, 300px — its own
// width — which is what "slides in" means for a badge.transition.container covers both edges — a clip that entered across its own box and left across the frame would accelerate on the way out for no stated reason. effectContainer is separate, because a full-bleed photo may legitimately want a frame-sized shake while its caption slides in across its own box.The zoom family ignores it
zoomIn, zoomOutand the pans sample a moving window out of the clip’s own picture rather than displacing the clip, so they are already measured against it and effectContainer does nothing to them. It applies to shake, float and the focus pulls.Text animations
A text or titleasset can arrive rather than simply appear — typed out, a word at a time, flipping in letter by letter, or as one line rising into place. This is an animation of the clip’s content rather than of its position, so it is set on the asset and not on the clip.
"asset": {
"type": "text",
"text": "LUXURY APARTMENT",
"font": { "family": "Montserrat", "size": 88, "weight": 500 },
"animation": {
"type": "wordByWord",
"staggerSec": 0.2,
"durationSec": 0.45
}
}
// type any name from the table below, or none
// unit block, line, word, letter — overrides what the animation counts
// staggerSec gap between two parts starting. 0 reveals everything at once
// durationSec how long one part takes to arrive
// delaySec hold before the first part moves
// easing the curve each part travels on
// intensity 0-3. Scales the travel, the turn and the blur. 1 is as designed
// reverse run the parts back to front| Family | Names | Counts by default |
|---|---|---|
| Arrive | fadeIn fadeUp fadeDown slideUp slideDown slideLeft slideRight zoomIn zoomOut popIn blurIn trackIn trackOut | The whole block |
| Assemble | typewriter wordByWord letterByLetter lineByLine cascade riseUp dropIn | Letters, words or lines |
| Physical | springIn bounceIn elasticIn swingIn | Words, or letters |
| Turn | flipIn rollIn tumbleIn | Letters |
| Optical | focusIn shutterIn maskUp wipeIn glitchIn neonFlicker | Letters, words or lines |
| Loop | wave float pulse shimmer | Runs for the whole clip |
Every name carries its own unit, its own pace and its own curve, and unit overrides the first of those — so flipIn flips letters, and { "type": "flipIn", "unit": "word" } flips whole words. The four Loop animations never settle: they run for as long as the clip does, and their durationSec is one cycle rather than one arrival.
Both values are absolute seconds, so a reveal runs at the same pace whatever the clip does — a two-word headline on a length: "end" clip assembles in the first second, not spread across the whole timeline. Parts are also kept at least one output frame apart, so they can never quantise onto the same frame and arrive together. If a line genuinely cannot fit at the requested pace, the whole schedule compresses into the first 70% of the clip rather than overrunning it.
The durationSec default is deliberately longer than the stagger: parts overlap, which is what makes a line flow rather than tick over one word at a time.
This one is painted per frame
Transitions, effects and keyframes are computed by the render graph on a single browser paint. A reveal changes what the text says between frames, so it needs one paint per frame of the clip. Keep reveal clips short — put the reveal on a 2s clip and let a following static clip hold the finished line, rather than running a reveal over alength: "end" clip.Fitting type to its box
Type that does not fit its box is cut — the clip is painted at exactly the box the layout gives it, and anything past that edge is clipped. Two things make that easy to run into: a text asset with no font.size is sized against its box, and a title is sized against the frame, so dragging one into a lower third keeps its full size in a box a fifth as tall.
"asset": {
"type": "title",
"text": "An Extremely Long Title That Will Not Fit",
"style": "chunk",
"autoFit": true
}
// Shrinks the type until it fits — measured in the renderer
// with the real font loaded, so where the lines break is the
// truth rather than an estimate.
//
// autoFit is a CEILING, not a target: type that already fits
// is left at exactly the size it asked for, never grown.The fit is measured against the box after padding, borders and the plate, so it accounts for everything you have put around the text. It will not shrink type below a quarter of its stated size — past that the answer is a bigger box or fewer words, not smaller type. A countdown needs no switch: its row of tiles is scaled to its box always, since there is no design in which the last unit is meant to be cut off.
The editor shows the same cut
The studio’s canvas clips to the box exactly as the renderer does, and runs the same fitting code against the same fonts. Type that is cut in the file is cut in the preview, and a fitted headline is the same size in both.Text transforms
A warp sets the line on a shape instead of a straight baseline. It is static geometry, baked into the frame the clip was already drawing, so unlike an animation it costs nothing at all to use and composes freely with one.
"asset": {
"type": "title",
"text": "SUMMER SALE",
"style": "chunk",
"warp": { "type": "arcUp", "intensity": 1.4 }
}
// A bare name is the same as intensity 1:
"warp": "leftUplift"| Warp | What it does | How |
|---|---|---|
flat | A straight baseline. The default. | — |
leftUplift | The left end rides high, the line falling away to the right | Tilts the whole line |
rightUplift | The mirror of it | Tilts the whole line |
slantLeft | The type leans; the baseline does not move | Shears the whole line |
slantRight | The same, the other way | Shears the whole line |
arcUp | Set on a curve, the middle rising | Places each letter |
arcDown | Set on a curve, the middle dropping | Places each letter |
circle | Wrapped round a circle, reading along the top | Places each letter |
circleDown | The same circle, read along the bottom | Places each letter |
wave | A sine through the line | Places each letter |
zigzag | Alternating letters, up and down | Places each letter |
stairsUp | Each letter a step higher than the last | Places each letter |
stairsDown | Each letter a step lower | Places each letter |
fan | Letters splayed from a shared centre | Places each letter |
perspectiveLeft | Receding to the left | Turns the whole line |
perspectiveRight | Receding to the right | Turns the whole line |
The warps that tilt, shear or turn the whole line leave it a single flowing run, so it keeps its wrapping, its kerning and its justification. The ones that place each letter individually cannot — a line set on an arc does not wrap — so give those a box wide enough for the line, or shorten the line.
Padding, borders and corners
Anywhere a plate sits behind text — a text or title background, a caption’s, a ticker’s, a countdown’s tiles — the same three fields describe it, and each takes a shorthand or a long form.
"background": {
"color": "#101014",
"opacity": 0.9,
// One number pads every side...
"padding": 16,
// ...or state them separately.
"padding": { "top": 8, "right": 20, "bottom": 24, "left": 20 },
// One radius rounds every corner...
"borderRadius": 12,
// ...or one corner at a time.
"borderRadius": { "topLeft": 12, "topRight": 12 },
// A rule round the box, or along one edge of it.
"border": {
"width": 1,
"style": "solid",
"color": "#2A2A32",
"left": { "width": 6, "color": "#8B3DFF" }
}
}| Field | Shorthand | Long form |
|---|---|---|
padding | A number, every side | { top, right, bottom, left } |
borderRadius | A number, every corner | { topLeft, topRight, bottomRight, bottomLeft } |
border | { width, style, color } | Plus any of top / right / bottom / left |
A side left out of the long form is zero, not the shorthand’s value — there is no shorthand present to inherit from. A border edge is the exception and inherits every part it does not state from the border’s own width/style/color, so one accent edge does not mean restating the other three. An edge that ends at zero width is not drawn, which is why a border naming only a colour draws nothing.
style takes the CSS keywords: solid, dashed, dotted, double, groove, ridge, inset, outset, none. Padding and border are drawn inside the box the clip declares, so adding either never changes the size you asked for.
Marquee
A news-channel ticker. The strip is painted once at its natural width and scrolled by the render graph, so a ten-minute ticker costs one browser paint. The text repeats end to end and the scroll wraps on the repeat boundary, which is what keeps the loop seamless.
{
"asset": {
"type": "marquee",
"text": "Markets close higher as tech leads the rally",
"direction": "left",
"speed": 220,
"separator": " • ",
"background": { "color": "#B00020" },
"label": { "text": "Breaking", "background": "#FFFFFF", "color": "#B00020" },
"font": { "family": "Inter", "weight": 700 }
},
"start": 0,
"length": 30,
"position": "bottom"
}
// direction left (default), right, up, down
// speed output pixels per second
// cycles complete passes over the clip — overrides speed
// label a fixed chip that stays put while the text runs under it
// With no width/height the ticker is a full-width bar 9% of the frame tall.Keyframes
Animate a numeric property with explicit stops and easing. Times are relative to the clip’sstart, not the timeline’s.
"keyframes": {
"opacity": [
{ "at": 0, "value": 0 },
{ "at": 0.6, "value": 1, "easing": "easeOutCubic" }
],
"offsetY": [
{ "at": 0, "value": -0.05 },
{ "at": 5, "value": 0.05, "easing": "linear" }
],
"scale": [
{ "at": 0, "value": 1 },
{ "at": 4, "value": 1.15, "easing": "easeInOutQuad" }
]
}Animatable: opacity, scale, rotate, offsetX, offsetY, volume, blur, brightness. Easing covers the usual quad/cubic/quart/expo/back curves plus spring and bounce.
One thing that costs real time
Position, scale and simple fades are animated by the encoder essentially for free. Anopacity track that actually moves falls back to a per-pixel filter, which is noticeably slower — a track whose stops all hold the same value costs nothing, so a constant one is safe to leave in. Use a fade transition where you can.Imported templates
The other common spelling — animation segments written directly on the property, as{ start, length, from, to, interpolation } — is accepted and folded onto the form above on the way in. So "scale": [{ "start": 0, "length": 5, "from": 0.3, "to": 1 }] is read as a scale track, not as a broken number.Charts and data#
A chart asset draws itself from the numbers in your payload — no image to generate, no chart service to call. It is rendered as SVG, so it is one browser paint for the whole clip, stays sharp at any output size, and is the same code that draws charts in the image API: the same chart JSON is the same pixels as a still, in a video, or in the mobile app.
Every data field takes a delimited string as well as an array, which is what makes a chart driveable rather than frozen into the template. A field binding carries a scalar and a {{MERGE}}field carries text, so a chart whose values had to be a JSON array of numbers could be reached by neither — the design would render, correctly, with last month’s figures in it.
Every label a chart draws is addressable: fontScale moves all of them together and text moves one, legend decides which edge the legend sits on, and padding gives a crowded plot more room. Start at "fontScale": 1.4 for a vertical frame — a chart there is laid out on a monitor and watched at phone size. See Type: nine roles, one scale below.
"asset": {
"type": "chart",
"chart": "bar",
"title": "{{ REGION }} enquiries",
"data": {
"labels": "{{ MONTHS }}",
"values": "{{ SALES }}"
}
}
// Then one render per branch, from the same template:
//
// { "MONTHS": "Jun,Jul,Aug", "SALES": "18,34,27" }
// { "MONTHS": ["Jun","Jul","Aug"], "SALES": [18,34,27] }
//
// Both work. A merge field keeps its own type, so an array
// arrives as an array and a number as a number.| chart | Draws | Needs |
|---|---|---|
bar | Vertical bars, grouped by series | data.values |
hbar | Horizontal bars — better for long category names | data.values |
line | A line per series, with markers | data.values |
area | A line with the area beneath it filled | data.values |
pie | One set of values as shares of a whole | data.values |
donut | A pie with the centre cut out | data.values |
table | Rows and columns, with badges and inline bars | table.rows |
progress | A bar filling towards a target | value |
ring | A ring filling towards a target | value |
gauge | A half-circle dial | value |
kpi | A single large number with a caption | value |
Drawing on
With "animation": { "type": "drawOn" } the chart arrives rather than cutting in: bars grow from the baseline, a line draws itself left to right, a pie sweeps once round, a widget counts up to its number, and table rows land in turn. Values printed next to the marks count with the geometry, so the number always states what the mark is showing — and they count in whole numbers when the target is whole, rather than flickering through decimals.
The draw-on takes durationSec— 1.2s by default — measured from the clip’s start, and the chart then holds for the rest of the clip. It does not stretch to fill a long clip.
A drawing chart costs frames
A finished chart is a single browser paint however long it is on screen. One that draws itself is content that changes between frames, so it costs a paint per frame while it moves. Frames after it settles are reused rather than repainted, so the price is the draw-on itself and not the clip — a 1.2s draw-on inside a 10s clip costs about 30 paints, not 250.What a data field accepts
| Field | Array form | String form |
|---|---|---|
data.values | [18, 34, 27] | "18, 34, 27" |
data.labels | ["Jun", "Jul"] | "Jun, Jul" |
data.series | [{ name, values }] | "18, 34, 27" |
table.columns | [{ label }] | One heading per line |
table.rows | [["Pune", 24000]] | One row per line, cells by comma |
value | 12400 | "12400" |
max | 100 | "100" |
colors | ["#8B3DFF"] | "#8B3DFF, #00C4CC" |
A list may be separated by newlines, pipes or commas — in that order of precedence, which is what lets "1,200\n980" keep its thousands separators. Currency symbols, separators and trailing per-cent signs are stripped from numbers, because a spreadsheet column is where these usually come from.
An unresolved variable is an error, not an empty chart
A{{SALES}} that reaches the renderer is a variable nobody supplied. It is rejected before the render starts, naming the field — rather than drawing an empty chart and letting you find out from the finished file. In the editor, a bound chart previews with a neutral sample series so it can still be laid out and coloured.Type: nine roles, one scale
Every label a chart draws is derived from font.size— a title is that plus three, an axis tick one less, a category label exactly it. That is a sensible default and a poor ceiling: a chart is laid out on a monitor and watched on a phone, so the answer to “the legend is too small to read” used to be a single number that moved every label at once, including the ones already the right size.
fontScaleis the one-field answer — it multiplies every piece of type in the chart, a table’s rows and a widget’s readout included. text is the precise one: it addresses each label by role, so one moves and the rest stay.
"asset": {
"type": "chart",
"chart": "bar",
"data": { "labels": ["Jun", "Jul"], "values": [18, 34] },
"fontScale": 1.5
}
// Everything grows together and stays in proportion:
// the labels, the ticks, the legend and its swatches,
// and the left gutter widens to fit the bigger ticks.
//
// 1 is the chart as drawn. Start at 1.4-1.8 for a
// 9:16 frame, where the chart is watched at phone size.| Role | The label it addresses | Default size |
|---|---|---|
title | The heading inside the chart’s own box | font.size + 3 |
axis | The value-axis ticks, beside the gridlines | font.size − 1 |
labels | The category names along the other axis | font.size |
values | Each datum beside its own mark, and a pie’s share | font.size − 1 |
legend | The series names beside their swatches | font.size |
header | chart: "table" — the heading row | font.size |
cell | A table’s body cells, badges included | font.size |
value | A widget’s readout — the big number | A fraction of the widget |
caption | A widget’s label, under that number | A fraction of the readout |
font.sizedefaults to 13, and a table’s to 12. A role a given chart does not draw is simply never asked for, so one text block can be shared by every chart in a template.
| text.<role> | Takes | Notes |
|---|---|---|
size | number | Absolute. Ignores fontScale — an absolute instruction should not be multiplied by a global one. |
scale | number | Relative to the role’s default, and composes with fontScale. |
family | string | Falls back to the chart’s font.family. |
weight | number or string | 400–900, or a CSS keyword. |
color | string | Any CSS colour. |
opacity | 0–1 | Left off the element entirely at 1. |
letterSpacing | number | In the chart’s own coordinate space. |
transform | none | uppercase | lowercase | capitalize | Applied before escaping, so entities survive it. |
align | left | center | right | Where the role has a choice: title and legend. |
offset | { x, y } | A nudge, applied after everything else. |
hidden | boolean | Draws nothing and gives the role’s room back to the plot. |
"size": 24 is the same share of the chart at 512×288 as it is at 4K. That is what keeps a chart looking the same in the editor and in the render.Moving the legend
A legend used to go wherever the renderer put it — under an axis plot, to the right of a pie — and nothing in the payload could move it. legend sets the edge it sits on, the direction it runs, and what its marks look like. The plot gives up the room rather than being drawn over, so moving a legend re-lays the chart around it.
"asset": {
"type": "chart",
"chart": "bar",
"data": {
"labels": ["Jun", "Jul", "Aug"],
"series": [
{ "name": "Rentals", "values": [12, 19, 24] },
{ "name": "Sales", "values": [8, 11, 9] }
]
},
"showLegend": true,
"legend": { "position": "right", "swatch": "circle" }
}
// The plot narrows by the legend's band and the
// legend stacks down its side. On a wide frame that
// reads better than a row of names under the bars.| legend | Takes | Default |
|---|---|---|
position | top | bottom | left | right | bottom for an axis plot, right for a pie |
align | start | center | end | start |
direction | horizontal | vertical | Across on a top/bottom edge, down on a left/right one |
swatch | square | circle | line | square |
swatchSize | number | Tracks the legend’s type size, never below 10 |
gap | number | Between a swatch and its text — 4 |
itemGap | number | Pitch between items across — fitted to the width |
rowGap | number | Pitch between rows down — the swatch plus 8 |
size | number | The band reserved on the edge — one row of type across, 40% of the width down |
A legend needs more than one series to appear
On an axis plotshowLegend draws a legend only where there is more than one series — with one series the colour carries no information and the legend is a label for the chart, which is what title is. A pie legends every slice and is on unless you set "showLegend": false. Setting legend does not by itself turn one on.Room, gridlines and where a value sits
| Field | Takes | Does |
|---|---|---|
padding | number or { top, right, bottom, left } | Extra room around the plot, on top of what the chart works out for itself. Additive on purpose: the chart already sizes its left gutter to fit its own tick labels, and replacing that number outright would mean recomputing it every time the data changed. Negative values tighten. |
gridColor | string | The gridlines behind an axis plot. showGrid still decides whether they are drawn at all. |
axisTicks | number | Divisions on the value axis — 4 by default, so five labels counting zero. |
valuePosition | auto | inside | outside | Where showValues puts a bar’s number. auto puts it outside the bar unless it would overflow the box, in which case it goes inside and inverts to white. inside always inverts; outside never does. |
chartFontScale, chartTextStyles, chartLegend, chartGridColor, chartAxisTicks, chartPadding and chartValuePosition. Same renderer, same behaviour, one prefix, because an image layer’s text and padding already mean something else.colors array. Charts with seven or more series should set colorsexplicitly: two of the palette’s later slots are hard to tell apart for red-green colourblind viewers, and a legend alone does not fix that.Dynamic content#
Two asset types resolve their content when the video is rendered rather than when the template is written. That is what lets one template keep earning: the JSON stays the same and what lands in the frame is right for the day it ran.
Date and time
A datetimeclip resolves its value when the video is rendered, not when the template is written. That is what lets one template keep working: a “posted on” stamp, a session time, an offer’s expiry date — the same clip, saying something different every day it runs.
{
"asset": {
"type": "datetime",
"mode": "date",
"value": "now",
"format": "long",
"timezone": "Asia/Kolkata",
"font": { "family": "Inter", "size": 64, "weight": 700, "color": "#FFFFFF" }
},
"start": 0,
"length": 5
}
// mode date | time | datetime (default) | relative
// value "now" (default), an ISO 8601 date, or a Unix timestamp
// format a preset name, or your own token pattern
// timezone an IANA zone. Defaults to the render worker's own.
// locale a BCP-47 tag driving month and weekday names. Defaults to "en".
// `date` and `time` also work as asset types on their own, and set the
// matching mode: { "type": "date", "format": "long" }Format presets. short, medium (the default), long, full, iso, us, compact, and ordinal for dates. Anything that is not a preset name is read as a token pattern.
| Token | Meaning | Example |
|---|---|---|
YYYY YY | Year | 2026 · 26 |
MMMM MMM MM M | Month | August · Aug · 08 · 8 |
DD D Do | Day of month | 14 · 14 · 14th |
dddd ddd dd | Weekday | Friday · Fri · F |
HH H | Hour, 24-hour | 15 |
hh h | Hour, 12-hour | 03 · 3 |
mm m | Minute | 42 |
ss s | Second | 09 · 9 |
SSS SS S | Fraction of a second | 040 · 04 · 0 |
A a | Meridiem | PM · pm |
Z ZZ | UTC offset | +05:30 · +0530 |
z zzzz | Zone name | GMT+5:30 · India Standard Time |
Q W DDD | Quarter, ISO week, day of year | 3 · 33 · 226 |
X x | Unix timestamp, seconds / millis | 1786702329 |
[square brackets], or their letters are read as tokens. [Updated] D MMMMprints “Updated 14 August”; without the brackets the d and ain “Updated” would become a weekday and a meridiem.Countdown
A timer counting toward — or away from — a moment. The asset owns the whole widget, not just the number: segment tiles, unit captions, an urgency palette that takes over as the deadline closes in, and what happens once the clock runs out.
{
"asset": {
"type": "countdown",
"to": "2026-12-25T09:00:00Z",
"layout": "segments",
"units": ["days", "hours", "minutes", "seconds"],
"labels": { "show": true, "style": "long", "position": "below" },
"segment": { "background": "rgba(255,255,255,0.10)", "borderRadius": 16, "gap": 18 },
"font": { "family": "Inter", "size": 84, "weight": 800, "color": "#FFFFFF" }
},
"start": 0,
"length": 10,
"position": "center"
}
// layout inline (default) · segments · stacked · flip · ring
// units largest to smallest. Omit and they are chosen from the actual span.
// padStart leading zeros. Default true.
// The LARGEST unit you show absorbs everything above it: a three-day
// countdown showing only hours reads "78h", never a silently missing 3 days.binding: "render" to freeze it at the moment the render starts.progress a since and the widget draws an elapsed bar or ring alongside the numbers. Progress needs a beginning as well as an end, and a countdown only carries the end — without since the indicator is skipped rather than guessed at.Captions and voice-over#
A caption asset burns spoken words into the frame. There are four ways to say when each one appears, and they differ only in where the timings come from.
| Give it | Timing comes from | Use when |
|---|---|---|
| Transcribe it | Recognising the speech, word by word | Almost always — you need neither a transcript nor a subtitle file |
transcribe + audio | The same, but at render time | Automation — see captioning in bulk |
cues | Exactly what you specify | You already know the timings |
src | An SRT or WebVTT file | You have a subtitle file or transcript |
script + audio | Listening to the pauses in a voice-over | A generated voice-over with no transcript, and no provider connected |
cues with per-word offsets, which is what makes the karaoke highlight land on the syllable rather than near it — and neither a subtitle file nor a script can carry those, because neither format has anywhere to put them.{
"asset": {
"type": "caption",
"script": "Luxury apartment now for rent in central Pune",
"audio": "https://cdn.acme.com/voiceover.mp3",
"wordsPerCue": 4,
"font": { "family": "Inter", "size": 44, "weight": 800, "color": "#FFFFFF" },
"karaoke": {
"enabled": true,
"style": "fill",
"activeBackground": "#FFD200",
"activeColor": "#111111"
}
},
"start": 0,
"length": "end"
}Timing against the voice, with no transcript
A generated voice-over rarely comes with a transcript, so there is nothing to align the words to — only your script and the audio itself. Speech is separated by pauses, and those can be located exactly, so the engine finds the phrases the speaker actually uttered and lays your script into them, each word taking time in proportion to its length.
Captions then land on the voice rather than on a stopwatch: a pause in the delivery is a pause in the captions, and no caption is ever drawn over silence. No transcription service is involved, nothing is sent anywhere, and the result is identical on every render.
What this is not
It never reads the words in the audio, so it cannot notice that your script and the recording disagree, and within a phrase it places words by their length rather than by hearing them. That is accurate to about a word on ordinary narration with clear pauses, and looser on dense, unbroken delivery. For word-perfect captions, passcues or an SRT.For word-perfect timings without writing them by hand, transcribe the recording instead. That reads the words rather than the pauses, works on a video as well as on a voice-over, and returns cues carrying per-word offsets.
One template, a different voice-over per render
Both of a caption’s URLs are ordinary strings, so both take merge fields: audio for the voice-over it times against, and src for a subtitle file. That is what makes captions work in a personalised video — every render sends its own recording, and the captions follow that file rather than the one the template was built with.
{
"merge": [
{ "find": "VOICEOVER_URL", "replace": "https://cdn.acme.com/vo/priya.mp3" }
],
"timeline": {
"tracks": [{ "clips": [{
"asset": {
"type": "caption",
"audio": "{{VOICEOVER_URL}}",
"script": "Hi Priya, your order is on its way.",
"karaoke": { "enabled": true }
}
}] }]
}
}Marking the spoken word
karaoke.stylepicks one of ten markings. Every one of them is layout-neutral — the line keeps its exact metrics as the highlight moves along it — because a marking that changed a word’s box would shift every word after it at the moment the eye is tracking one of them. That is why box and underlineare drawn as inset shadows rather than as borders, and why every word carries the chip’s padding whether it is lit or not.
| style | What happens to the spoken word | Reads well on |
|---|---|---|
color | Recoloured, nothing else | Busy footage — the subtlest of the ten |
fill | A filled chip lands behind it | Social video; the default look |
box | An outline traces it, the fill left alone | Bright footage where a chip would shout |
underline | A rule struck under it | Hand-drawn and editorial styles |
scale | It grows | Any palette — no colour change at all |
bounce | It lifts and settles across its own duration | Playful brands |
glow | It blooms | Dark footage and night shots |
shadow | It gains a hard offset shadow | Flat, graphic looks |
wipe | It fills left to right as it is said | The closest to a real karaoke track |
none | Nothing; the cue is a block of text | Ordinary subtitling |
flow decides how much of a cue is on screen at once, which is a separate question from marking: all shows the whole caption and holds it, progressive reveals words as they are spoken and keeps them, and active shows only the word being said — one word at a time, very large, which is the look built for a 9:16 frame. inactiveOpacity dims words not yet spoken.
background is separate and independent from all of it: it is the plate behind the whole cue. Use either, both, or neither — and nullremoves a preset’s plate outright, which is not the same as leaving it out.
Presets, and overriding them
A caption carries about thirty styling fields, and they are not independent — a typeface that reads at 4% of the frame height needs a different plate from one set at 11%, and a preset that marks words needs a colour that survives being marked. So the styling is a preset plus overrides: naming one is a complete, production-ready caption, and every other field replaces one part of it.
| preset | What it is |
|---|---|
subtitle | Broadcast standard. White on a translucent plate, low in the frame. The default. |
clean | No plate — a heavy outline instead, so nothing boxes in the picture. |
bold | Heavy uppercase on a solid chip. Built to survive a busy shot. |
karaokePop | The social-video look — a yellow chip lands on each word as it is spoken. |
highlight | The spoken word changes colour. The quietest way to follow a voice. |
lowerThird | Left-aligned against a bar, the way an interview is captioned. |
cinema | Restrained serif, wide margins, no plate. For a film rather than a feed. |
minimal | Light type, no outline, no plate. Clean footage and a quiet brand. |
wordPop | One enormous word at a time, dead centre. Built for a 9:16 frame. |
typewriter | Words land one at a time and stay. Monospace, on a dark slab. |
neon | Cyan glow on the spoken word. Made for dark footage. |
karaokeWipe | Each word fills across its own duration. |
bounce | The spoken word lifts and settles. |
marker | Hand-drawn, with a rule struck under the word being spoken. |
boxed | An outline traces the spoken word. |
Sizes inside a preset are fractions of the frame, not pixels, so the same preset reads correctly at 720p, at 4K and on a 1080×1920 vertical. State font.size and you pin it to that exact size instead.
"asset": {
"type": "caption",
"cues": [ … ],
"preset": "karaokePop",
"karaoke": { "activeBackground": "#00E5A0" }
}
// Everything else — the typeface, the size, the outline,
// the position, the reveal — comes from the preset.animation is the same reveal catalogue a text asset uses — all thirty-six of them, with their units, easing and timings — because a cue is a run of text and none of it needed reinventing. The reveal runs once per caption, not once per clip, and is fitted to that caption’s own length.
Transcribing a recording
POST /v1/captions turns any recording in your workspace — an audio file, a voice-over, or a video clip straight off the timeline — into word-timed cues, and writes .vtt and .srt to your media library. In the studio it is the captions wizard, which asks the same three questions in the same order.
| Provider | Reads video | Detects language | Speakers | Keyword biasing |
|---|---|---|---|---|
deepgram | Yes | Yes | Yes | Yes |
aws-transcribe | Yes | Yes | Yes | Custom vocabularies only |
google-speech | No — audio only | No | No | No |
Omit provider and the best connected one is chosen: Deepgram, then Amazon, then Google. Name one that cannot read your file and the refusal says which of your connected providers can, rather than leaving you to guess. Each bills your own account with that provider per audio-minute; pixbix charges no render credits for transcription and never marks it up.
Amazon needs a bucket
Amazon Transcribe reads its input only from S3, so its credential includes a region and a staging bucket you nominate. Each recording is written there for the length of the job and deleted the moment it finishes, on the failure paths as well as the successful one.POST /v1/captions
{
"assetId": "ast_7f21",
"languageCode": "en-IN",
"wordsPerCue": 5,
"charsPerCue": 42,
"diarize": false
}
// 202 with { "id": "trs_31c8", "status": "queued" } — poll
// GET /v1/transcriptions/trs_31c8
// 200 with { "status": "done", "cached": true, "cues": [ … ] }
// when the same recording was transcribed before.Captioning thousands of videos
Everything above needs the timings to exist before a render is submitted, which is fine for one video and does not survive contact with automation. A personalised campaign sends a different voice-over on every render, so the caller would have to transcribe each one, poll it, splice the cues into the edit and only then submit — three round trips per video, and a bespoke orchestrator to run them.
transcribemoves that into the render. The template says “caption whatever voice-over arrives” once, and two thousand renders of it need nothing but two thousand merge values.
"asset": {
"type": "caption",
"transcribe": true,
"audio": "{{VOICEOVER_URL}}",
"preset": "karaokePop"
}
// Every render then needs only its own merge value:
{
"templateId": "tpl_9a2f",
"merge": [
{ "find": "VOICEOVER_URL", "replace": "https://cdn.acme.com/vo/priya.mp3" }
]
}What happens next depends entirely on the cache, and the difference is worth understanding because it is the difference between free and billed:
| If the recording… | The render… | Costs |
|---|---|---|
| has been transcribed before | is queued to the engine immediately, with the cues already in it | Nothing — no provider is called |
| is new | waits at queued while it is transcribed, then goes on by itself | One transcription, ever, for that recording |
So a template with ONE fixed voice-over transcribes once no matter how many times it renders, and a campaign of two thousand personalised voice-overs pays for two thousand transcriptions — the true cost, each of them exactly once however many times that recipient is re-rendered. A render never waits for a provider it does not need.
While a render is waiting, GET /v1/render/:id reports status: "queued" with stage: "Transcribing the voice-over", so a poller can tell the difference between a render that is waiting on a transcript and one that is waiting for a worker. Nothing else about the lifecycle changes — the same webhooks fire, in the same order.
A merge field left unresolved is refused
audio is validated before the render is charged: a {{VOICEOVER_URL}} with no merge entry is a 422 naming the field, not a provider call that fails minutes later on a render you have already paid for.The other route to the same place is a workflow: a captions.generate step transcribes each row and hands {{item.captions.cues}} to the render.template step after it. Use that when you want the transcript itself for something else too — a description, a summary, a subtitle file delivered alongside the video. Use transcribe when you only want the captions burned in.
Paying for a transcript once
The same recording gets captioned more than once far more often than anybody expects: an editor trying four styles, a personalised template rendering for two hundred recipients over one fixed voice-over, a workflow re-run after a failure downstream. None of those are new transcriptions — they are the same question asked again, and every one of them would otherwise be another audio-minute billed to your account.
So every transcript is stored against a SHA-1 of the recording and the settings that affect it, scoped to your workspace, and an identical request is answered from it for nothing. The response says so: cached: true, with cachedFromId naming the transcription it came from.
| Change this | Costs |
|---|---|
| wordsPerCue, charsPerCue, maxCueSec | Nothing — the stored word timings are re-cut |
| preset, karaoke, animation, any styling at all | Nothing — styling never reaches a provider |
| languageCode, model, diarize, keywords, punctuation | A fresh transcription — these change the transcript |
cache: false | A fresh transcription, deliberately |
An assetIdis the cheapest route to a hit: the media library already computed the file’s checksum at upload, so a repeat is answered before a single byte is downloaded. A bare URL is matched on its path with the query string stripped, which is what makes signed URLs — a different signature and expiry every time — hit rather than miss.
Audio#
"timeline": {
"soundtrack": {
"src": "https://cdn.acme.com/bed.mp3",
"effect": "fadeInFadeOut",
"volume": 0.4,
"loop": true,
"duck": { "enabled": true, "toVolume": 0.15 }
},
"tracks": [ … ]
}Clip audio and the soundtrack are mixed without the automatic attenuation most mixers apply, so a voiceover does not get quieter simply because a music bed exists. A limiter catches the clipping that then becomes possible.
Set output.normalizeAudio: true to normalise loudness to −14 LUFS, which is what most social platforms target.
duck drops the soundtrack whenever anything else is playing, keyed off the mix of every other audio source. It only applies when there is one.
Fonts#
Any asset with a font block names its typeface by family. Every family in the Google Fonts catalogue works — 1,942 of them at the time of writing — and the engine downloads it while the edit is being planned. Nothing needs uploading and nothing needs declaring.
{
"type": "text",
"text": "Season sale",
"font": {
"family": "Playfair Display",
"size": 96,
"weight": 700,
"color": "#FFFFFF"
}
}weight is matched against the cuts the family actually ships. A family that only draws one weight — Lobster, Archivo Black — snaps to it rather than letting the browser smear a synthetic bold over the real design. A variable family covers its whole range, so any weight inside it is genuine.
Style-qualified names work too: "Montserrat ExtraBold" resolves to Montserrat at 800. Families whose real name ends in a style word — Archivo Black, Bungee Hairline, Shadows Into Light, Love Light — are matched whole and are never split.
Checking a family before you render
Google answers a request for a family it does not publish with a success and no font. The render then completes in a system fallback face — no error, no warning in the response, just the wrong typeface in a finished video. If you generate edits programmatically, check the name first:
curl "https://pixbix.app/api/v1/fonts?family=Playfair%20Display"
{
"success": true,
"data": {
"query": "Playfair Display",
"supported": true,
"generic": false,
"font": {
"family": "Playfair Display",
"category": "serif",
"weights": [400, 500, 600, 700, 800, 900],
"italic": true,
"variable": true
}
}
}GET /v1/fonts needs no API key. Browse it with search, category and limit, or pass family to check one exact string. The full endpoint reference is under Endpoints below.
Fonts you host yourself
For anything not in the catalogue — a brand face, a licensed cut — add it to timeline.fonts as an https URL to a .ttf, .otf, .woff or .woff2 file. The engine reads the real family name out of the file itself, so font.family is whatever the font calls itself rather than whatever the file is called. Set family on the declaration to override that.
| Property | Meaning |
|---|---|
src | Required. https URL to the font file. |
family | Optional. Overrides the name read from the file. Set it when your CSS asks for something else. |
weight | Optional. The CSS weight this file provides. Defaults to what the file declares. |
style | Optional. normal or italic. |
A declared family is never also requested from Google, so a self-hosted font of the same name as a Google one wins rather than being quietly replaced by a different cut of it.
Validation#
Edits are checked before they are accepted, not while they render. A malformed edit comes back as a 422 straight away, with the JSON path of every problem — no credits are held and no job appears in your history.
{
"success": false,
"message": "The edit has 3 problems that must be fixed before it can render.",
"code": "EDIT_VALIDATION_FAILED",
"errors": {
"timeline.tracks[0].clips[0].length": "Must be a number of seconds, \"auto\" or \"end\". Received the string \"4\".",
"timeline.tracks[0].clips[0].transition.in": "Unknown transition \"slideup\". Did you mean \"slideUp\"?",
"output.format": "Unknown format \"jpeg\". Did you mean \"jpg\"? Expected one of: mp4, webm, mov, …"
},
"issues": [
{ "field": "timeline.tracks[0].clips[0].length", "message": "…" }
]
}errors is a flat map keyed by field path; issues is the same set in the order the problems appear in your JSON, which reads top to bottom through the edit. Everything knowable from the JSON alone is checked here — required fields, types, enum names, ranges. Anything needing the network, such as whether an asset URL resolves, is still reported by the render itself.
POST /v1/render/estimate runs exactly the same check, so you can validate a template while you build it without spending anything.Output#
| Property | Values |
|---|---|
format | mp4, webm, mov, gif, jpg, png, webp, mp3, wav |
resolution | preview, mobile, sd, hd, fhd, 4k |
aspectRatio | 16:9, 9:16, 1:1, 4:5, 4:3, 3:4, 21:9, 2:3, 3:2 |
size | Explicit { width, height } — overrides resolution |
fps | Frames per second — 30 by default |
quality | low, medium, high, veryhigh |
motionBlur | Average several rendered positions into each frame, the way a shutter does. true is four samples, or give a number up to eight. Worth it for fast movement; costs roughly one render per sample. |
codec | h264 (default), h265, vp9, av1, prores. The container has the final say on what it can carry: prores is delivered as a .movwhichever format you asked for, since nothing else muxes it, and a codec a container cannot hold falls back to that container’s default. |
range | Export part of the timeline: { start, length } |
poster | Still at a given second: { capture: 1.5 } |
thumbnail | { capture, scale } |
mute | Drop all audio |
normalizeAudio | Normalise to −14 LUFS |
aspectRatio reshapes a resolution preset while preserving its pixel budget, so { resolution: "fhd", aspectRatio: "9:16" } gives 1080×1920 rather than a letterboxed 1920×1080.
timeline.background: "transparent" for an alpha channel. H.264 has no alpha, so an MP4 request is automatically switched to WebM — the response tells you the format actually produced.Merge fields#
Any string in an edit can carry a {{PLACEHOLDER}}, replaced before rendering. Template field values are exposed as merge fields automatically, so template authors can use whichever mechanism suits the spot.
Any string means any: the words of a text, title, caption, marquee, datetime or countdown asset; a colour, whether it is the type’s, a shape’s fill or a background; the src of any image, video, audio, luma or Lottie; a block of html or inline svg, including a single fill deep inside it; and a clip’s start or length. In the studio the {} button beside each of those fields names the variable for you.
{
"timeline": {
"tracks": [{
"clips": [{
"asset": { "type": "title", "text": "{{HEADLINE}}" },
"start": 0, "length": "{{DURATION}}"
}]
}]
},
"merge": [
{ "find": "HEADLINE", "replace": "Diwali Sale" },
{ "find": "DURATION", "replace": 5 }
],
"output": { "format": "mp4", "resolution": "fhd" }
}A placeholder that fills an entire string keeps the replacement’s type — so "{{DURATION}}" becomes the number 5, not the string "5".
Inspecting a source#
Before a clip goes on a timeline, POST /api/v1/video/info tells you what it actually is. It runs ffprobe against a public URL and returns the container, duration, real dimensions, frame rate, codecs, bitrate, rotation, and every audio and subtitle stream — no render, no credits.
curl -X POST https://pixbix.app/api/v1/video/info \
-H "x-api-key: $PIXBIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://cdn.acme.com/clips/diwali-hero.mp4" }'| Field | Why it matters |
|---|---|
durationSec | What length: "auto" will resolve to, and what to subtract when trimming. |
displayWidth / displayHeight | Stored dimensions with rotation applied — what the clip is actually shaped like. A phone video is routinely stored 1920×1080 with rotation: 90and plays as a portrait 1080×1920. |
fps | The average rate over the file, not the container’s nominal one, so variable-rate sources report honestly. |
hasAudio | A silent source makes volume and audio ducking settings on the clip do nothing. |
hasAlpha | Transparency survives only into webm and mov output. |
usable / warnings | Whether the engine will accept the file as-is, and every reason it might not — checked all at once rather than one failed render at a time. |
It reads more than video
Audio and image URLs probe the same way —kind comes back as audio or image. That is the reliable way to check a URL is what someone claims it is before building an edit around it.The engine reads the container header with ranged requests, so probing a 700 MB file usually transfers a few hundred kilobytes. Origins that refuse ranged reads force a full fetch, which is slower and reported as probedVia: "download".
Limits#
- Plan ceilings apply to duration, resolution and frame rate — see
/docs/credits. - Engine safety rails: 30 tracks, 400 clips, 512 MB per asset, 2 GB total per edit.
- A clip painted per frame — a text reveal, a Lottie, karaoke captions — is capped at 900 rastered frames, 30s at 30fps. A longer one still renders, with its animation time-stretched to fit the frames available.
- Assets must be reachable over public HTTPS. Private and internal addresses are refused.
- Fonts must be licensed for server-side rendering and embedding.
Endpoints#
A video render goes through the same POST /api/v1/renderas a still — the template’s kind decides which engine runs. The difference is that video is queued: you get a job id back immediately, and completion arrives by webhook or by polling.
Render a video
/api/v1/renderAPI keyrenders:writeCost Video credits scale with duration and resolution. Price it first with /v1/render/estimate.
Body parameters
templateIdstringrequired- Id or slug of the template to render.
valuesobjectrequired- Field key → value, matching the template schema from GET /v1/templates/:id. Any {{PLACEHOLDER}} in the template also accepts a value under its own key, whether or not it is a declared field.
output.format"mp4" | "webm" | "mov" | "gif"optional- Defaults to mp4.
output.resolution"sd" | "hd" | "fhd" | "4k"optional- Defaults to hd.
output.fpsnumberoptional- 24, 30 or 60.
webhookUrlstringoptional- Overrides the workspace webhook for this render only.
{
"templateId": "diwali-sale-story",
"values": {
"headline": "Diwali Sale",
"offer": "50% OFF",
"product": "https://cdn.acme.com/lamp.jpg"
},
"output": {
"format": "mp4",
"resolution": "fhd",
"fps": 30
}
}Try it
/api/v1/renderShow as cURL
curl -X POST "https://pixbix.app/api/v1/render" \
-H "x-api-key: pk_live_your_key" \
-H "Content-Type: application/json" \
-d '{ "templateId": "diwali-sale-story", "values": { "headline": "Diwali Sale", "offer": "50% OFF", "product": "https://cdn.acme.com/lamp.jpg" }, "output": { "format": "mp4", "resolution": "fhd", "fps": 30 } }'Render an edit directly
/api/v1/renderAPI keyrenders:writeCost Priced like any other video render — duration × resolution. Check it with /v1/render/estimate.
Body parameters
timelineobjectrequired- Tracks of clips, plus optional background, soundtrack and font declarations. Track 0 is the topmost layer. A track or a clip carrying enabled: false is dropped from the render, picture and audio both, without retiming anything around it — clips using start: "auto" still chain from where it ends, and the duration is unchanged. Absent means visible.
asset.font (title)objectoptional- A title names a look with `style` — Blockbuster is Oswald 700, Vogue is Playfair Display, and so on — and every part of that preset can be overridden: `font` (family, size in pixels, weight, colour, letterSpacing, lineHeight, style), `alignment` for both axes, and `stroke`/`shadow`. A `font.size` in pixels wins over the `size` keyword, which is a proportion of the frame. Anything omitted keeps the preset, so an existing title renders exactly as it did.
clip.fit"cover" | "contain" | "crop" | "none"optional- How the asset fills the box its scale and position describe. cover (the default) and crop scale it, aspect preserved, until it COVERS that box — the excess overflows and is cropped by the output frame, not by the box, so a 3:2 photo at scale 0.84 in a 16:9 frame still bleeds top to bottom. contain fits the whole asset inside the box; none leaves it at its native pixel size. Give a clip an explicit width/height or a mask and the box becomes a window instead: the picture is cut to it.
timeline.lumaPolarity"white" | "dark" | "auto"optional- Which way round every luma matte in the edit is read. A matte reveals where it is WHITE and hides where it is black — the convention After Effects, SVG and CSS masks all share. Plenty of template libraries author the opposite, the shape drawn black on a white field, and rendered as written those produce the negative of their own design. Set "dark" once and every matte in the edit follows, deterministically, with nothing measured. Omit it and each unstated matte is measured instead — a good guess, but a guess. A track sets its own to override this, and a clip’s invert overrides both.
track.lumaPolarity"white" | "dark" | "auto"optional- The same field per layer, and the fix for an edit that MIXES conventions. Measurement cannot tell a shape that is kept from a shape that is a hole — they are the same picture — so an edit combining a dark-keeps template with correctly authored stock mattes cannot be settled by inference at all. Put each set on its own layer and state it here.
asset.invert (luma)boolean | "auto"optional- One matte’s polarity, overriding its track and the timeline. true is dark-keeps, false is white-keeps, "auto" measures this matte and decides while ignoring any convention stated further out — which is how you exempt a single asset. Absent inherits.
asset.fit (luma)"stretch" | "cover" | "contain"optional- How the stencil maps onto the clip it masks. stretch (the default) fills that clip’s box exactly, which is right for a matte authored against this design; cover and contain keep the matte’s own aspect, which is what stops a square avatar stencil arriving as an oval on a portrait render. What a contain matte does not reach is KEPT, not cut away.
asset.fontScale (chart)numberoptional- Multiplies every piece of type a chart, table or widget draws — the title, the axis ticks, the category labels, the values on the marks, the legend and its swatches, a table’s rows, a widget’s readout. 1 is the chart as drawn. The one-field answer to a chart whose labels are too small, which is what a chart in a vertical frame usually is: it was laid out on a monitor and it is watched on a phone. Start around 1.4–1.8 for 9:16.
asset.text (chart)objectoptional- Per-role typography, for when one label has to move and the rest must not. Nine roles: `title`, `axis` (the value-axis ticks), `labels` (the category names), `values` (each datum beside its mark), `legend`, `header` and `cell` for a table, and `value` and `caption` for a widget. Each takes { family, size, scale, weight, color, opacity, letterSpacing, transform, align, offset, hidden }. `size` is absolute and ignores fontScale; `scale` is relative to the role’s own default and composes with it. `hidden` draws nothing AND gives the role’s room back to the plot, which is how a chart becomes a sparkline without a second asset.
asset.legend (chart)objectoptional- { position, align, direction, swatch, swatchSize, gap, itemGap, rowGap, size }. `position` is top, bottom, left or right — bottom by default on an axis plot and right on a pie — and the plot gives up the room rather than being drawn over, so moving a legend re-lays the chart around it. `swatch` is square, circle or line. Note this places a legend rather than turning one on: that is showLegend, which needs more than one series on an axis plot.
asset.padding (chart)number | objectoptional- EXTRA room around a chart’s plot, as a number or { top, right, bottom, left } — added to what the chart works out for itself rather than replacing it. Additive on purpose: the chart already sizes its left gutter to fit its own tick labels, and a caller who set that outright would have to recompute it every time the data changed. Negative values tighten.
asset.gridColor / axisTicks / valuePosition (chart)string | number | stringoptional- The gridline colour behind an axis plot (showGrid still owns whether they are drawn); the number of divisions on the value axis, 4 by default so five labels counting zero; and where showValues puts a bar’s number — "auto" keeps it outside unless it would overflow, then inverts it to white inside the bar, while "inside" always inverts and "outside" never does.
asset.speed / reverse (video, audio)number | booleanoptional- `speed` is the playback rate, 0.25–4, default 1; `reverse` plays the clip’s window backwards. Both retime the picture AND the sound, so a clip at 2 sounds fast rather than merely ending early and a reversed clip’s audio runs backwards with it. The clip’s length does not change — the rate decides how much SOURCE fills the window, so four seconds at 2 reads eight seconds of footage and runs out of picture if the file is shorter. Use "length": "auto" to take the playable length at the rate you set. Reversal cannot stream — the whole window is decoded and held — so keep reversed clips short.
outputobjectrequired- Format, resolution or size, fps, quality and encoding options.
output.bitrateKbpsnumberoptional- Target an average bitrate instead of a constant quality. Leave it off and the encode is CRF, which spends fewer bits for the same picture on the flat colour and text a template is mostly made of — set it only when you need a predictable file size.
output.twoPassbooleanoptional- Measure the material first, then spend the bitrate where the first pass found it was needed. It works only alongside bitrateKbps: against CRF there is no fixed budget to redistribute, so a second pass cannot change anything and the render stays single-pass rather than charging you for one. Roughly doubles encode time. Supported on h264, h265 and vp9.
output.normalizeAudiobooleanoptional- Normalise the finished mix to −14 LUFS, the level the social platforms target — so a quiet voice-over and a loud music bed arrive at a comparable loudness instead of being re-levelled on upload.
output.destinationsarrayoptional- Extra places to deliver the finished file, on top of pixbix storage: [{ provider, options }]. "s3" takes bucket, region, optional endpoint (which is what points it at any S3-compatible service) and credentials; "http" PUTs or POSTs the bytes to a signed upload URL, which is how Google Cloud Storage and Azure Blob are written to. A delivery that fails does NOT fail the render — the output still lands in pixbix storage and the reason comes back in the job’s warnings. Set exclude: true to keep a destination configured without using it.
mergearrayoptional- Placeholder substitutions applied to every string in the edit: [{ find, replace }].
callbackstringoptional- Overrides the workspace webhook for this render only.
{
"timeline": {
"background": "#000000",
"tracks": [
{
"name": "Type",
"clips": [
{
"asset": {
"type": "text",
"text": "Diwali Sale",
"font": {
"family": "Inter",
"size": 72,
"weight": 700,
"color": "#FFFFFF"
},
"alignment": {
"horizontal": "center",
"vertical": "center"
}
},
"start": 0,
"length": 3,
"transition": {
"in": "fade",
"out": "fade"
}
}
]
},
{
"name": "Countdown",
"clips": [
{
"asset": {
"type": "countdown",
"to": "2026-12-25T09:00:00Z",
"layout": "segments",
"units": [
"days",
"hours",
"minutes",
"seconds"
],
"labels": {
"show": true,
"style": "long",
"position": "below"
},
"segment": {
"background": "rgba(255,255,255,0.10)",
"borderRadius": 16,
"gap": 18
},
"urgency": [
{
"belowSec": 3600,
"color": "#FF6D00"
}
],
"expired": {
"text": "Sale closed"
},
"font": {
"family": "Inter",
"size": 84,
"weight": 800,
"color": "#FFFFFF"
}
},
"start": 0,
"length": 3,
"position": "center"
}
]
},
{
"name": "Numbers",
"clips": [
{
"asset": {
"type": "chart",
"chart": "bar",
"title": "Enquiries by month",
"data": {
"labels": [
"Jun",
"Jul",
"Aug",
"Sep"
],
"series": [
{
"name": "Rentals",
"values": [
18,
34,
27,
41
]
},
{
"name": "Sales",
"values": [
8,
11,
9,
14
]
}
]
},
"showLegend": true,
"showValues": true,
"font": {
"family": "Inter",
"color": "#E2E8F0"
},
"fontScale": 1.5,
"gridColor": "#334155",
"text": {
"title": {
"align": "left",
"scale": 1.2
},
"values": {
"weight": 700
},
"labels": {
"transform": "uppercase",
"letterSpacing": 1
},
"axis": {
"color": "#94A3B8",
"scale": 0.9
}
},
"legend": {
"position": "right",
"swatch": "circle"
},
"padding": {
"bottom": 8
},
"animation": {
"type": "drawOn",
"durationSec": 1.2
}
},
"start": 0,
"length": 3,
"width": 900,
"height": 460,
"position": "center"
}
]
},
{
"name": "Datestamp",
"clips": [
{
"asset": {
"type": "datetime",
"mode": "date",
"value": "now",
"format": "long",
"timezone": "Asia/Kolkata",
"prefix": "Offer valid ",
"font": {
"family": "Inter",
"size": 36,
"weight": 600,
"color": "#FFFFFF"
}
},
"start": 0,
"length": 3,
"position": "bottom",
"offset": {
"y": 0.06
}
}
]
},
{
"name": "Presenter",
"clips": [
{
"asset": {
"type": "video",
"src": "https://cdn.acme.com/presenter-greenscreen.mp4",
"chromaKey": {
"color": "#00B140",
"threshold": 60,
"halo": 24,
"spill": 0.6,
"edgeBlur": 2
}
},
"start": 0,
"length": 3,
"fit": "contain",
"position": "bottomRight",
"scale": 0.7
}
]
},
{
"name": "Shape",
"useLumaMatte": true,
"lumaPolarity": "dark",
"clips": [
{
"asset": {
"type": "luma",
"src": "https://cdn.acme.com/mattes/circle.png",
"fit": "cover"
},
"start": 0,
"length": 3
}
]
},
{
"name": "B-roll",
"clips": [
{
"asset": {
"type": "video",
"src": "https://cdn.acme.com/sparklers.mp4",
"trim": 2,
"speed": 0.5,
"reverse": true,
"mute": true
},
"start": 0,
"length": 3,
"fit": "cover",
"opacity": 0.35
}
]
},
{
"name": "Background",
"clips": [
{
"asset": {
"type": "image",
"src": "https://cdn.acme.com/lamp.jpg"
},
"start": 0,
"length": 3,
"fit": "cover",
"effect": "zoomIn"
}
]
}
]
},
"output": {
"format": "mp4",
"resolution": "fhd",
"fps": 30,
"quality": "high",
"bitrateKbps": 4500,
"twoPass": true,
"normalizeAudio": true
}
}Try it
/api/v1/renderShow as cURL
curl -X POST "https://pixbix.app/api/v1/render" \
-H "x-api-key: pk_live_your_key" \
-H "Content-Type: application/json" \
-d '{ "timeline": { "background": "#000000", "tracks": [ { "name": "Type", "clips": [ { "asset": { "type": "text", "text": "Diwali Sale", "font": { "family": "Inter", "size": 72, "weight": 700, "color": "#FFFFFF" }, "alignment": { "horizontal": "center", "vertical": "center" } }, "start": 0, "length": 3, "transition": { "in": "fade", "out": "fade" } } ] }, { "name": "Countdown", "clips": [ { "asset": { "type": "countdown", "to": "2026-12-25T09:00:00Z", "layout": "segments", "units": [ "days", "hours", "minutes", "seconds" ], "labels": { "show": true, "style": "long", "position": "below" }, "segment": { "background": "rgba(255,255,255,0.10)", "borderRadius": 16, "gap": 18 }, "urgency": [ { "belowSec": 3600, "color": "#FF6D00" } ], "expired": { "text": "Sale closed" }, "font": { "family": "Inter", "size": 84, "weight": 800, "color": "#FFFFFF" } }, "start": 0, "length": 3, "position": "center" } ] }, { "name": "Numbers", "clips": [ { "asset": { "type": "chart", "chart": "bar", "title": "Enquiries by month", "data": { "labels": [ "Jun", "Jul", "Aug", "Sep" ], "series": [ { "name": "Rentals", "values": [ 18, 34, 27, 41 ] }, { "name": "Sales", "values": [ 8, 11, 9, 14 ] } ] }, "showLegend": true, "showValues": true, "font": { "family": "Inter", "color": "#E2E8F0" }, "fontScale": 1.5, "gridColor": "#334155", "text": { "title": { "align": "left", "scale": 1.2 }, "values": { "weight": 700 }, "labels": { "transform": "uppercase", "letterSpacing": 1 }, "axis": { "color": "#94A3B8", "scale": 0.9 } }, "legend": { "position": "right", "swatch": "circle" }, "padding": { "bottom": 8 }, "animation": { "type": "drawOn", "durationSec": 1.2 } }, "start": 0, "length": 3, "width": 900, "height": 460, "position": "center" } ] }, { "name": "Datestamp", "clips": [ { "asset": { "type": "datetime", "mode": "date", "value": "now", "format": "long", "timezone": "Asia/Kolkata", "prefix": "Offer valid ", "font": { "family": "Inter", "size": 36, "weight": 600, "color": "#FFFFFF" } }, "start": 0, "length": 3, "position": "bottom", "offset": { "y": 0.06 } } ] }, { "name": "Presenter", "clips": [ { "asset": { "type": "video", "src": "https://cdn.acme.com/presenter-greenscreen.mp4", "chromaKey": { "color": "#00B140", "threshold": 60, "halo": 24, "spill": 0.6, "edgeBlur": 2 } }, "start": 0, "length": 3, "fit": "contain", "position": "bottomRight", "scale": 0.7 } ] }, { "name": "Shape", "useLumaMatte": true, "lumaPolarity": "dark", "clips": [ { "asset": { "type": "luma", "src": "https://cdn.acme.com/mattes/circle.png", "fit": "cover" }, "start": 0, "length": 3 } ] }, { "name": "B-roll", "clips": [ { "asset": { "type": "video", "src": "https://cdn.acme.com/sparklers.mp4", "trim": 2, "speed": 0.5, "reverse": true, "mute": true }, "start": 0, "length": 3, "fit": "cover", "opacity": 0.35 } ] }, { "name": "Background", "clips": [ { "asset": { "type": "image", "src": "https://cdn.acme.com/lamp.jpg" }, "start": 0, "length": 3, "fit": "cover", "effect": "zoomIn" } ] } ] }, "output": { "format": "mp4", "resolution": "fhd", "fps": 30, "quality": "high", "bitrateKbps": 4500, "twoPass": true, "normalizeAudio": true } }'Inspect a media URL
/api/v1/video/infoAPI keyrenders:readBody parameters
urlstringrequired- Public http(s) address of the file. Private, loopback and link-local addresses are refused, and every redirect hop is re-checked before it is followed.
{
"url": "https://cdn.acme.com/clips/diwali-hero.mp4"
}Try it
/api/v1/video/infoShow as cURL
curl -X POST "https://pixbix.app/api/v1/video/info" \
-H "x-api-key: pk_live_your_key" \
-H "Content-Type: application/json" \
-d '{ "url": "https://cdn.acme.com/clips/diwali-hero.mp4" }'Inspect a media URL (GET)
/api/v1/video/infoAPI keyrenders:readQuery parameters
urlstringrequired- Public http(s) address of the file, URL-encoded.
{
"success": true,
"message": "Media inspected",
"data": {
"kind": "video",
"durationSec": 32.533,
"duration": "00:00:32.533",
"sizeBytes": 18446221,
"hasVideo": true,
"hasAudio": true,
"video": {
"codec": "h264",
"displayWidth": 1920,
"displayHeight": 1080,
"fps": 29.97,
"orientation": "landscape"
},
"usable": true,
"warnings": []
}
}Try it
/api/v1/video/info?url=https%3A%2F%2Fcdn.acme.com%2Fclips%2Fdiwali-hero.mp4Show as cURL
curl -X GET "https://pixbix.app/api/v1/video/info?url=https%3A%2F%2Fcdn.acme.com%2Fclips%2Fdiwali-hero.mp4" \ -H "x-api-key: pk_live_your_key"
List available fonts
/api/v1/fontsNo authQuery parameters
familystringoptional- Check one exact name. Returns { supported, font } instead of a list. Case- and whitespace-insensitive.
searchstringoptional- Substring match on the family name.
category"sans-serif" | "serif" | "display" | "handwriting" | "monospace"optional- Restrict to one category.
limitnumberoptional- 1–2000. Defaults to 100.
{
"success": true,
"data": {
"total": 1942,
"matched": 349,
"returned": 100,
"categories": [
"sans-serif",
"serif",
"display",
"handwriting",
"monospace"
],
"generics": [
"sans-serif",
"serif",
"monospace",
"system-ui",
"cursive"
],
"fonts": [
{
"family": "Playfair Display",
"category": "serif",
"weights": [
400,
500,
600,
700,
800,
900
],
"italic": true,
"variable": true
}
]
}
}Try it
/api/v1/fonts?family=Playfair+Display&search=mono&category=serif&limit=100Show as cURL
curl -X GET "https://pixbix.app/api/v1/fonts?family=Playfair+Display&search=mono&category=serif&limit=100" \ -H "x-api-key: pk_live_your_key"
Font categories
/api/v1/fonts/categoriesNo auth{
"success": true,
"data": {
"total": 1942,
"categories": {
"sans-serif": 717,
"serif": 349,
"display": 467,
"handwriting": 358,
"monospace": 51
},
"generics": [
"sans-serif",
"serif",
"monospace",
"system-ui",
"cursive"
]
}
}Try it
/api/v1/fonts/categoriesShow as cURL
curl -X GET "https://pixbix.app/api/v1/fonts/categories" \ -H "x-api-key: pk_live_your_key"
Every endpoint#
The rest of the public API, grouped by what it renders. Endpoints outside this page link to the reference that documents them.
Postman#
Generated from the same catalogue as this page. In Postman choose Import → Link and paste either URL.
Collection
Every public v1 endpoint, foldered by API, plus webhook management — with example bodies and saved responses.
https://pixbix.app/postman/collection.jsonOpen JSONEnvironment
baseUrl plus empty, secret-typed apiKey and authToken. Fill the credentials in Postman, not here.
https://pixbix.app/postman/environment.jsonOpen JSON