# VidScript Language Reference (v2)

VidScript is a declarative DSL for composing short-form videos. Write a script, render an MP4.

This is the grammar reference. For task-by-task editing (layering, masks, cuts, speed, sound mixing, grading, delivery), use `editing-actions.md`; for the rules agents most often break, `vidscript-strict.md`.

Contents: Agent quick start · Program structure · Statements (input, output, let, import) · Time blocks · Including TypeScript scenes · Instructions (clips, text, grid areas, video methods, compositing, filters, motion blur, shaders, audio, plugin calls) · Reuse: functions and modules · Animations and effects · Expressions · Template placeholders · CLI commands · Best practices

## Agent quick start (read before composing)

1. **Import every package function** — use `import xai from "@scenerok/xai"`, `import cf from "@scenerok/cloudflare"`, `import eleven from "@elevenlabs/music"`, and `import motion from "@scenerok/basic-animations"` before calling `xai.*`, `cf.*`, `eleven.*`, or `motion.*`.
2. **Validate early** — `scenerok validate file.vid` (errors show as `Line N:C — message`).
3. **Music package syntax** — `import eleven from "@elevenlabs/music"`, then call `eleven.music(...)`, `eleven.generateMusic(...)`, or `eleven.composeMusic(...)`.
4. **Copy working examples** — `vidscript-sample.md`, and the `.vid` files in the `@scenerok/cli` package's `examples/system/` (e.g. `minimal-text-reel.vid`, `ecom-product-launch.vid`, `rokmilk-chocolate-promo.vid`).
5. **Use URL assets selectively** — if the user gives a website or product URL and browser screenshot tools are available, you can capture above-fold screenshots, product images, app screenshots, and logos for VidScript `input` assets. Do not treat screenshots as mandatory or as the only visual source.
6. **Probe asset type and dimensions** — before placing real assets, identify image vs video with `file`; inspect width, height, and duration with `ffprobe`, `sips`, or `identify`; then scale from the measured aspect ratio and set explicit `width`, `height`, `x`, and `y`.
7. **Use still images directly** — `input` supports videos and images. Place still screenshots, logos, and product photos with `video image_input`; the renderer treats images as static clips for the block duration.
8. **Use the full media plugin toolkit** — choose still generation, text-to-video, image-to-video, reference-to-video, video extension, TTS, and music deliberately. Do not default to only `xai.imagine`.
9. **Include audio when useful** — use `xai.tts` / `xai.textToSpeech` for narration and `eleven.music` / `eleven.generateMusic` / `eleven.composeMusic` for music beds.
10. **Generated media has no text** — prompts for AI-generated images/videos must explicitly ask for no text, no words, no letters, no captions, no logos, no watermarks, and no readable UI copy. Use VidScript `text` primitives for all final copy.
11. **Channel-aware `[-]` timing** — audio and visual auto blocks have separate playheads. A music bed or TTS sequence does not delay the next `[-] = video ...` block.
12. **Never guess asset durations** — assign plugin results with `let` and reference the measured duration: `let vo = xai.tts(...)` then `[0 .. vo.duration] = audio vo`. Guessed windows like `[0s .. 3s] = audio xai.tts(...)` clip or overlap when the real audio runs longer.
13. **Never guess text coordinates** — place text with `area:` (`title`, `stage`, `lower-third`, `caption` built in) instead of hand-picked `x`/`y`. See Grid Areas below.
14. **Deep-validate before rendering** — `scenerok validate script.vid --generate-assets --report-json` runs the real compile (billed, assets cached for the render) and returns measured text geometry, collisions, and audio overlaps to fix before rendering.
15. **Strict rules** — see `vidscript-strict.md` for invalid patterns (bare `xai.imagine`, bare `cf.video(...)`, bare `eleven.music(...)` without import, trailing params after direct plugin calls, nested quotes in prompts).

### Common validation failures

| Symptom | Fix |
|---------|-----|
| `Unknown function 'xai.imagine'` | Add `import xai from "@scenerok/xai"` at top |
| `Unknown function 'cf.video'` | Add `import cf from "@scenerok/cloudflare"` at top |
| `Unknown function 'fadeIn'` | Use `import motion from "@scenerok/basic-animations"` and call `motion.fadeIn(...)` |
| `Expected ... but "," found` | Do not put `, volume:` after a direct plugin call. Use `let bed = eleven.music(...)`, then `audio bed, volume: ...` |
| `Unknown function 'eleven.generateMusic'` | Add `import eleven from "@elevenlabs/music"` before calling `eleven.generateMusic(...)` |
| Prompt needs quoted words | Escape quotes inside strings, e.g. `"Package labeled \"AER-01\""` |

## Program Structure

A VidScript program is a sequence of statements separated by newlines.

Strings support common backslash escapes. Use `\n` inside a `text` string for an intentional rendered line break, and use `\"` or `\'` for quote characters inside the matching string delimiter.

```vidscript
[0s .. 3s] = text "RUN LIGHTER\nDAILY", line_height: 1.05
let prompt = "Package labeled \"AER-01\", no text, no watermark"
```

```vidscript
# Single-line comment
/* Multi-line
   comment */
```

## Statements

### Input Declaration

```vidscript
input hero = "https://cdn.example.com/hero.mp4"
input logo = "./assets/logo.png"
```

Supports HTTP(S) URLs, `/uploads/` paths, and local paths for video/image assets.

