Skip to content

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#

Minimal edit
{
  "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#

ValueMeaning
start: 2.5Seconds from the start of the timeline
start: "auto"Immediately after the previous clip on the same track
length: 4Seconds 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#

TypePurposeKey properties
videoA video clipsrc, trim, volume, speed, reverse, crop, chromaKey
imageA stillsrc, crop, chromaKey
textFully controlled texttext, font, stroke, shadow, background, alignment, gradient, animation
titlePreset-styled titletext, style, size, position — plus font, alignment, stroke and shadow to override any part of the preset
htmlArbitrary HTML/CSShtml, css, width, height
shapeRectangle, circle, star…shape, fill, stroke, borderRadius, points
svgSVG file or inline markupsrc or markup
lottieLottie animationsrc, speed, loop, colorOverrides
audioAudio tracksrc, trim, volume, effect, speed, reverse
captionBurned-in captionscues, src or script + audio, font, background, karaoke
chartData-driven chart, table or widgetchart, data, table, value, colors, animation
lumaLuminance mattesrc, invert (inherited by default), fit, trim
marqueeScrolling ticker texttext, direction, speed or cycles, font, background, label, separator
datetimeA date or time, filled in at rendermode, value, offset, format, timezone, locale, binding
countdownA timer to or from a momentto or from, direction, units, layout, labels, segment, urgency, expired
A 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.
One matte can cut several clips. A matte sharing a track with a clip is a deliberate pairing and cuts that one clip. A matte on its own track cuts every clip it overlaps in the nearest layer below — so a matte track laid over a row of photographs shapes all of them, not just the first. It reaches one layer down and no further, leaving the backdrop a masked clip is cut away to reveal still showing through.
Importing a template from elsewhere? Many libraries author mattes the other way round — the shape drawn in black on a white field. Rendered as-is, that produces the negative of the design: every panel filled solid with the shapes punched out as holes. You do not have to do anything about it. A luma asset that does not state 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.
Polarity is settled by whoever stated it, closest first. A clip’s own 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.
LevelFieldUse it for
Clipasset.invertThe one matte that differs from everything around it.
TracklumaPolarityA 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.
TimelinelumaPolarityA whole library authored one way round. One field, every matte in the edit, no guessing anywhere.
ValueMeaning
absentInherit 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.
trueDark keeps. Stated, never second-guessed. On a track or timeline, spelled "dark".
falseWhite keeps. The opt-out for a matte that really is meant to punch a hole. On a track or timeline, spelled "white".
When it does come down to measuring, the decision is made once per edit rather than once per matte, because that turns out to be the only reliable signal there is: a matte of light shapes on a dark ground is, in isolation, indistinguishable from one whose shapes are holes. So every unstated matte is measured, and one matte drawn unmistakably dark-keeps settles the reading for all of them. An all-light border around a real shape, down to a hairline outline, has only one explanation; a dark border with a light shape has two, so it is evidence for neither convention and never overrules. Animated mattes and soft gradients never vote — a wipe’s border is wherever the sweep has reached, which says nothing about how it was authored — and simply follow whatever the rest of the edit concludes. Every decision is written to the render log.
Getting different results from different mattes? That is inference doing its best on files that cannot be told apart. Set lumaPolarity on the timeline — or per layer, if the edit mixes conventions — and the guessing stops entirely.
A matte is stretched to the box of the clip it cuts, because it describes a shape in that clip’s own coordinate space. Set 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.
Set 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.
A matte combines withtransparency the clip already has rather than overriding it. Mask a chroma-keyed presenter and you get the keyed subject inside the matte’s shape — not the original rectangle back. The same holds for a transparent PNG, an alpha WebM, and a clip carrying a mask: wherever either the clip or the matte is transparent, the result is transparent.
A matte may be a video as well as an image, which is how a wipe or an animated reveal is built: each frame of the matte cuts that frame of the clip. If the matte is shorter than the clip, its last frame holds. In the studio a video matte is sampled as you scrub — smooth playback there is approximate, while the render is exact.
A 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.
Text is rendered by headless Chrome, not by an encoder’s text filter. That is what makes typography in a video frame identical to the same design rendered as a poster — real web fonts, real shaping, gradient fills clipped to glyphs.

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.

PropertyValues
speed0.25–4. Default 1. Picture and sound are both retimed.
reversetrue / false. Default false. Plays the clip’s window backwards, audio included.
Reversal is the one operation that cannot stream: the last frame has to be read before the first can be written, so the whole window is decoded and held. Keep reversed clips short — a few seconds — and prefer reversing one clip over reversing a whole scene.
Anything above about 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#

PropertyValues
fitcover (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.
positionNine anchors: top, topLeft, center, bottomRight
offsetFractions of the frame: { x: 0.1, y: -0.2 }. Positive y is up.
scaleMultiplier 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 / heightExplicit 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.
opacity0–1
adjustmentsBrightness, 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.
transformrotate, skew, flip
blendModemultiply, screen, overlay, softLight…
maskrectangle, circle or ellipse, with borderRadius and feather
enabledfalse drops the clip from the render, picture and audio both. Set it on a track to drop every clip on that layer.
A clip with 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.

PropertyRangeWhat it does
colorhexThe backdrop colour to remove
presetgreen, blue, white, blackA named screen colour, used when color is absent
modechroma (default), colorMatch on hue and ignore lighting, or compare raw RGB
threshold0–255, default 40How close a colour must be to the screen to go
halo0–255, default 20Softness of the cut — partial alpha at the edge
spill0–1, default 0Drain the screen’s colour cast off the subject
shrink0–8 source pxEat the matte back to lose a coloured fringe
edgeBlur0–20 source pxFeather the edge so it is not a hard stencil
showMattebooleanDraw the matte instead of the picture, to tune the key
enabledbooleanSet 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.
Spill is not the same knob as threshold. A green screen throws green light onto whatever stands in front of it, and those pixels genuinely belong to the subject — raising 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.
The studio previews the key live, using the same arithmetic the engine does, so a threshold that looks right there renders right. 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.

Names
"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, whipRight

Effects

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.

Effects
"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 suffix

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

containerMotion 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
FamilyNamesCounts by default
ArrivefadeIn fadeUp fadeDown slideUp slideDown slideLeft slideRight zoomIn zoomOut popIn blurIn trackIn trackOutThe whole block
Assembletypewriter wordByWord letterByLetter lineByLine cascade riseUp dropInLetters, words or lines
PhysicalspringIn bounceIn elasticIn swingInWords, or letters
TurnflipIn rollIn tumbleInLetters
OpticalfocusIn shutterIn maskUp wipeIn glitchIn neonFlickerLetters, words or lines
Loopwave float pulse shimmerRuns 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 a length: "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.

Auto-fit
"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.

Warp
"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"
WarpWhat it doesHow
flatA straight baseline. The default.
leftUpliftThe left end rides high, the line falling away to the rightTilts the whole line
rightUpliftThe mirror of itTilts the whole line
slantLeftThe type leans; the baseline does not moveShears the whole line
slantRightThe same, the other wayShears the whole line
arcUpSet on a curve, the middle risingPlaces each letter
arcDownSet on a curve, the middle droppingPlaces each letter
circleWrapped round a circle, reading along the topPlaces each letter
circleDownThe same circle, read along the bottomPlaces each letter
waveA sine through the linePlaces each letter
zigzagAlternating letters, up and downPlaces each letter
stairsUpEach letter a step higher than the lastPlaces each letter
stairsDownEach letter a step lowerPlaces each letter
fanLetters splayed from a shared centrePlaces each letter
perspectiveLeftReceding to the leftTurns the whole line
perspectiveRightReceding to the rightTurns 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" }
  }
}
FieldShorthandLong form
paddingA number, every side{ top, right, bottom, left }
borderRadiusA 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.

