Skip to main content

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/capabilitiesmotions[], 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 gradientfill, 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 (4545deg) and pixel stop positions (40px25%).

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/capabilitieselement_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_SELFfrom_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_HIDDENvisible: false on an audio element removes the track from the render; it is not a mute.
  • SHADOW_BELOW_MIN_SIZEshadow.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.