A numbered image sequence is one input: the pattern names the frames, and `count:` says how many (a local compile can find it by probing). Optional `rate:` (frames per second, default the output fps) and `first:` (number of the first file, default 0):

```vidscript
input frames = "shots/shot.%04d.png", rate: 12, first: 1, count: 24
[0s .. 2s] = frames
```

Place it by name like a video; `Trim`, `Speed`, and `Loop` work on it. `first:` and `count:` on a plain file are errors. `rate:` on a plain video file declares the footage's frame rate for a timeline at another rate (`input clip = "clip.mp4", rate: 23.976` in a 30 fps output; each output frame shows the source frame its time falls in). It is an error on audio or a still, never auto-applied from a probe, and a probed rate more than 0.1% off the declared one warns. On the render workers the pattern must be an `https` URL pattern (or a path on the worker).

For ads based on a supplied URL, browser tools can capture real website visuals and save product images or logos when useful. Use screenshots sparingly: favor above-fold, cropped, or focused captures that remain legible at video size. Avoid full-page or long scrolling screenshots as backgrounds because their text will usually become unreadable.

Website captures are optional grounding assets, not a ban on generated video. A strong ad may combine product/logo/app screenshots with generated lifestyle clips, motion backgrounds, transitions, and VidScript text primitives. Use generative clips when they improve storytelling, show context, or make the ad feel more polished.

After declaring or downloading assets, probe file type and dimensions before placing them:

```bash
file asset.png
ffprobe -v error -select_streams v:0 -show_entries stream=width,height,duration -of csv=p=0 asset.mp4
sips -g pixelWidth -g pixelHeight asset.png
identify asset.webp
```

Use real videos as motion footage and still images as static clips. The `video` primitive accepts both; the renderer detects image vs video:

```vidscript
import motion from "@scenerok/basic-animations"

input screenshot = "assets/screenshots/homepage.png"
[0s .. 4.5s] = video screenshot, width: 1080, height: 1920, x: 0, y: 0, animate: motion.fadeIn(0.35s)
```

For URL-based ads, still assets can also drive generated motion: use `xai.imageToVideo(screenshot, "...")` for one strong source image, or `xai.referenceToVideo([product, logo, person], "...")` to guide a new scene with extracted references.

Use the measured width and height to preserve aspect ratio. For a 1080x1920 vertical ad, a common centered fit is:

```text
scaledWidth = min(assetWidth * scale, 900)
scaledHeight = scaledWidth / (assetWidth / assetHeight)
x = (1080 - scaledWidth) / 2
y = chosen safe-area top offset
```

Do not guess asset size. Tiny logos, wide screenshots, and tall app screenshots need different scale and placement.

### Output Declaration

```vidscript
output to "video.mp4", resolution: "1080x1920", fps: 30
output to "reel.mp4", resolution: "720x1280", format: "mp4", codec: "h264", bitrate: "5M", background: "#0D0D0D"
```

Several `output` statements make several canvases (`compileVidscript` returns one IR each in `irs`; `ir` is the first). Each output is the whole script lowered against its own `resolution`, `fps`, and `background`, so percent positions and `grid` areas resolve per canvas. Every output needs its own path (two with the same path is a compile error). Where it renders: the headless renderer (`renderVidscriptOutputsToMP4`, local scripts) writes one file per output. Where it errors: the render queue (the web app, the CLI, and the API) delivers one artifact per render, so a script with more than one `output` is rejected with `N outputs are rendered … only one output per render is supported by the render queue yet` before anything is billed. Use one `output` for a hosted render.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `resolution` | string | `"1080x1920"` | Width×Height or single number |
| `fps` | number | `30` | Frames per second |
| `format` | string | `"mp4"` | Container format. Every render is MP4 today |
| `codec` | string | `"h264"` | Video codec. Every render is H.264 today |
| `bitrate` | string | `"5M"` | Encoding bitrate |
| `background` | string | `"#000000"` | Background color hex |
| `start_timecode` | string | none | Timecode of the first frame, `"HH:MM:SS:FF"` (non-drop-frame; the frame field must be below `fps`). Written to the mp4's timecode track |
| `burn_in` | string | none | `"timecode"` draws the running timecode (from `start_timecode`, else `00:00:00:00`) onto the picture |
| `allow_offline` | boolean | `false` | `true` renders an input whose file is missing at compile time (media probing on) as a "MEDIA OFFLINE" slate: a flat `#2b2b2b` frame labelled with the input name, over the clip's block or `Trim` length. `Resize`, `x`/`y`, `opacity` and `animate:` still apply; its sound is dropped with a warning; a transition at its seam is an error. Without it a missing local file is a compile error. Media that goes missing only at render time still fails the render |
| `alpha` | boolean | `false` | Needs `format: "mov", codec: "prores"` or `format: "webm", codec: "vp9"`. No render path delivers alpha yet (renders are H.264 mp4), so `alpha: true` is a compile error for now |

### Variable Assignment

```vidscript
let brandColor = "#6366F1"
let fadeTime = 0.5s
let titleSize = 72
let clipName = "hero"
```

Variables can hold strings, numbers, time literals, and object expressions. Variables are evaluated at compile time.

### Package Imports

Only default package imports are supported for callable packages. Call functions through the imported alias.

```vidscript
import xai from "@scenerok/xai"
import cf from "@scenerok/cloudflare"
import eleven from "@elevenlabs/music"
import motion from "@scenerok/basic-animations"
```

Named imports (`import { fn } ...`) and namespace imports (`import * as ...`) are not supported.