Ticker
{
  "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
"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. An opacity 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.
chartDrawsNeeds
barVertical bars, grouped by seriesdata.values
hbarHorizontal bars — better for long category namesdata.values
lineA line per series, with markersdata.values
areaA line with the area beneath it filleddata.values
pieOne set of values as shares of a wholedata.values
donutA pie with the centre cut outdata.values
tableRows and columns, with badges and inline barstable.rows
progressA bar filling towards a targetvalue
ringA ring filling towards a targetvalue
gaugeA half-circle dialvalue
kpiA single large number with a captionvalue

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

FieldArray formString 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
value12400"12400"
max100"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.
RoleThe label it addressesDefault size
titleThe heading inside the chart’s own boxfont.size + 3
axisThe value-axis ticks, beside the gridlinesfont.size − 1
labelsThe category names along the other axisfont.size
valuesEach datum beside its own mark, and a pie’s sharefont.size − 1
legendThe series names beside their swatchesfont.size
headerchart: "table" — the heading rowfont.size
cellA table’s body cells, badges includedfont.size
valueA widget’s readout — the big numberA fraction of the widget
captionA widget’s label, under that numberA 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>TakesNotes
sizenumberAbsolute. Ignores fontScale — an absolute instruction should not be multiplied by a global one.
scalenumberRelative to the role’s default, and composes with fontScale.
familystringFalls back to the chart’s font.family.
weightnumber or string400–900, or a CSS keyword.
colorstringAny CSS colour.
opacity0–1Left off the element entirely at 1.
letterSpacingnumberIn the chart’s own coordinate space.
transformnone | uppercase | lowercase | capitalizeApplied before escaping, so entities survive it.
alignleft | center | rightWhere the role has a choice: title and legend.
offset{ x, y }A nudge, applied after everything else.
hiddenbooleanDraws nothing and gives the role’s room back to the plot.
Sizes are in the chart’s own coordinate space, not output pixels. A chart is drawn in a canonical box sized by its aspect and mapped onto the requested pixels by the SVG viewBox, so "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.
legendTakesDefault
positiontop | bottom | left | rightbottom for an axis plot, right for a pie
alignstart | center | endstart
directionhorizontal | verticalAcross on a top/bottom edge, down on a left/right one
swatchsquare | circle | linesquare
swatchSizenumberTracks the legend’s type size, never below 10
gapnumberBetween a swatch and its text — 4
itemGapnumberPitch between items across — fitted to the width
rowGapnumberPitch between rows down — the swatch plus 8
sizenumberThe 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 plot showLegend 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

FieldTakesDoes
paddingnumber 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.
gridColorstringThe gridlines behind an axis plot. showGrid still decides whether they are drawn at all.
axisTicksnumberDivisions on the value axis — 4 by default, so five labels counting zero.
valuePositionauto | inside | outsideWhere 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.
All of this reaches the image API too, under the layer’s own names — 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.
Colours come from the product’s chart palette in series order, or from your own 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.

TokenMeaningExample
YYYY YYYear2026 · 26
MMMM MMM MM MMonthAugust · Aug · 08 · 8
DD D DoDay of month14 · 14 · 14th
dddd ddd ddWeekdayFriday · Fri · F
HH HHour, 24-hour15
hh hHour, 12-hour03 · 3
mm mMinute42
ss sSecond09 · 9
SSS SS SFraction of a second040 · 04 · 0
A aMeridiemPM · pm
Z ZZUTC offset+05:30 · +0530
z zzzzZone nameGMT+5:30 · India Standard Time
Q W DDDQuarter, ISO week, day of year3 · 33 · 226
X xUnix timestamp, seconds / millis1786702329
Words that are not part of a date go in [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.
A countdown is live by default — it would not be much of a countdown otherwise. It repaints once per tick of its smallest visible unit, so a ten-minute timer showing seconds is 600 paints regardless of frame rate, and one showing only days and hours is a handful. Set binding: "render" to freeze it at the moment the render starts.
Give 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 itTiming comes fromUse when
Transcribe itRecognising the speech, word by wordAlmost always — you need neither a transcript nor a subtitle file
transcribe + audioThe same, but at render timeAutomation — see captioning in bulk
cuesExactly what you specifyYou already know the timings
srcAn SRT or WebVTT fileYou have a subtitle file or transcript
script + audioListening to the pauses in a voice-overA generated voice-over with no transcript, and no provider connected
The first is the one to reach for. Transcription produces 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, pass cues 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.

A voice-over per render
{
  "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 }
      }
    }] }]
  }
}
A placeholder left unresolved is rejected before the render starts, naming the field and the merge entry to add. Without that check it would reach the engine as a URL that fails to download — logged as a warning, and captions that simply never appear.

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.

