> ## Documentation Index
> Fetch the complete documentation index at: https://docs.framelane.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Author a video

> The renderer semantics an agent must know to author a RenderRequest that renders right the first time — timing, layering, positioning, and the silent defaults that trip agents up.

## 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](/api-reference/render-request) alongside
this. When you are ready to submit and await the artifact, hand off to the
[`render-video`](/agent-skills/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:

```json theme={null}
{
  "width": 1920,
  "height": 1080,
  "duration": 6,
  "background_color": "#0B1B3AFF",
  "elements": [
    {
      "type": "text",
      "id": "headline",
      "text": "Meet Lumen",
      "time": 0,
      "duration": 6,
      "x": "50%",
      "y": "46%",
      "font_size": 120,
      "font_weight": 700,
      "text_color": "#FFFFFF",
      "motion": [{ "type": "fade", "time": 0, "duration": 1 }]
    }
  ]
}
```

### A gradient card

<Warning>
  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.
</Warning>

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

```json theme={null}
{
  "type": "shape",
  "id": "hero_card",
  "time": 0,
  "duration": 6,
  "x": "50%",
  "y": "50%",
  "width": "62%",
  "height": "34%",
  "path": "M0 0 H100 V100 H0 Z",
  "corner_radius": 0.18,
  "fill": {
    "kind": "linear",
    "angle_degrees": 45,
    "stops": [
      { "offset": 0, "color": "#e07a4f" },
      { "offset": 1, "color": "#7c3aed" }
    ]
  },
  "shadow": { "color": "#00000066", "distance": 65, "direction": 90, "blur": 60 }
}
```

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:

```json theme={null}
{
  "type": "shape",
  "id": "hero_card",
  "time": 0,
  "duration": 6,
  "x": "50%",
  "y": "50%",
  "width": "62%",
  "height": "34%",
  "path": "M0 0 H100 V100 H0 Z",
  "corner_radius": 0.18,
  "fill": "linear-gradient(45deg, #e07a4f 0%, #7c3aed 100%)"
}
```

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.