## Time Blocks

Time blocks are the core of VidScript. They define when instructions execute on the timeline.

### Dynamic Playhead (recommended)

```vidscript
input hero = "hero.mp4"
[-] = hero                            # auto-append: starts at visual cursor
[- 3s] = text "Title", size: 72       # auto-start, last 3 seconds
[- 2.5s] = filter "vignette", intensity: 0.4
```

VidScript keeps separate auto-playhead cursors for visual and audio content. Visual blocks (`video`, `text`, filters, shaders, visual plugin calls, and bare visual inputs) advance the visual cursor. Audio blocks (`audio`, `xai.tts`, `eleven.music`, and other audio plugin calls) advance the audio cursor. `[- duration]` advances the relevant cursor by the explicit duration.

This means a long audio bed or voiceover sequence does not push a following auto video block to the end of the audio:

```vidscript
import xai from "@scenerok/xai"
import eleven from "@elevenlabs/music"

input product = "https://cdn.example.com/product.png"
let bed = eleven.music("Warm music bed", duration: 15, instrumental: true)
[0s .. 15s] = audio bed, volume: 0.35
[-] = video xai.imageToVideo(product, "Slow premium camera move", aspect_ratio: "9:16", duration: 6)
```

The generated video above starts at the current visual cursor, usually `0s`, even though the audio cursor already reaches `15s`. Mixed blocks containing both audio and visual instructions advance both cursors. The `prev` keyword resolves against the cursor for the block being compiled; for mixed timing it uses the current combined timeline position.

### Explicit Range

```vidscript
input hero = "hero.mp4"
[0s .. 5s] = hero                      # absolute range
[0.5s .. 3s] = text "Hello"            # explicit times
[prev + 1s .. prev + 4s] = filter "vignette"  # expression-based
```

Use `..` (not `-`) as the range separator to avoid ambiguity with subtraction.

### Time Formats

```text
5s           # 5 seconds
300ms        # 300 milliseconds
90 frame     # 90 frames at the output fps (default 30): 3 seconds
0:30         # 30 seconds
1:30:00      # 1 hour 30 minutes
```

### The `prev` Keyword

`prev` refers to the current channel-aware playhead position for the block being compiled. In a visual block it follows the visual cursor; in an audio block it follows the audio cursor.

```vidscript
[- 2s] = text "Hello"                              # plays from cursor to cursor+2s
[prev + 1s .. prev + 3s] = text "World"            # 1s after "Hello" ends, for 2s
```

### Symbolic Durations (`asset.duration`)

Generated assets (TTS, music, video) have durations you cannot know while writing the script. Assign the plugin result with `let` and reference its **measured** duration in any time expression — the compiler probes the generated file, so the value is real, not an estimate:

```vidscript
import xai from "@scenerok/xai"

let vo1 = xai.tts("Welcome to the show", voice: "eve")
let vo2 = xai.tts("Let's dive in", voice: "eve")

[0 .. vo1.duration] = audio vo1
[vo1.duration + 0.3 .. vo1.duration + 0.3 + vo2.duration] = audio vo2
[0 .. vo1.duration] = text "Welcome", area: "title"     # visual spans the narration exactly
```

- Bare numbers are seconds; `2s` / `300ms` literals and `+ - * /` arithmetic work anywhere a time appears.
- Exported timelines expose `.duration` too: `use scene1 at 0s .. 0s with {}` then `[scene1.duration .. scene1.duration + 4] = ...`. A `use` also advances the playhead past its content, so a following `[-]` block never overlaps it.
- This replaces guessing: `[0s .. 5s] = audio xai.tts(...)` clips or overlaps when the real audio is longer. For simple back-to-back narration, consecutive `[-] = audio ...` blocks also never overlap.

## Include TypeScript / JavaScript scenes

Import a `@scenerok/sdk` scene (`.ts` / `.js`) into VidScript with the same default-import form as plugins, then `use` it like a timeline:

```vidscript fragment
import intro from "./intro.ts"
import bump from "./bump.js"

use intro at 0s .. 0s with {}
use bump at intro.duration .. intro.duration with {}
[intro.duration + bump.duration .. intro.duration + bump.duration + 2s] = text "Thanks", area: "caption"

output to "reel.mp4", resolution: "1080x1920"
```

Rules:
- The SDK file must export `default function main()` (or `module.exports = main` / `exports.main`) that returns `t.toIR()` or an `IRTimeline`.
- `with { ... }` params are not supported on SDK scenes (no timeline parameters).
- Relative paths only (`./` / `../`). The CLI uploads companion scene files automatically when you `scenerok validate` / `scenerok render` a `.vid`.
- Prefer this when a complex loop/branch scene should sit inside a mostly-VidScript reel. For a fully TypeScript project, author the entry as `.ts` instead.

## Instructions (inside time blocks)

Each line places one thing. Several lines can share a window:

```vidscript
input hero = "hero.mp4"
[0s .. 5s] = hero.Trim(0s, 7.5s).Speed(1.5)
[0s .. 5s] = text "Hello", area: "title"
```

### Clip Reference

```vidscript
input hero = "hero.mp4"
[-] = hero              # just play the input clip
```

### Text Overlay

```vidscript
[- 3s] = text "Hello World", font: "Bebas Neue", x: "50%", y: "45%", align: center, color: "#FFFFFF", size: 72, stroke: "#000000", stroke_width: 3, letter_spacing: 4, shadow_color: "#FF0055", shadow_blur: 8
```