styleWhat happens to the spoken wordReads well on
colorRecoloured, nothing elseBusy footage — the subtlest of the ten
fillA filled chip lands behind itSocial video; the default look
boxAn outline traces it, the fill left aloneBright footage where a chip would shout
underlineA rule struck under itHand-drawn and editorial styles
scaleIt growsAny palette — no colour change at all
bounceIt lifts and settles across its own durationPlayful brands
glowIt bloomsDark footage and night shots
shadowIt gains a hard offset shadowFlat, graphic looks
wipeIt fills left to right as it is saidThe closest to a real karaoke track
noneNothing; the cue is a block of textOrdinary 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.

presetWhat it is
subtitleBroadcast standard. White on a translucent plate, low in the frame. The default.
cleanNo plate — a heavy outline instead, so nothing boxes in the picture.
boldHeavy uppercase on a solid chip. Built to survive a busy shot.
karaokePopThe social-video look — a yellow chip lands on each word as it is spoken.
highlightThe spoken word changes colour. The quietest way to follow a voice.
lowerThirdLeft-aligned against a bar, the way an interview is captioned.
cinemaRestrained serif, wide margins, no plate. For a film rather than a feed.
minimalLight type, no outline, no plate. Clean footage and a quiet brand.
wordPopOne enormous word at a time, dead centre. Built for a 9:16 frame.
typewriterWords land one at a time and stay. Monospace, on a dark slab.
neonCyan glow on the spoken word. Made for dark footage.
karaokeWipeEach word fills across its own duration.
bounceThe spoken word lifts and settles.
markerHand-drawn, with a rule struck under the word being spoken.
boxedAn 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.

