The model
You author a video as a single JSON object — a RenderRequest — submitted to a
deterministic C++ render engine. Field names are snake_case. Unknown or misspelled
fields are rejected at submit time with a JSON pointer to the offending path and a
did-you-mean suggestion; fix and resubmit.
This page is the semantics — the renderer behavior that is not visible in the field
list, and the silent defaults that make an otherwise-valid request render wrong. For the
exhaustive field-by-field list (every element, every option, generated from the source of
truth), read the RenderRequest schema reference alongside
this. When you are ready to submit and await the artifact, hand off to the
render-video skill.
Semantics you must know (renderer behavior)
- Times are absolute output-timeline seconds.
time = when the element
appears; text/image/shape need duration. Video/audio length comes
from in_point/out_point (seconds into the source file); always set
out_point explicitly.
- Layering: the painter’s rule. An explicit
z_index always wins between
distinct values; among elements that don’t set one (defaults: text 1,
everything else 0), ties resolve by array order — later in elements renders
on top. So an overlay listed after a video stacks above it with no z_index
needed; set z_index only to override array order.
- Positions/sizes:
x/y are the element’s CENTER as canvas percentages
("50%" = centered). width/height accept "38%" (of canvas) or "140px".
Font sizes are pixels at the canvas width — the renderer normalizes
font_size by width only, so the same value is the same visual size whatever
the output height. Rescaling a measurement from a reference frame uses the
width ratio, never the height ratio.
- Entrances/exits:
"reversed": true runs a preset’s exit form, and only
presets that ship both accept it (fade, slide_*, wipe_*, rotate_*,
bounce). Entrance-only presets have a separate exit-only twin instead —
zoom_out (not zoom_in reversed), evaporate (not blur), whip_down,
drift_out, swing_out, elastic_drop — and those six set reversed for
you, so never type it. Asking for a missing exit form is a 422 that names the
replacement. Motion time is absolute; for entrances and loops set it to the
element’s time, for exits to element time + duration − motion duration.
delay only shifts that start (time + delay) on a non-loop motion, and
spaces the repeats on a loop_* one — it never staggers glyphs. Per-glyph or
per-word stagger is spacing on the motion entry: 1 (the engine default)
plays the glyph windows fully sequentially, smaller values overlap them, and
block animations ignore it.
- Motion presets are surface-split. Text elements and everything else load
animations from two different renderer directories that overlap on only a
handful of names, so a preset is not portable:
blur and evaporate are
text-only; swing_in, tilt_zoom, ken_burns_* and identity are
element-only; fade, slide_* and zoom_* work on both. A mismatch is a 422
naming the other surface. scope: "character" applies only to per-glyph text
presets — whole-block presets (fade, slide_up, zoom_in) reject it.
Read GET /v1/capabilities → motions[], where each preset carries
element_entrance / element_exit / character / text_block, before
picking one.
- Gradient
angle_degrees is mathematical (counterclockwise, y-up):
0° runs left→right (offset 0 at the left), 45° bottom-left→top-right,
315° top-left→bottom-right. The same convention governs a shape’s gradient
fill and the request-level background_gradient. A CSS string is converted
for you (angle_degrees = (90 − css_deg) mod 360), and a numeric CSS angle is
box-relative: 135deg behaves as to bottom right whatever the card’s aspect,
so only 0/90/180/270 are pixel-exact on a non-square box. A radial center.y
runs bottom-up — {x: 0.5, y: 0} is the bottom edge, the opposite of CSS
at 50% 100%.
- Per-word captions:
word_animation.words[*].start/end are ABSOLUTE
timeline seconds (not element-relative). Styles color and box paint with
the element’s background_color, which is required for them — omit it and
the request 422s (an absent value resolves to opaque black on the renderer, an
invisible highlight behind a 200). glow, scale_pop, slide_up and
fly_in do not use it. A text element takes exactly one animation route:
motion[], or word_animation, or animation_preset. Combining
word_animation (or animation_preset) with motion[] is a 422 — the engine
reads the word object only when no animations array is emitted, and its word
timings would re-time the motion entries.
- Text is a different renderer. These are 422s on
text: border_radius
≠ 0 (put the text over a rounded shape card), static
x_rotation/y_rotation (tilt only via a custom_animations keyframe
rotation: {x, y, z}), x_anchor/y_anchor other than "50%" (text rotates
about its box centre), and text_decoration: "underline" /
"strikethrough" (use a per-word style.underline in words[], or draw a
thin shape rule). background, stroke and shadow select one renderer
display mode, so setting two of them is a 422 — pick one. An element carrying
a counter also rejects per-glyph animation (a per-glyph animation_preset,
or a scope: "character" motion): glyph timings are computed once against the
starting value, so later glyphs of longer numbers never render — animate a
counter with a block preset (fade, scaleIn).
- Sources: a video
source_url must end .mp4, .mov or .webm — any
other extension is a 422 at submit, so re-encode first. Audio: use
AAC/M4A/MP3. Give the audio element an explicit out_point; keep it ≤ the
composition duration.
- A solid or gradient card/bar is a
shape with a rectangular path
("M0 0 H100 V100 H0 Z", rounded with corner_radius) and a fill — a hex
colour, a Gradient object ({kind, angle_degrees, stops}, ≥2 stops), or a CSS
linear-gradient(...) / radial-gradient(...) string, which the API normalizes
into the Gradient object at validation. This is the only spelling: the
gradient element was removed and {"type": "gradient"} is a 422 whose message
names shape and carries the JSON to send instead. A flat card is the same
shape with a hex fill. Full-frame overlays: x/y "50%", width/height
"100%".
- A gradient
fill is just a paint: any path, plus everything else on the
element. It sits where a flat hex colour would sit, on the shape’s own
path — so a triangle, a blob or an icon outline takes a gradient as readily
as the card’s rectangle, and the same element can also carry
stroke/stroke_width/stroke_dash (a gradient shape outlined in a flat
colour), path_keyframes (morph a gradient-filled path), trim/trim_keyframes
(draw it on), and the compositing family — blend_mode,
mask_shape/mask/mask_keyframes, matte, backdrop_blur. fill_rule,
corner_radius and opacity behave exactly as with a flat fill. Two stacked
shapes — a gradient one behind a flat one — remain a good way to build a
layered look when you want two layers.
- Transparent output:
alpha: true requires output_format "webm" or
"mov" — with mp4 it is a 422. With alpha on, background_color is
ignored and the wire background is forced fully transparent, so an alpha
export cannot also carry a coloured backdrop; if you need one, draw it as a
full-frame shape. Everything else composites normally.
- Transitions are video-to-video only. Both
from_id and to_id must name
visible video elements — a transition onto an image, text or shape is
rejected (it crashes the render node). Each clip may be the outgoing
side of at most one transition and the incoming side of at most one.
{"type": "cross_dissolve", "duration": 0.8, "from_id": "clip1", "to_id": "clip2"} with clip2’s time at the boundary. To cross-fade stills,
animate opacity with fade motions instead.
- Speed ramps:
playback_rate_keyframes on a video are piecewise-linear
time/rate points in absolute timeline seconds (rate 0 = freeze), at most
512, and the times must be strictly increasing — for an instant step put
the two rates t and t + 0.001 apart. The audio inside the clip keeps the
static rate, so mute the video element (volume: 0, legal there) or move
the sound to its own audio element. A ramp ending at rate 0 needs an
explicit request-level duration to say when the freeze stops, otherwise it
is rejected. An audio element cannot be muted at all — its volume must be
0; delete it instead (visible: false drops the track, it is not a mute).
- Masks:
mask_shape ("circle", …) clips the element;
mask (with scale, center) sizes it (scale ≈ 0.15 = small iris, ≈ 2.0
covers the frame); mask_keyframes animates it over time. Masks,
blend_mode, matte and backdrop_blur exist on video, image and
shape only — on text they are unknown fields and the request 422s with
Extra inputs are not permitted. For text blends use the
motion presets overlay / difference; for a masked or blended card, put the
text over a shape.
- Shadows and radii use four different scales. On
video/image,
shadow_x/shadow_y and border_radius are real pixels — but a shadow whose
offset is 0,0 is rejected outright (the renderer skips the pass entirely, so
set at least 1px). On shape, shadow.distance/blur are the
engine’s 0–100 strength scale (reference scenes author 60–70; a px-looking
8 renders under a pixel on a small card) and corner_radius is already a
0–1 fraction of half the shorter side, where 1.0 is a pill. Percentage
strings are rejected on every pixel-unit field (border_radius,
border_width, shadow_x, shadow_y, backdrop_blur) — pass a number or
"Npx". On text, shadow_x/shadow_y/shadow_blur are a scale of their
own again — pixels normalized against the canvas, neither the 0–100 strength
of shape nor the border-grown pixels of video/image — so
author them as a few px of offset and a blur in the tens, and never carry a
card’s distance: 60 onto a text element.
- 3D tilt on
video, image and shape:
x_rotation/y_rotation accept degree strings ("18°" or "18deg").
Animate the tilt — and tilt text at all — with a custom_animations
definition whose keyframes carry rotation: {x, y, z}, bound via
motion: [{"custom": "<name>", ...}].
- A
custom_animations keyframe time is a 0–1 fraction of the animation,
not seconds (0 = start, 1 = end); the wall-clock window comes from the
motion[] entry’s time/duration that binds it. Every other keyframe list
— mask_keyframes, playback_rate_keyframes, path_keyframes,
trim_keyframes, intensity_keyframes, word_animation.words[] — is in
absolute timeline seconds. (counter.start_time is a third unit: seconds
into the element.)
- Accepted but inert — do not build on these.
text.opacity is never
emitted (fade text with a fade motion, not a static opacity).
motion[].easing is ignored — curves are baked into the preset, or into your
custom_animations keyframes’ own easing. x_scale/y_scale,
aspect_ratio, clip, name and track cross nowhere; size with
width/height. lut_url/lut_intensity are accepted on image but
applied only on video — grade a still with temperature/tint/vibrance/
highlights/shadows, or bake it in. flip_horizontal/flip_vertical cross
only on video and image (mirror a shape by rewriting its path). On
text, background_opacity, x_padding and y_padding cross nowhere: use
an 8-digit background_color ("#000000aa") for a translucent plate, and pad
by widening width or setting the text over a shape card.
(color_overlay is the opposite case — it is rejected outright; tint with a
blend-moded overlay element.)
image and shape elements require a non-empty id and a
duration. text requires duration; an id is optional there, but give
every element a unique one anyway — duplicates are a blocking
DUPLICATE_ELEMENT_ID, and transitions, groups, mattes and ops all address
elements by id.
A minimal composition
A dark hero card with a headline that fades in — note the explicit duration on the text,
the center-based % position, and the entrance motion whose time matches the element’s:
A gradient card
Do not write a gradient element — it was removed. {"type": "gradient", …} is a 422
whose message names shape and includes the exact JSON to send. Stored projects needed no
editing: every saved composition was rewritten to the shape form, which renders identically.
To port a body by hand: type "gradient" → "shape", rename gradient → fill, add
"path": "M0 0 H100 V100 H0 Z". Everything else (corner_radius, shadow, effects,
motion, the transform fields) keeps its name and units.
The panel a brief calls a “gradient background” or a “hero card” — a shape with a
rectangular path, a corner_radius and a gradient fill. List it before the headline and
the painter’s rule puts the text on top with no z_index:
The CSS shorthand is accepted wherever the object is — a shape’s fill and the request-level
background_gradient — and is normalized at validation, so the stored composition and every
read-back hold the object, never the string:
What the CSS parser cannot represent it refuses with the fix in the message: repeating-*
and conic-gradient(...), var()/calc(), vendor prefixes, a unitless angle (45 →
45deg) and pixel stop positions (40px → 25%).
Beyond the basics — what else the engine draws
shape — an SVG d path with fill/stroke/dash, where the fill is a hex
colour, "none", a Gradient object or a CSS linear-gradient(...) string, so a
gradient card is a rectangular path with a gradient fill; trim +
trim_keyframes draw it on (trim applies before dash, so a dashed line draws on
without the pattern re-flowing), path_keyframes morph it.
groups[] — transform several elements as one (translate/scale/rotate/3D
tilt, a row/column stack layout, and their own animations[]); group
opacity is 0–1 while element opacity is 0–100.
custom_animations — your own keyframed presets, bound with
motion: [{"custom": "<name>"}]; the only way to tilt or 3D-rotate text.
Name them distinctively: none, overlay, difference, any name a catalog
animation already answers to (fade, pop, scale, bounce,
typewriter, …) and duplicates within one registry are all rejected — the
engine would silently run the catalog animation instead of yours. Binding:
motion[].custom resolves only against this request’s registry (an unknown
name is a 422, not a silent drop), takes no reversed: true (author the exit
as its own entry) and no scope: "character" (put group: "glyph" or
"word" on the definition instead).
matte — drive one layer’s coverage from another layer’s render; the
source layer is auto-hidden.
text.counter / text.glow / text.words[] — animated numbers,
em-relative glow, per-word colour/bold/underline runs.
motion[].audio — beat/bass-reactive offsets on video/image/shape and
groups (never text); pair with type: "identity" for a pure generator.
effects[] — a shader chain of 47 named effects, on video, image and
shape alike. intensity is 0–100, but
intensity_keyframes are {time, value} with time in absolute timeline
seconds and value on a 0–1 scale, not 0–100 — a value above 1 is a
422. Two rules: props is whitelisted per shader and only aurora binds
one ({"speed": n}) — any other key on aurora, and any prop at all on
any other shader, is a 422, because the renderer would bind nothing and
silently no-op. And chroma_key takes its parameters through
chroma_settings (key_color, threshold, smoothness, spill_suppress),
never props — it is the one shader that builds its own props block, so a
props you set on it is silently discarded rather than rejected. Leave its
intensity unset for a full key, since an explicit 50 leaves a translucent
green wash.
motion_blur, background_gradient, blend_mode,
backdrop_blur — request- and element-level.
Field shapes live in GET /v1/capabilities → element_schemas (per element
type) and request_schema (composition level — groups, custom animations,
motion blur, transitions); this page does not repeat them.
Named text animations (animation_preset)
A text element can name a renderer catalog text animation directly in
animation_preset — 70 of them, and none of them appear in
GET /v1/capabilities, whose motions[] covers only the 42 motion[]
presets. It is the largest authorable surface on text and there is no other way
to find it. Names are the engine’s camelCase ids, not snake_case
MotionType values.
Entrances — ascent, billboard, block, blur, bounce, boxBounce,
boxHighlight, boxHighlightV2, burst, colourHighlight,
colourHighlightV2, compress, dragonfly, driftIn, dropIn, evaporate,
fade, fall, flipClock, flipboard, floatInBottom, floatInTop,
glitchPop, highlight, impact, impactPop, karaoke, reveal, roll,
rotateFlipClock, rotateZigZag, rubberIn, scale, scaleIn, skid,
slideDown, slideLeft, slideRight, slideUp, stomp, typewriter,
verticalStretch, vogue, wave, wavey, whipUp, zoomIn.
Exits — out-billboard, out-block, out-burst, out-decompress,
out-dragonfly, out-driftOut, out-fade, out-fall, out-flipboard,
out-roll, out-scale, out-sink, out-skid, out-slideDown,
out-slideLeft, out-slideRight, out-slideUp, out-stomp,
out-verticalStretch, out-vogue, out-wavey, out-whipDown, out-zoomOut.
Two further names resolve here without being catalog animations: overlay and
difference, the renderer’s static text blend keys (the same two you can reach
as motion[] presets).
Three rules. A name that is none of those — not a catalog animation, not a
blend key, not a custom_animations.text entry in the same request — is a 422
(the engine would drop it with no warning). A loop-* name is a 422 too: a
loop needs an explicit cycle length this route cannot express, so use motion[]
with the matching loop_* type and duration = one cycle. And
animation_preset cannot be combined with motion[] — that pair is a 422.
Pairing it with word_animation is not rejected, which is worse:
word_animation takes precedence and your preset is silently dropped, so send
exactly one of the three.
Validate before you render
Every composition can be linted for free before you spend a render. POST /v1/preview
with dry_run: true runs the cross-element linter and returns
{ "kind": "validation", "ok": <bool>, "violations": [...] } — no render is dispatched.
Each violation carries a stable code, a human message, a path (e.g.
elements[2].time), and, for layout findings, a bbox and time you can act on
directly. ok is true only when there are no error-severity violations — meaning the
request will submit. Every unsupported-feature rejection the submit path would
raise is folded into this array at error severity too, so the dry run surfaces
the whole 422 class before you spend a render. Repair every error, re-lint, then
render. Common findings:
OUT_OF_FRAME — an element’s box falls partly or fully off-canvas.
ELEMENT_OUT_OF_RANGE — an element starts at/after the output duration and the
renderer drops it.
DUPLICATE_ELEMENT_ID — two elements share an id; transition targets and ops
that address it become ambiguous. Rename one. (error)
DANGLING_TRANSITION — a transition from_id/to_id matches no element. (error)
TRANSITION_SELF — from_id equals to_id. (error)
WORD_OUT_OF_RANGE — a word_animation.words[] timing falls outside the element’s
[time, time + duration] window; word timings are absolute, not element-relative.
ENTRANCE_AFTER_ELEMENT_START — an element’s entrance motion starts after the element
itself, so it sits fully rendered on screen and then plays its entrance. Set
motion[].time (and delay) to the element’s own time.
AUDIO_HIDDEN — visible: false on an audio element removes the track from the
render; it is not a mute.
SHADOW_BELOW_MIN_SIZE — shadow.distance is a 0–100 strength, not pixels, and this
value renders an offset under one pixel.
LOW_CONTRAST and TEXT_OVERLAP abstain rather than guess: the linter stays silent when
the backdrop is a photo, a video, a translucent fill or only a partial cover, and it
compares glyph ink rather than line boxes, so an ordinary title/subtitle stack whose
leading grazes is not reported.
The same linter rides along on POST /v1/projects/{id}/ops, so iterative edits are validated
as you make them.
It also rides along on the paths that spend money — the 202 from POST /v1/renders
and POST /v1/projects/{id}/renders, and the 201 from POST /v1/projects — where it
is advisory: ok and violations[] are attached, and the render is dispatched (or
the project stored) regardless. Treat that as a receipt, not a gate. The gate is the free
dry_run above, which is also the more informative of the two, because it runs the full
translator and so reports the whole 422 class as well. A LINT_UNAVAILABLE warning with
ok: true on a submit means the linter itself failed server-side; it says nothing about
your composition.