| Param | Type | Description |
|-------|------|-------------|
| `style` | string | `title` (64px), `subtitle` (54px), `caption` (36px), or `default` (48px) |
| `area` | string | Named grid region (preferred over `x`/`y`). Built-in: `title`, `stage`, `lower-third`, `caption`. Centers text in the cell and caps `max_width`/`max_height` to it. See Grid Areas. |
| `position` | string | `center`, `top`, `bottom`, `top-left`, `top-right`, `bottom-left`, `bottom-right` |
| `x` | number \| string | Horizontal position. Number = px from left. `"50%"` = percent of width. Overrides `position` and `area`. |
| `y` | number \| string | Vertical position. Number = px from top. `"50%"` = percent of height. Overrides `position` and `area`. |
| `max_width` | number \| string | Wrap/shrink boundary, px or `"90%"` |
| `max_height` | number \| string | Vertical fit boundary, px or `"20%"` |
| `fit` | string | `wrap-shrink` (default), `wrap`, `shrink`, `none` |
| `overflow` | string | `contain` (default, clamps to safe area), `crop`, `allow` |
| `color` | string | Hex color |
| `size` | number \| string | Font size in output canvas pixels. `size: 72` is shorthand for `size: "72px"`. |
| `font` | string | Font family. System fonts or Google Fonts: `"Inter"`, `"Bebas Neue"`, `"Space Grotesk"`, `"Outfit"`, etc. |
| `align` | string | `left`, `center`, `right`. Default: `center` |
| `line_height` | number | Line height multiplier for multi-line text. Default: 1.2 |
| `letter_spacing` | number | Extra letter spacing in pixels. Default: 0 |
| `rotation` | number | Rotation in degrees. Positive turns text clockwise (video turns the other way). Default: 0 |
| `opacity` | number | Opacity 0–1. Default: 1 |
| `stroke` | string | Outline color |
| `stroke_width` | number | Outline thickness |
| `shadow_color` | string | Drop shadow color |
| `shadow_blur` | number | Shadow blur radius |
| `shadow_offset_x` | number | Shadow horizontal offset |
| `shadow_offset_y` | number | Shadow vertical offset |
| `anchor` | string | Point the text rotates and positions around, e.g. `"top-left"` |
| `stagger` | string | `"word"` or `"char"`: reveal the text piece by piece over its `animate:` |

Params are optional and comma-separated. Params can be on the same line as the content.

### Grid Areas

Place text in named regions instead of guessing coordinates. Without any declaration, a built-in grid provides `title`, `stage`, `lower-third`, and `caption`:

```vidscript
[0 .. 3] = text "BIG SALE", area: "title"
[0 .. 3] = text "50% off this week", area: "lower-third"
```

Declare a custom grid with CSS Grid semantics — `rows`/`cols` are track lists (`%` and `fr` units only), `areas` is grid-template-areas (one string per row, `.` for empty cells, repeated names span):

```vidscript
grid rows: "20% 1fr 30%", cols: "1fr 1fr", areas: ["title title", "left right", "caption caption"]
[0 .. 3] = text "Left column", area: "left"
```

The area centers the text in its cell and sets `max_width`/`max_height` to the cell size, so long copy wraps and shrinks to fit the region. Explicit `x`/`y`/`max_width`/`max_height` override the area. Everything resolves to percentages, so grids work at any output resolution.

**TypeScript SDK equivalent** (`sdk-guide.md`):

```ts
t.grid({
  rows: '20% 1fr 30%',
  cols: '1fr 1fr',
  areas: ['title title', 'left right', 'caption caption'],
})
t[0][s(3)] = text('Left column', { area: 'left' })
```

### Video Methods

Methods are operations on values. Capitalised names are methods; lowercase names are
properties (`hero.duration`). A method returns **a new value** — chain them, or bind
the result with `let`:

```vidscript
input beach = "beach.mp4"
let hero = beach.Trim(2s, 8s).Speed(1.5)
[0s .. 4s] = hero, opacity: 0.9
[4s .. 8s] = beach.Trim(0s, 2s).Loop(2).Resize(720, 1280)
[8s .. 10s] = beach.Opacity(0.5).Filter("sepia", intensity: 0.8)
[10s .. 12s] = beach.Crop(0, 0, 640, 360)
let framed = beach.Mask(solid("#fff", radius: 120, width: 800, height: 500), mode: "luma")
[12s .. 14s] = framed
```

| Method | Arguments | Result |
|--------|-----------|--------|
| `Trim(start, end)` | source times | Plays that source range |
| `Speed(factor)` | number | Playback rate; negative reverses, `0` holds a frame |
| `Loop(count)` | number | Repeats the range `count` times |
| `Resize(width, height)` | pixels | Display size |
| `Opacity(value)` | 0–1 | Opacity |
| `Filter(name, …params)` | filter name | A grade on this value only |
| `Crop(x, y, width, height)` | source pixels from the top-left | The rectangle, filling the display size |
| `Mask(matte, mode?)` | any picture; `"alpha"`, `"luma"`, `"inverted"` | Drawn through the matte |
| `Overlay(other, x?, y?, opacity?)` | a picture value | `other` on top at a position |
| `Composite(other, mode?, x?, y?, opacity?)` | a picture value, a blend mode | `other` blended on top |

Arguments can be positional or named (`hero.Trim(start: 0s, end: 5s)`).