ProviderReads videoDetects languageSpeakersKeyword biasing
deepgramYesYesYesYes
aws-transcribeYesYesYesCustom vocabularies only
google-speechNo — audio onlyNoNoNo

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 beforeis queued to the engine immediately, with the cues already in itNothing — no provider is called
is newwaits at queued while it is transcribed, then goes on by itselfOne 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.
If a transcription fails, the render fails and the credits are returned. That is deliberate: the alternative is a video that renders successfully with its captions silently missing, which is a broken deliverable reporting itself as a success — and at two thousand renders nobody is watching each one.

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 thisCosts
wordsPerCue, charsPerCue, maxCueSecNothing — the stored word timings are re-cut
preset, karaoke, animation, any styling at allNothing — styling never reaches a provider
languageCode, model, diarize, keywords, punctuationA fresh transcription — these change the transcript
cache: falseA 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#

Soundtrack
"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:

Check one name
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.

The studio’s font picker is backed by this same catalogue, so a font chosen there is always one the engine can serve. Type into its preview box to see your own words set in each face before you commit.

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.

PropertyMeaning
srcRequired. https URL to the font file.
familyOptional. Overrides the name read from the file. Set it when your CSS asks for something else.
weightOptional. The CSS weight this file provides. Defaults to what the file declares.
styleOptional. 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.

Fonts you host must be licensed for server-side rendering and embedding. Google Fonts already are.

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.