A second `Trim` is relative to the first: `a.Trim(10s, 20s).Trim(2s, 4s)` plays 12–14 s. A second `Crop` is relative to the first the same way.
`Crop` keeps the display size, so the rectangle is scaled to fill it; `Resize` to the rectangle's aspect avoids stretching.
The `Mask` matte is any picture value — a `solid` with `radius:` or `corners:` for a shape, `text(…)`, a function, or another clip (a track matte) — drawn in the same frame at the same local time.
Place a masked value (`[0s .. 4s] = a.Mask(…)`); a `Mask` line after the clip is placed, an unknown mode, a masked clip as a
`Composite` side, and a cropped clip inside a transition are compile errors.
`audio x` places a sound — an audio file, or a video's own sound (`audio a.Trim(1s, 3s)`);
`video` on an audio file is an error.

A placed value lasts its own length (range ÷ |speed| × loops), so make its window that long.
Each placement is its own value: `[0s .. 4s] = a.Trim(0s, 4s)` and `[4s .. 6s] = a.Trim(10s, 12s)` are two independent cuts.

A method on its own line right after `[…] = hero` (the legacy spelling) still edits the clip that time block placed. Outside a time block it rebinds the name:
`bg.Loop(count: 3)` makes `bg` the looped clip for later placements.

### Compositing

```vidscript
input hero = "hero.mp4"
input logo = "logo.png"
input texture = "light-leak.mp4"
[0s .. 4s] = hero.Overlay(logo, x: 50, y: 100, opacity: 0.8)
[4s .. 8s] = hero.Composite(texture, x: 0, y: 0, opacity: 0.5, mode: "screen")
```

| Param | Type | Description |
|-------|------|-------------|
| `x` | number | Horizontal offset |
| `y` | number | Vertical offset |
| `opacity` | number | 0–1 transparency |
| `mode` | string | Blend mode for Composite: `normal`, `multiply`, `screen`, `overlay`, `difference`, `add`, `subtract`, `darken`, `lighten` |

The other side can be a clip name, or a value: a function's picture, a `solid(…)`, a
`text(…)`, or a trimmed clip. A clip joins as a composite layer; anything else draws as a
group blended with `mode` (`x`, `y`, and `opacity` place that group). A composite is itself
a target, so layers stack: `let ab = a.Composite(b, mode: "screen")` then
`[0s .. 4s] = ab.Composite(card("x"), mode: "multiply")`. Both sides must overlap in time.

### Filters

```vidscript
input hero = "hero.mp4"
[0s .. 5s] = hero
[0s .. 2s] = filter "chromatic", offset: 0.01
[0s .. 5s] = filter "vignette", intensity: 0.3
[0s .. 5s] = filter "contrast", amount: 1.2, target: hero
```

Built-in filters: `monochrome`, `sepia`, `blur`, `chromatic`, `glitch`, `vignette`, `contrast`, `saturation`, `brightness`, plus `lut` and `cdl` (below and in `editing-actions.md`) and the VFX in `shaders-filters.md`. A `filter` line grades everything in its window; `target: clip` grades one clip.

**LUT.** `filter "lut", file: "look.cube"` applies a 3D `.cube` look to the frame, to one clip with `target:`, or to a function body's picture. The compiler reads the file (relative to the `.vid`, 512 KB at most, up to 33 points per axis) and embeds the table, so render workers need nothing else. The lookup runs on sRGB-encoded values with 8-bit trilinear interpolation. `Filter("lut")` as a method is an error; use the statement.

### Motion blur

`motion_blur: true` or `motion_blur: { shutter: 180, samples: 8 }` on a video/image placement or a placed function averages `samples` sub-frames across the shutter angle (above 0, up to 360; samples 2 to 64) of that item's own `animate:` motion. Each sample is another render of the frame, so a frame with a blurred item costs `samples` times as much. It does not re-sample the video's own frames, does not blur a group's moving children (set it on them), and is an error on text, solids, captions, audio, and a clip at a transition.

```vidscript
input hero = "hero.mp4"
[0s .. 2s] = hero, animate: { type: "slideX", from: { x: 0 }, to: { x: 900 } }, motion_blur: { shutter: 360, samples: 16 }
```

### Shaders

Author fragment GLSL in a separate file, import it, then apply with `shader` (not `filter`):

```vidscript
import shader glow from "./shaders/glow.glsl"
# or: import shader glow from "shader-pack/shaders/glow.glsl"

[0s .. 5s] = shader glow, intensity: 0.8
```

Fragment must sample `tDiffuse`, write `gl_FragColor`, and declare uniforms matching param names. See `shaders-filters.md` → Custom shaders.
### Audio

```vidscript
input music = "music.mp3"
[0s .. 15s] = audio music, volume: 0.5, fade_in: 1s, fade_out: 2s
```

Audio sources can be MP3 inputs, audio plugin results, or the embedded audio stream from an input video. Video inputs are visually silent unless you explicitly add `audio clip`. Plugin-generated audio (TTS, music, and probed native audio on generated video clips) is EBU R128 loudness-normalized during render to about -16 LUFS; `volume` is a relative gain applied after that. Ordinary imported audio/video is not auto-normalized.

| Param | Type | Description |
|-------|------|-------------|
| `volume` | number | Relative gain (0–1+). For generated audio, applied after loudness normalization. |
| `fade_in` | number | Fade-in duration (seconds) |
| `fade_out` | number | Fade-out duration (seconds) |
| `trim_start` | number | Start this many seconds into the file |
| `pan` | number | -1 (left) to 1 (right) |
| `duck_under` | name | Lower this track while the named track plays |
| `loudness` | string | `"ebu-r128"` normalizes an imported file |
| `animate` | object | `{ type: "volume", from: { volume: 0 }, to: { volume: 1 } }` automates the level |

Mixing recipes (J- and L-cuts, ducking, automation): `editing-actions.md` → Sound.

### Plugin Calls

```vidscript
import xai from "@scenerok/xai"
import cf from "@scenerok/cloudflare"
import eleven from "@elevenlabs/music"
import motion from "@scenerok/basic-animations"

[- 4s] = video xai.image("Premium product still on seamless studio background, no text, no words, no letters, no captions, no logos, no watermark, no readable UI copy", aspect_ratio: "9:16", resolution: "2k")
[-] = video xai.imagine("Cinematic product video, premium lighting, no text, no words, no letters, no captions, no logos, no watermark, no readable UI copy", aspect_ratio: "9:16", resolution: "720p", duration: 5)
[-] = video xai.imageToVideo("https://cdn.example.com/product.png", "Slow premium camera move around the product, no text, no words, no letters, no captions, no logos, no watermark, no readable UI copy", aspect_ratio: "9:16", duration: 6)
[-] = video xai.referenceToVideo(["https://cdn.example.com/product.png", "https://cdn.example.com/person.png"], "Lifestyle ad shot featuring the referenced product and person, no text, no words, no letters, no captions, no logos, no watermark, no readable UI copy", aspect_ratio: "9:16", duration: 8)
[-] = video xai.extendVideo("https://cdn.example.com/clip.mp4", "Camera pulls back to reveal the full premium setup, no text, no words, no letters, no captions, no logos, no watermark, no readable UI copy", duration: 5)
[-] = audio xai.tts("Welcome to SceneRok", voice: "eve")

# Cloudflare AI Gateway package.
[-] = video cf.video("High-energy fashion product shot, no text, no words, no letters, no captions, no logos, no watermark, no readable UI copy", model: "pixverse/v6", aspect_ratio: "9:16", duration: 5, generate_audio: false)
[-] = video cf.imageToVideo("https://cdn.example.com/product.png", "Animate the product with realistic parallax, no text, no words, no letters, no captions, no logos, no watermark, no readable UI copy", model: "runwayml/gen-4.5", aspect_ratio: "9:16", duration: 5)
[- 4s] = video cf.image("Clean editorial product still, no text, no words, no letters, no captions, no logos, no watermark, no readable UI copy", model: "black-forest-labs/flux-2-pro-preview", aspect_ratio: "9:16")

# ElevenLabs music package functions require an import alias.
[0s .. 15s] = audio eleven.music("Warm premium launch bed", duration: 15, instrumental: true)

# If you need volume or fades, store the generated audio first.
let bed = eleven.music("Warm premium launch bed", duration: 15, instrumental: true)
[0s .. 15s] = audio bed, volume: 0.35, fade_out: 2s
```

`@scenerok/xai`, `@scenerok/cloudflare`, and `@elevenlabs/music` require explicit default imports. ElevenLabs music validates as `eleven.music(...)`, `eleven.generateMusic(...)`, or `eleven.composeMusic(...)` after `import eleven from "@elevenlabs/music"`. Plugin calls run at compile time; direct media plugin instructions cannot have trailing audio params, so use a `let` binding when you need `volume`, `fade_in`, or `fade_out`.

### Media Plugin API Reference

| Package | Function | Use when |
|---------|----------|----------|
| `@scenerok/xai` | `xai.image(prompt, ...)`, `xai.generateImage(prompt, ...)` | Generate a still image clip. Use `resolution: "1k"` or `"2k"` and `duration` for how long the still stays on the timeline. |
| `@scenerok/xai` | `xai.imagine(prompt, ...)`, `xai.genVideo(prompt, ...)` | Generate video from text. Use video resolutions such as `"480p"` or `"720p"`. |
| `@scenerok/xai` | `xai.imageToVideo(image, prompt, ...)` | Animate one source image, screenshot, logo, product photo, data URI, file id, or `{ url }` object. |
| `@scenerok/xai` | `xai.referenceToVideo([images...], prompt, ...)` | Guide a generated scene with 1-7 reference images. Requires a prompt; keep duration <= 10s. |
| `@scenerok/xai` | `xai.extendVideo(video, prompt, ...)` | Continue an existing video clip by up to 10s. |
| `@scenerok/xai` | `xai.tts(text, ...)`, `xai.textToSpeech(text, ...)` | Generate spoken narration. Voices: `eve`, `ara`, `rex`, `sal`, `leo`. |
| `@scenerok/cloudflare` | `cf.image(prompt, ...)`, `cf.generateImage(prompt, ...)` | Generate stills through Cloudflare AI Gateway image models such as `black-forest-labs/flux-2-pro-preview`, `openai/gpt-image-2`, `xai/grok-imagine-image-quality`, or `recraft/recraftv4-pro`. |
| `@scenerok/cloudflare` | `cf.video(prompt, ...)`, `cf.imagine(prompt, ...)`, `cf.genVideo(prompt, ...)`, `cf.generateVideo(prompt, ...)` | Generate text-to-video through models such as `pixverse/v6`, `vidu/q3-turbo`, `runwayml/gen-4.5`, `minimax/hailuo-2.3`, `bytedance/seedance-2.0`, `google/veo-3.1`, `google/veo-3.1-fast`, or `google/veo-3-fast`. |
| `@scenerok/cloudflare` | `cf.imageToVideo(image, prompt, ...)`, `cf.referenceToVideo([images...], prompt, ...)`, `cf.extendVideo(video, prompt, ...)` | Use Cloudflare-backed image-guided, reference-guided, or extension modes when the selected model supports them. |
| `@scenerok/cloudflare` | `cf.listModels()`, `cf.models()` | Return model metadata as data; useful for exploration, not for timeline media. |
| `@elevenlabs/music` | `eleven.music(prompt, ...)`, `eleven.generateMusic(prompt, ...)`, `eleven.composeMusic(prompt, ...)` | Generate background music. Use a `let` binding if you need `volume`, `fade_in`, or `fade_out`. |