422 response
{
  "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#

PropertyValues
formatmp4, webm, mov, gif, jpg, png, webp, mp3, wav
resolutionpreview, mobile, sd, hd, fhd, 4k
aspectRatio16:9, 9:16, 1:1, 4:5, 4:3, 3:4, 21:9, 2:3, 3:2
sizeExplicit { width, height } — overrides resolution
fpsFrames per second — 30 by default
qualitylow, medium, high, veryhigh
motionBlurAverage 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.
codech264 (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.
rangeExport part of the timeline: { start, length }
posterStill at a given second: { capture: 1.5 }
thumbnail{ capture, scale }
muteDrop all audio
normalizeAudioNormalise 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.

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

Merge
{
  "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" }'
FieldWhy it matters
durationSecWhat length: "auto" will resolve to, and what to subtract when trimming.
displayWidth / displayHeightStored 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.
fpsThe average rate over the file, not the container’s nominal one, so variable-rate sources report honestly.
hasAudioA silent source makes volume and audio ducking settings on the clip do nothing.
hasAlphaTransparency survives only into webm and mov output.
usable / warningsWhether 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

POST/api/v1/renderAPI keyrenders:write
The same endpoint as an image render — the template’s kind decides the engine. Video is queued: the response returns immediately with a job id, and completion arrives by webhook or by polling GET /v1/render/:id. Submit as many as you like — your plan’s concurrent-render allowance controls how many run at once, not how many you may send. Anything over it waits at `queued` and starts automatically as slots free, with `stage` saying so.

Cost Video credits scale with duration and resolution. Price it first with /v1/render/estimate.

Body parameters

templateId
stringrequired
Id or slug of the template to render.
values
objectrequired
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.fps
numberoptional
24, 30 or 60.
webhookUrl
stringoptional
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/render
Show 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

POST/api/v1/renderAPI keyrenders:write
The same endpoint again, given a whole edit instead of a template id. Nothing is looked up and nothing is stored, so this is the form to use when your own system already owns the composition. The video studio’s “Edit spec” export is exactly this body, and the studio imports it back.

Cost Priced like any other video render — duration × resolution. Check it with /v1/render/estimate.

Body parameters

timeline
objectrequired
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.
output
objectrequired
Format, resolution or size, fps, quality and encoding options.
output.bitrateKbps
numberoptional
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.twoPass
booleanoptional
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.normalizeAudio
booleanoptional
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.destinations
arrayoptional
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.
merge
arrayoptional
Placeholder substitutions applied to every string in the edit: [{ find, replace }].
callback
stringoptional
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/render
Show 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

POST/api/v1/video/infoAPI keyrenders:read
Runs ffprobe against a public URL and returns everything it can read — duration, dimensions, frame rate, codecs, bitrate, rotation, audio and subtitle streams, chapters and container metadata — without rendering anything and without spending a credit. Also reports whether the file is usable as a clip source, and what would go wrong if not. The engine reads the container header over ranged HTTP, so a large file normally costs a few hundred kilobytes of transfer rather than a full download. Works on audio and image URLs too, which is how you answer "is this actually a video?".

Body parameters

url
stringrequired
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/info
Show 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)

GET/api/v1/video/infoAPI keyrenders:read
The same probe as the POST above, with the URL as a query parameter — a read with one argument, so it pastes into a browser or a curl one-liner. Identical response, identical metering.

Query parameters

url
stringrequired
Public http(s) address of the file, URL-encoded.
Response
{
  "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.mp4
Show 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

GET/api/v1/fontsNo auth
Every font family the render engine can serve, most popular first, with the weights each one ships. Open — it needs no API key. Pass ?family= to check one exact name before you spend a render on it: Google answers a request for a family it does not have with a silent fallback, so a typo produces a finished video in the wrong typeface and no error anywhere.

Query parameters

family
stringoptional
Check one exact name. Returns { supported, font } instead of a list. Case- and whitespace-insensitive.
search
stringoptional
Substring match on the family name.
category
"sans-serif" | "serif" | "display" | "handwriting" | "monospace"optional
Restrict to one category.
limit
numberoptional
1–2000. Defaults to 100.
Response
{
  "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=100
Show 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

GET/api/v1/fonts/categoriesNo auth
How many families sit in each category — enough to build a picker’s filter bar without pulling two thousand records to count them.
Response
{
  "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/categories
Show 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 JSON

Environment

baseUrl plus empty, secret-typed apiKey and authToken. Fill the credentials in Postman, not here.

https://pixbix.app/postman/environment.jsonOpen JSON