xAI TTS voices: `eve` for upbeat demos/announcements, `ara` for warm conversational narration, `rex` for business/tutorial content, `sal` for balanced general delivery, and `leo` for authoritative instruction.

Do not call `eleven.tts(...)`; the registered ElevenLabs package is `@elevenlabs/music` for music beds only. Use `xai.tts(...)` or `xai.textToSpeech(...)` for voiceover.

Cloudflare video calls accept these common signatures:

| Function | Signature |
|----------|-----------|
| `cf.video` / `cf.generateVideo` / `cf.imagine` / `cf.genVideo` | `(prompt, model?, aspect_ratio?, duration?, resolution?/quality?, generate_audio?, negative_prompt?, seed?, input?)` |
| `cf.imageToVideo` | `(image, prompt, model?, aspect_ratio?, duration?, resolution?/quality?, generate_audio?, negative_prompt?, seed?, input?)` |
| `cf.referenceToVideo` | `([images...], prompt, model?, aspect_ratio?, duration?, resolution?/quality?, generate_audio?, input?)` |
| `cf.extendVideo` | `(video, prompt, model?, duration?, aspect_ratio?, resolution?/quality?, input?)` |

Use `aspect_ratio: "9:16"` for vertical output, `"16:9"` for wide, `"1:1"` for square, or model-supported values such as `"4:3"`, `"3:4"`, and `"21:9"`. For Runway models, keep writing friendly `aspect_ratio` values; SceneRok translates them to Cloudflare's required `ratio` values such as `"720:1280"` for vertical Gen-4.5. Prefer explicit `model:` when choosing Cloudflare so the intended provider is clear.

**Invalid (will not validate):**

```vidscript invalid
[-] = video xai.imagine("...")                    # missing xAI import
[0s .. 5s] = video cf.video("...")                # missing Cloudflare import
[0s .. 15s] = audio eleven.generateMusic("...")   # missing `import eleven from "@elevenlabs/music"`
[0s .. 5s] = audio eleven.tts("...")              # ElevenLabs TTS is not registered; use xai.tts
[0s .. 15s] = audio eleven.music("..."), volume: 0.5      # trailing params after direct plugin call
```

## Reuse: values, functions, and modules

Anything that can be placed is a value: an input, a plugin result, `text(…)` / `audio(…)` /
`solid(…)`, a method chain, or a function call. A time block places one.

### Functions

```vidscript
export fn lowerThird(title: string, color: string = "#ff5500") -> video {
  [- 2s] = text title, color: color, size: 48
}

[0s .. 2s] = lowerThird("Konkan Day 1")
[2s .. 4s] = lowerThird(title: "Day 2", color: "#00ff00")

# one-liners: fn … = expression
fn label(s: string) -> text = text(s, size: 24)
```

| Form | Meaning |
|------|---------|
| `fn name(params) -> kind { … }` | Block body: places items when called and placed |
| `fn name(params) -> kind = expr` | Expression body: returns that value |
| `export fn …` | Exported (callable through an imported namespace) |
| `export timeline name(…) { … }` | Same as `export fn name(…) -> timeline { … }` |

Return kinds: `video`, `image`, `text`, `audio`, `timeline`. `-> video` is a closed unit
(place it with properties — `[0s .. 4s] = card("A"), x: 40, opacity: 0.5, blend: "screen"` — or
chain `.Opacity()`, `.Resize()`, `.Filter()`, `.Crop()`, `.Mask()` and it draws as one item);
`-> timeline` is open, its items join the caller. A `filter` or `shader` without `target:`
inside a function or timeline body grades that unit's picture, not the whole frame.

Arguments are checked at the call: a wrong kind, an unknown or missing parameter, too
many arguments, or a value of the wrong kind is a compile error naming the parameter.
Optional parameters take `name: kind = default`.

`use card at 0s .. 2s with { title: "A" }` is exactly `[0s .. 2s] = card(title: "A")` —
the same IR.

### Modules

```vidscript
import brand from "./brand.vid"     # another .vid file's exports

[0s .. 2s] = brand.lowerThird("Hello")
[0s .. 2s] = text "x", color: brand.accent
use brand.card at 2s .. 4s with { title: "B" }
```

`brand.vid` exports with `export fn lowerThird(title: string) -> video { … }`,
`export timeline card(title: string) { … }`, and `export const accent = "#ff5500"`.

### Builtin values

```vidscript
[0s .. 2s] = solid("#1a2b3c", opacity: 0.8)   # flat-colour rectangle
[2s .. 3s] = gap()                            # transparent gap (advances the cursor)
[4s .. 4s] = marker("act 2")                  # named instant (timeline data, not rendered)
[0s .. 8s] = captions("talk.srt", size: 32)   # one text layer per SRT cue
```

Any visual value takes the same placement properties: `x`, `y`, `width`, `height`,
`opacity`, `rotation`, `radius`, `corners`, `area`, `blend`, `animate`, `enabled`.
`blend: "screen"` on a clip lowers to a composite layer over whatever is below the placement;
on text, a solid, or a function it draws as a group with that blend. A blend needs something
below it in the same window, or it is a compile error. More than 4 nested groups, or more
than 16 alive at once, is a compile warning (each group renders into its own GPU target).
`volume:` on a generated clip with its own sound sets that sound's level.

### Names must resolve

An unquoted word is only allowed where a value is an enumeration (`align: center`,
`position: top`, `style: title`, `mode: screen`). Everywhere else an unresolved name is
`Unknown variable 'titel'`. Quote strings; pass real variables.

## Animations and effects

`animate:` moves any text or picture over time, with `motion.*` calls or object descriptors. Filters are separate `filter` lines (above), and can be animated too.

### animate: parameter

```vidscript
import motion from "@scenerok/basic-animations"

input hero = "hero.mp4"
input app_screen = "app-screen.png"
[0s .. 3s] = text "Hello", x: "50%", y: "50%", animate: motion.fadeIn(0.8s)

[-] = video hero, animate: [motion.fadeIn(0.5s), motion.slideY(-40, 0, 1.2s)]

# Static asset with measured 4:3-ish aspect ratio, centered in a vertical 1080x1920 frame.
[0s .. 3.4s] = video app_screen, width: 760, height: 615, x: 160, y: 300, animate: [motion.riseIn(0.6s, 70), motion.float(2.4s, 8)]
[3.4s .. 4s] = video app_screen, width: 760, height: 615, x: 160, y: 300, animate: motion.fadeOut(0.6s)

[0s .. 2s] = text "Title", animate: {
  type: "fade",
  from: { opacity: 0 },
  to: { opacity: 1 },
  start: 0,
  end: 1.5,
  easing: "easeOutQuad"
}
```

Easings: `linear`, `easeInQuad`, `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeOutBack`, `spring`. A list of objects with absolute `start` / `end` chains segments on one property (a path with more than two points); `keyframes:` is not supported and is silently dropped.

### Animated filters

```vidscript
import motion from "@scenerok/basic-animations"

[0s .. 5s] = filter "vignette", intensity: 0.4

[0s .. 5s] = filter "glitch", intensity: 0.5, animate: motion.fadeIn(1s)
```

Filters are optional finishing tools; use them only when they improve the composition. Parameter names per filter: `editing-actions.md` → Color and finishing.

### Animation functions (`@scenerok/basic-animations`)

| Function | Description |
|----------|-------------|
| `motion.fadeIn(duration?)` | Opacity 0 → 1 |
| `motion.fadeOut(duration?)` | Opacity 1 → 0 |
| `motion.slideX(from, to, dur?)` | Horizontal slide |
| `motion.slideY(from, to, dur?)` | Vertical slide |
| `motion.popIn(duration?)` | Scale + fade entrance |
| `motion.riseIn(duration?, dist?)` | Upward fade entrance |
| `motion.swingIn(duration?)` | Rotating slide/fade |
| `motion.glitchIn(duration?)` | Jitter + flash entrance |
| `motion.float(duration?, amp?)` | Gentle vertical bob |
| `motion.typewriter(duration?)` | Character reveal |

## Expressions

Used in time specs, `let` values, and function arguments:

```vidscript
let title_text = "Launch"
let x = 5s                    # Time literals
let y = x * 2                 # Arithmetic
let prompt = "Scene: " + title_text  # String concatenation
let z = prev + 0.5s           # Playhead reference
let a = { primary: "#FF5733" } # Objects
```

Operators: `+`, `-`, `*`, `/` with standard precedence.

## Template Placeholders

```vidscript
[- 3s] = text "{{headline | Default Text}}", color: "{{color | #FFFFFF}}"
```

Templates fill each `{{name | default}}` before the script compiles; outside a template the default is used.

## CLI Commands

```bash
# Authenticate
scenerok auth login

# Validate a script
scenerok validate my-video.vid

# Render a video
scenerok project upload my-video.vid --assets ./assets --render --watch --download ./renders
scenerok project render <project-id> --watch --download ./renders
scenerok render my-video.vid --project-id <project-id> --watch
scenerok cache pull

# Check render status  
scenerok status <render-id>

# Install agent skills
scenerok skills install <platform>

```

## Best Practices

1. **Use dynamic timeblocks** — `[-]` auto-advances the relevant audio or visual cursor, reducing calculation errors
2. **Use `prev` for offsets** — `[prev + 0.5s .. prev + 2s]` for gaps after previous content in the same channel
3. **1080×1920 for vertical** (TikTok, Reels, Shorts), **1920×1080 for horizontal** (YouTube)
4. **Hook viewers in the first 3 seconds** — place the most compelling content early
5. **High-contrast text** — use `stroke` and `stroke_width` on text overlays over video
6. **Test with `scenerok validate` first** — catch syntax errors before spending render credits
7. **Keep text content short** — text disappears when the time block ends
8. **One instruction per line** — each instruction inside a timeblock goes on its own line
9. **Text-only auto blocks** — `[-] = text ...` lasts 2 seconds by default; use `[- 3s]` for custom timing
10. **Named arguments are clearer** — `hero.Trim(start: 0s, end: 5s)` over `hero.Trim(0s, 5s)`
