Metadata-Version: 2.5
Name: scenescript
Version: 0.5.0
Summary: Render complete videos from declarative JSON files
Project-URL: Homepage, https://github.com/CtrlAltDeplete/scenescript
Author-email: CtrlAltDeplete <gavyn@ctrlaltdeplete.com>
License: MIT
License-File: LICENSE
Keywords: declarative,mcp,rendering,video
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Requires-Dist: numpy>=1.26
Requires-Dist: pillow>=10.1
Provides-Extra: code
Requires-Dist: pygments>=2.15; extra == 'code'
Provides-Extra: mcp
Requires-Dist: mcp>=2; extra == 'mcp'
Description-Content-Type: text/markdown

# SceneScript

SceneScript is a Python command-line tool that renders complete videos from declarative JSON files. One JSON document describes the whole video: text overlays, backgrounds, inserted video clips, images, shapes, scene transitions, and audio tracks.

The same JSON always produces the same video. There are no LLMs in the pipeline, and no video-editing frameworks (no MoviePy, no editly, no Remotion). Frames are composed directly with Pillow and numpy; ffmpeg is used only as the encoder/muxer at the edges of the pipeline.

## How It Works

```
video.json ──► parse & validate ──► compose frames ──► encode ──► mix audio ──► output.mp4
                (schema check,       (Pillow/numpy,     (frames     (tracks,
                 asset check)         scene by scene)    piped to    fades,
                                                         ffmpeg)     volume)
```

1. **Parse & validate.** The JSON is checked against the schema, and every referenced asset (clips, images, fonts, audio) must exist before any rendering starts.
2. **Compose frames.** Each scene is rendered frame-by-frame: background first, then elements in document order, with per-frame animation values applied. Transitions blend the boundary frames of adjacent scenes.
3. **Encode.** Raw frames are piped straight into ffmpeg. No intermediate image files are written.
4. **Mix audio.** Audio tracks, plus audio from inserted clips, are trimmed, faded, volume-adjusted, and mixed into the final container.

## Requirements

- Python 3.11+
- [ffmpeg](https://ffmpeg.org/) available on your `PATH`

## Installation

```bash
pip install scenescript
```

For the [MCP server](#mcp-server), include the extra:

```bash
pip install "scenescript[mcp]"
```

For syntax-highlighted [`code` elements](#code), include the `code` extra
(it pulls in [pygments](https://pygments.org)):

```bash
pip install "scenescript[code]"
```

### Upgrading

```bash
pip install --upgrade scenescript
```

If you use extras (the MCP server, `code` highlighting), include them so
their dependencies are upgraded too:

```bash
pip install --upgrade "scenescript[mcp,code]"
```

### Installing from source

To work on SceneScript itself, install editable from a clone — the executables then run straight from the source tree, so code changes (and `git pull`) take effect without reinstalling. Re-run the install when `pyproject.toml` changes (new dependencies or entry points):

```bash
git clone https://github.com/CtrlAltDeplete/scenescript
cd scenescript
pip install -e ".[mcp,code]"
```

## Quick Start

Create `video.json`:

```json
{
  "output": { "width": 1920, "height": 1080, "fps": 30 },
  "scenes": [
    {
      "duration": 4,
      "background": { "type": "color", "color": "#1a1a2e" },
      "elements": [
        {
          "type": "text",
          "content": "Hello, world",
          "start": 0.5,
          "end": 3.5,
          "position": "center",
          "size": 96,
          "color": "#ffffff"
        }
      ],
      "transition": { "type": "fade", "duration": 1.0 }
    },
    {
      "duration": 8,
      "elements": [
        { "type": "video", "src": "assets/clip.mp4", "fit": "cover" }
      ]
    }
  ],
  "audio": [
    { "src": "assets/music.mp3", "start": 0, "volume": 0.6, "fade_out": 2.0 }
  ]
}
```

Render it:

```bash
scenescript render video.json -o output.mp4
```

## JSON Reference

### Top Level

| Key       | Type   | Required | Description |
|-----------|--------|----------|-------------|
| `output`  | object | no       | Output settings. Defaults: `1920×1080`, `30` fps, `mp4`. |
| `scenes`  | array  | yes      | Ordered list of scenes, played back to back. |
| `defs`    | object | no       | Named, reusable element groups (see [Reusable Elements](#reusable-elements)). |
| `include` | array  | no       | Paths of JSON files whose `defs` are merged in (see [Shared def libraries](#shared-def-libraries)). |
| `audio`   | array  | no       | Global audio tracks spanning the whole video. |
| `tempo`   | object | no       | A beat grid for time values (see [`tempo`](#tempo)). |

#### `output`

```json
{ "width": 1920, "height": 1080, "fps": 30, "format": "mp4" }
```

| Key      | Type   | Default  | Description |
|----------|--------|----------|-------------|
| `width`  | int    | `1920`   | Frame width in pixels. |
| `height` | int    | `1080`   | Frame height in pixels. |
| `fps`    | int    | `30`     | Frames per second. |
| `format` | string | `"mp4"`  | Container format: `mp4`, `mkv`, or `webm`. |

#### `tempo`

Declaring a tempo lets any time-valued field take a beat value instead of
seconds — a string of the form `"<number>b"` (beats) or `"<number>bar"`
(bars). This is how music-locked videos (montages, lyric videos, promos cut
to a track) keep every cut on the grid: change the `bpm` once and every beat
value follows.

```json
{
  "tempo": { "bpm": 152, "beats_per_bar": 4 },
  "scenes": [
    { "duration": "2bar", "elements": [
      { "type": "text", "content": "ONE", "start": "0b", "end": "1b" },
      { "type": "text", "content": "TWO", "start": "1b", "end": "2b" }
    ] }
  ]
}
```

| Key             | Type   | Default | Description |
|-----------------|--------|---------|-------------|
| `bpm`           | number | —       | Beats per minute (required, > 0). One beat is `60 / bpm` seconds. |
| `beats_per_bar` | int    | `4`     | Beats in one `bar`. |

Beat values are accepted by scene `duration`, element `start`/`end`,
keyframe `time`, transition `duration`, and audio `start`/`end`/
`fade_in`/`fade_out`. They convert to seconds when the JSON is parsed —
the renderer never sees beat units, and seconds and beat values can be
mixed freely.

`trim` stays seconds-only, on both clips and audio tracks: trim addresses
time *inside the source media*, while the beat grid describes the video's
own timeline. Using a beat value without a top-level `tempo` is a
validation error. `scenescript validate` prints the grid in its summary
(`tempo: 152 bpm, beat = 0.3947 s`) so you can sanity-check the math.

### Scenes

A scene is a self-contained span of time with its own background and elements. Scenes play in document order; the video's total duration is the sum of scene durations (transitions overlap scene boundaries and do not add time).

```json
{
  "duration": 5,
  "background": { "type": "color", "color": "#1a1a2e" },
  "elements": [ ... ],
  "transition": { "type": "fade", "duration": 1.0 }
}
```

| Key          | Type   | Required | Description |
|--------------|--------|----------|-------------|
| `duration`   | number | yes      | Scene length in seconds, or a beat value (see [`tempo`](#tempo)). |
| `background` | object | no       | Scene background (defaults to black). |
| `elements`   | array  | no       | Elements drawn over the background, in document order (later = on top). |
| `transition` | object | no       | Transition into the *next* scene (ignored on the last scene). Defaults to a hard cut. |

#### Backgrounds

Solid color:

```json
{ "type": "color", "color": "#1a1a2e" }
```

Linear gradient:

```json
{ "type": "gradient", "from": "#1a1a2e", "to": "#16213e", "angle": 45 }
```

Full-frame image:

```json
{ "type": "image", "src": "assets/backdrop.png", "fit": "cover" }
```

### Elements

All element types share these keys:

| Key          | Type          | Default        | Description |
|--------------|---------------|----------------|-------------|
| `type`       | string        | —              | `text`, `video`, `image`, `shape`, `code`, `group`, or `ref` (see [Reusable Elements](#reusable-elements)). |
| `start`      | number        | `0`            | Seconds into the scene when the element appears; beat values allowed. |
| `end`        | number        | scene duration | Seconds into the scene when the element disappears; beat values allowed. |
| `position`   | string/object | `"center"`     | Named frame position (`center`, `top`, `bottom`, `top-left`, `top-right`, `bottom-left`, `bottom-right`) or explicit `{ "x": 100, "y": 200 }` pixel coordinates. |
| `anchor`     | string        | `"center"` (`"top-left"` for `code`) | Which point of the element an explicit `{x, y}` position places (see [Anchors](#anchors)). Not on `video` elements. |
| `opacity`    | number        | `1.0`          | `0.0`–`1.0`. |
| `shadow`     | object        | none           | Drop shadow (see [Shadows](#shadows)). Not on `video` elements. |
| `animations` | array         | `[]`           | Keyframed property animations (see [Animations](#animations)). |

Pixel dimensions (`width`, `height`, `size`, `radius`, `max_width`) accept any
number; fractional values are rounded, so computed layouts can pass floats.

#### Anchors

By default an explicit `{x, y}` position locates the element's **center**.
`anchor` changes which point it locates instead: `top-left`, `top`,
`top-right`, `left`, `right`, `bottom-left`, `bottom`, `bottom-right`, or
`center`. Named string positions (`"top-left"` etc.) already place the whole
box against the frame and ignore `anchor`.

For `text` elements, anchors are **font-metric based** so that different
strings line up consistently: `left`/`right` anchor the pen origin and
advance width (not the inked pixels), `top` anchors the ascender line, and
the additional `baseline-left`, `baseline`, and `baseline-right` anchors sit
on the first line's baseline. Anchoring text `top-left` or `baseline-left`
is the reliable way to build precise layouts (columns of text, labels that
change content) without measuring fonts yourself.

#### Shadows

`text`, `image`, `shape`, `code`, and `group` elements take an optional
drop shadow:

```json
{ "shadow": { "blur": 24, "color": "#000000", "opacity": 0.6, "offset": { "x": 0, "y": 8 } } }
```

| Key       | Type   | Default     | Description |
|-----------|--------|-------------|-------------|
| `blur`    | number | `8`         | Gaussian blur radius in pixels. |
| `color`   | string | `"#000000"` | Shadow color. |
| `opacity` | number | `0.5`       | Shadow strength, `0.0`–`1.0`. |
| `offset`  | object | `{0, 0}`    | Shadow displacement in pixels. |

#### `text`

```json
{
  "type": "text",
  "content": "Chapter One",
  "start": 0.5,
  "end": 4.0,
  "position": "center",
  "font": "assets/fonts/Inter-Bold.ttf",
  "size": 96,
  "color": "#ffffff",
  "align": "center",
  "max_width": 1600
}
```

| Key         | Type   | Default        | Description |
|-------------|--------|----------------|-------------|
| `content`   | string | —              | The text to display. Supports `\n` for manual line breaks. |
| `font`      | string | bundled default | Path to a `.ttf`/`.otf` file. |
| `size`      | number | `48`           | Font size in pixels. |
| `color`     | string | `"#ffffff"`    | Hex color. |
| `align`     | string | `"center"`     | `left`, `center`, or `right` (for multi-line text). |
| `max_width` | number | frame width    | Wrap text to fit this pixel width. |
| `wrap`      | bool   | `true`         | Set `false` to disable wrapping entirely (`\n` still breaks lines). |

Wrapping breaks lines between words and preserves indentation and interior
runs of spaces. Text content can also change over time via a `content`
animation (see [Animations](#animations)).

#### `video`

Inserts a video clip as an element. A clip covering the full frame for the full scene is the common case, but clips can also be positioned and timed like any other element (picture-in-picture).

```json
{
  "type": "video",
  "src": "assets/clip.mp4",
  "trim": { "start": 2.0, "end": 10.0 },
  "fit": "cover",
  "volume": 1.0
}
```

| Key      | Type   | Default   | Description |
|----------|--------|-----------|-------------|
| `src`    | string | —         | Path to the clip, relative to the JSON file. |
| `trim`   | object | full clip | Portion of the source clip to use, in source seconds. |
| `fit`    | string | `"cover"` | `cover` (fill, cropping as needed), `contain` (letterbox), or `stretch`. |
| `scale`  | number | `1.0`     | Uniform scale applied after `fit` (useful for picture-in-picture). |
| `volume` | number | `1.0`     | The clip's own audio level; `0` mutes it. |
| `speed`  | number | `1.0`     | Playback rate: `2.0` plays double speed, `0.5` half speed. Requires `volume: 0`. |

`speed` applies after `trim`: the trimmed span plays at `speed`× rate, so it
lasts `(trim.end − trim.start) / speed` seconds on screen. Element-relative
time `t` shows the source frame at `trim.start + t × speed`. Speed does not
apply to the clip's audio, so a speed other than `1.0` requires `volume: 0` —
this is a validation error rather than a silent guess about what sped-up
audio should sound like.

If the trimmed clip (at its playback speed) is shorter than its element window, it holds its last frame; if longer, it is cut off at `end`.

#### `image`

```json
{
  "type": "image",
  "src": "assets/logo.png",
  "position": "top-right",
  "scale": 0.5
}
```

| Key     | Type   | Default | Description |
|---------|--------|---------|-------------|
| `src`   | string | —       | Path to a PNG/JPEG (PNG alpha is respected). |
| `scale` | number | `1.0`   | Uniform scale relative to the source's native size. |
| `fit`   | string | none    | Optional `cover`/`contain`/`stretch` to size against the full frame instead of `scale`. |

#### `shape`

```json
{
  "type": "shape",
  "shape": "rect",
  "width": 800,
  "height": 4,
  "fill": "#e94560",
  "position": { "x": 960, "y": 700 }
}
```

| Key            | Type   | Default | Description |
|----------------|--------|---------|-------------|
| `shape`        | string | —       | `rect`, `ellipse`, or `line`. |
| `width`/`height` | number | —     | Dimensions in pixels (`line` uses `x2`/`y2` endpoints instead). |
| `fill`         | string | none    | Fill color. |
| `stroke`       | string | none    | Stroke color. |
| `stroke_width` | int    | `1`     | Stroke thickness in pixels. |
| `radius`       | number | `0`     | Corner radius for `rect`. |

#### `code`

A block of syntax-highlighted code, laid out on a fixed line grid with
optional line numbers — and optionally revealed over time, typed character
by character with a cursor, as if someone were writing it live. Built for
coding-tutorial videos: put a `code` element over a mock editor window (see
[Reusable Elements](#reusable-elements)) and the code types itself in.

```json
{
  "type": "code",
  "content": "def greet(name: str) -> str:\n    return f\"Hi, {name}\"\n",
  "language": "python",
  "theme": "dark-plus",
  "font": "assets/fonts/JetBrainsMono-Regular.ttf",
  "size": 28,
  "line_numbers": true,
  "position": { "x": 200, "y": 170 },
  "reveal": { "mode": "typing", "cps": 28, "line_pause": 0.3 }
}
```

| Key            | Type   | Default       | Description |
|----------------|--------|---------------|-------------|
| `content`      | string | —             | The code. Tabs expand to `tab_width` spaces; indentation is preserved exactly. |
| `language`     | string | `"python"`    | Any [pygments lexer name](https://pygments.org/languages/), or `"none"` for monochrome text (no pygments needed). |
| `theme`        | string | `"dark-plus"` | `dark-plus`, `monokai`, or `github-light`. |
| `font`         | string | —             | Path to a monospace `.ttf`/`.otf`. Required. |
| `size`         | number | `28`          | Font size in pixels. |
| `line_height`  | number | `size × 1.5`  | Vertical distance between line tops. |
| `line_numbers` | bool   | `false`       | Draw a right-aligned line-number gutter. |
| `tab_width`    | int    | `4`           | Spaces per tab. |
| `reveal`       | object | none          | Animate the code in (below). Omit for a static block. |

The block's `anchor` defaults to `top-left`, so `position` is the top-left
corner of the first line — matching how you think about an editor's text
area. Highlighting needs the `code` extra (`pip install "scenescript[code]"`).

##### `reveal`

| Key          | Type   | Default   | Description |
|--------------|--------|-----------|-------------|
| `mode`       | string | `"typing"`| `typing` (character by character) or `lines` (line by line). |
| `cps`        | number | `30`      | Typing speed, characters per second (`typing`). |
| `line_pause` | number | `0.3`     | Extra pause after each line break (`typing`). |
| `interval`   | number | `0.5`     | Seconds between lines (`lines`). |
| `delay`      | number | `0`       | Seconds after the element starts before the reveal begins. |
| `cursor`     | bool   | mode is `typing` | Draw an insertion-point cursor at the reveal position. |
| `blink`      | bool   | `true`    | Blink the cursor once the reveal finishes. |

Times are on the element's own clock (they start at `start`). Validation
fails if the reveal takes longer than the element's window, so a too-short
scene is caught before rendering.

#### `group`

A container that renders its child elements onto its own canvas, then
places, animates, and fades that canvas as a single element. Children use
the group's coordinate space (positions relative to the group's top-left,
named positions relative to the group's box), run on the group's clock
(`start`/`end` relative to the group's `start`), and are **clipped** to the
group's `width` × `height`.

```json
{
  "type": "group",
  "width": 800,
  "height": 400,
  "position": { "x": 960, "y": 540 },
  "elements": [
    { "type": "shape", "shape": "rect", "width": 800, "height": 400, "fill": "#1e1e1e", "position": { "x": 400, "y": 200 } },
    { "type": "text", "content": "inside the panel", "position": { "x": 400, "y": 200 } }
  ]
}
```

| Key              | Type   | Default  | Description |
|------------------|--------|----------|-------------|
| `width`/`height` | number | —        | The group's canvas size in pixels. Children are clipped to it. |
| `scale`          | number | `1.0`    | Uniform scale applied to the rendered canvas. |
| `content_offset` | object | `{0, 0}` | Shifts all children within the canvas — animate it to scroll (a long code listing inside an editor viewport, terminal output). |
| `elements`       | array  | `[]`     | Child elements, drawn in document order. Refs work here too. |

Groups make composites move as one: scale a whole mock window down,
slide a panel in, fade an entire overlay — and their clipping is what makes
scrolling viewports possible (animate `content_offset`).

### Reusable Elements

Define a group of elements once under the top-level `defs` key, then insert it anywhere with a `ref` element. This suits composites you use in many scenes — a mock IDE window, a lower third, a watermark block:

```json
{
  "defs": {
    "ide": [
      { "type": "shape", "shape": "rect", "position": { "x": 960, "y": 540 }, "width": 1800, "height": 1000, "fill": "#1e1e1e", "radius": 12 },
      { "type": "shape", "shape": "rect", "position": { "x": 960, "y": 80 }, "width": 1800, "height": 80, "fill": "#2d2d2d" },
      { "type": "text", "content": "main.py", "position": { "x": 960, "y": 80 }, "size": 28 }
    ]
  },
  "scenes": [
    {
      "duration": 8,
      "elements": [
        { "type": "ref", "name": "ide" },
        { "type": "text", "content": "def render():", "position": { "x": 300, "y": 200 } }
      ]
    }
  ]
}
```

| Key      | Type   | Required | Description |
|----------|--------|----------|-------------|
| `name`   | string | yes      | The def to insert. |
| `offset` | object | no       | `{ "x": dx, "y": dy }` added to every inserted element's position (after named anchors resolve). Defaults to no shift. |
| `params` | object | no       | Values for a parameterized def's placeholders (below). |

A `ref` splices the def's elements in place, keeping layer order: the def's elements draw in their own order, and anything listed after the ref draws on top — so a def works naturally as a background composite under per-scene content. Defs may reference other defs (offsets accumulate); circular references are a validation error. A def element that omits `end` runs to the end of whichever scene uses it. Every def is validated once at its own path (problems read `defs.ide[2].size: ...`), whether or not it is used.

#### Parameterized defs

A def can declare parameters with default values by using the object form —
`{ "params": { defaults }, "elements": [ ... ] }` — and referencing them as
`{name}` placeholders in any string:

```json
{
  "defs": {
    "editor_tab": {
      "params": { "filename": "untitled", "width": 200 },
      "elements": [
        { "type": "shape", "shape": "rect", "width": "{width}", "height": 40, "fill": "#1e1e1e", "position": { "x": 100, "y": 20 } },
        { "type": "text", "content": "{filename}", "size": 22, "position": { "x": 100, "y": 20 } }
      ]
    }
  },
  "scenes": [
    {
      "duration": 4,
      "elements": [
        { "type": "ref", "name": "editor_tab", "params": { "filename": "app.py" } }
      ]
    }
  ]
}
```

A string that is exactly one placeholder (`"{width}"`) takes the parameter's
own type, so numbers can parameterize dimensions and positions. Placeholders
are spliced into longer strings as text. Only declared parameters substitute
— other braces (dict literals in code content, say) pass through untouched —
and passing an undeclared parameter is a validation error, so typos are
caught. A ref that omits `params` uses the declared defaults.

#### Shared def libraries

The top-level `include` key merges `defs` from other JSON files, so one IDE
mock, lower third, or brand kit can serve many videos:

```json
{ "include": ["../shared/ide.defs.json"], "scenes": [ ... ] }
```

An included file is a JSON object with a single `defs` object. Asset paths
inside it resolve against **that file's** directory (a def library carries
its own fonts and images no matter who includes it). Names must be unique
across the document and everything it includes — a collision is a validation
error, not a silent override. Included files cannot themselves include.
Problems inside an included def are reported with the file prefixed:
`ide.defs.json:defs.ide[2].size: ...`.

### Animations

Any element can animate `position`, `opacity`, `scale`, `scale_x`, or `scale_y` with keyframes. Text elements can additionally animate `content` (the string itself — values switch at each keyframe, no interpolation), and groups can animate `content_offset` (scrolling their children). Times are in seconds relative to the element's `start` (beat values are allowed with a [`tempo`](#tempo)). Values between keyframes are interpolated; before the first and after the last keyframe, the boundary value holds.

```json
{
  "type": "text",
  "content": "Sliding in",
  "start": 1.0,
  "end": 5.0,
  "animations": [
    {
      "property": "position",
      "keyframes": [
        { "time": 0.0, "value": { "x": -400, "y": 540 }, "easing": "ease_out" },
        { "time": 0.8, "value": { "x": 960, "y": 540 } }
      ]
    },
    {
      "property": "opacity",
      "keyframes": [
        { "time": 0.0, "value": 0.0 },
        { "time": 0.8, "value": 1.0 }
      ]
    }
  ]
}
```

| Key        | Type   | Description |
|------------|--------|-------------|
| `property` | string | `position`, `opacity`, `scale`, `scale_x`, `scale_y`, `content` (text only), or `content_offset` (groups only). |
| `keyframes`| array  | Two or more `{ time, value, easing }` points. A `position` animation may carry a [`shake`](#shake) instead. |
| `easing`   | string | Interpolation *out of* this keyframe: `linear` (default), `ease_in`, `ease_out`, `ease_in_out`, or `step` (hold this value, jump at the next keyframe). |
| `repeat`   | int    | Play the keyframe cycle this many times before the final value holds. Default `1`. |
| `loop`     | bool   | Repeat the cycle for as long as the element is visible. Mutually exclusive with `repeat`. |

`scale` is uniform. `scale_x` and `scale_y` scale one axis only; when one is combined with `scale` on the same element, the values multiply.

`step` easing plus `loop` covers everything that used to need long hand-built
keyframe lists — a blinking cursor is three keyframes:

```json
{
  "property": "opacity",
  "loop": true,
  "keyframes": [
    { "time": 0.0, "value": 1.0, "easing": "step" },
    { "time": 0.53, "value": 0.0, "easing": "step" },
    { "time": 1.06, "value": 1.0 }
  ]
}
```

A slow position/scale drift on an image gives you the Ken Burns effect:

```json
{
  "property": "scale",
  "keyframes": [
    { "time": 0.0, "value": 1.0 },
    { "time": 8.0, "value": 1.15 }
  ]
}
```

#### Shake

A `position` animation can carry a `shake` instead of `keyframes` (exactly
one of the two): a procedural, seeded camera-shake whose waypoints no one
should have to write by hand. Impact shakes, handheld wobble, and rumble
are all this one form.

```json
{
  "property": "position",
  "shake": { "amplitude": 14, "frequency": 15, "decay": 0.55, "seed": 42 }
}
```

| Key         | Type             | Default | Description |
|-------------|------------------|---------|-------------|
| `amplitude` | number or object | —       | Maximum offset in pixels; `{ "x": 10, "y": 4 }` shakes the axes by different amounts (required). |
| `frequency` | number           | `15`    | Waypoints per second. |
| `decay`     | number           | none    | Seconds over which the shake fades linearly to zero; omit for a sustained shake. Beat values allowed. |
| `delay`     | number           | `0`     | Seconds into the element before the shake starts. Beat values allowed. |
| `seed`      | int              | `0`     | Picks the noise sequence; the same seed always shakes the same way. |

The shake is **additive**: it offsets whatever the element's position
otherwise is — a static position, a named anchor, or a running keyframe
animation. So a zoom-punch drift and a shake are simply two `position`
animations on the same element, one keyframed and one shaken. `repeat` and
`loop` don't apply (a sustained shake never repeats itself; give it a
`decay` to end it).

The algorithm is pinned so the same JSON renders identically everywhere:
waypoint `k` sits at `delay + k / frequency` seconds and takes its x and y
offsets from draws `2k` and `2k + 1` of a counter-based
[SplitMix64](https://prng.di.unimi.it/splitmix64.c) stream — draw `n` is
`mix64(seed + (n + 1) × 0x9E3779B97F4A7C15) / 2⁶³ − 1`, uniform in
`[-1, 1)` — linearly interpolated between waypoints, scaled by the decay
envelope, then by `amplitude`.

### Transitions

A scene's `transition` describes how it hands off to the next scene. During the transition, the tail of the outgoing scene and the head of the incoming scene render simultaneously and are blended.

```json
{ "type": "wipe", "duration": 0.75, "direction": "left" }
```

| Type        | Description | Extra keys |
|-------------|-------------|------------|
| `cut`       | Hard cut (the default). | — |
| `fade`      | Outgoing scene fades to black, incoming fades in from black. | — |
| `crossfade` | Outgoing scene dissolves directly into the incoming one. | — |
| `wipe`      | Incoming scene is revealed by a moving edge. | `direction`: `left`, `right`, `up`, `down` |
| `slide`     | Incoming scene pushes the outgoing one off-frame. | `direction`: `left`, `right`, `up`, `down` |

`duration` is in seconds (beat values allowed) and is capped at the shorter of the two adjacent scenes.

### Audio

Global audio tracks are timed against the whole video, independent of scene boundaries. Multiple tracks are mixed together, along with any audio from `video` elements.

```json
{
  "src": "assets/music.mp3",
  "start": 0,
  "end": 30,
  "volume": 0.6,
  "fade_in": 1.0,
  "fade_out": 2.0,
  "loop": false
}
```

| Key        | Type    | Default      | Description |
|------------|---------|--------------|-------------|
| `src`      | string  | —            | Path to an audio file (mp3, wav, flac, ogg). |
| `start`    | number  | `0`          | Seconds into the *video* when the track starts; beat values allowed. |
| `end`      | number  | video end    | Seconds into the video when the track stops; beat values allowed. |
| `trim`     | object  | full file    | Portion of the source file to use, in source seconds. |
| `volume`   | number  | `1.0`        | Track gain. |
| `fade_in`  | number  | `0`          | Fade-in length in seconds; beat values allowed. |
| `fade_out` | number  | `0`          | Fade-out length in seconds; beat values allowed. |
| `loop`     | boolean | `false`      | Repeat the track to fill its window. |

## Full Example

A 20-second video: a title card, a narrated clip with a lower-third and logo, then a closing card. Music runs underneath the whole thing.

```json
{
  "output": { "width": 1920, "height": 1080, "fps": 30 },
  "scenes": [
    {
      "duration": 4,
      "background": { "type": "gradient", "from": "#1a1a2e", "to": "#16213e", "angle": 90 },
      "elements": [
        {
          "type": "text",
          "content": "Weekly Update",
          "position": { "x": 960, "y": 480 },
          "font": "assets/fonts/Inter-Bold.ttf",
          "size": 120,
          "animations": [
            {
              "property": "opacity",
              "keyframes": [
                { "time": 0.0, "value": 0.0 },
                { "time": 0.6, "value": 1.0 }
              ]
            }
          ]
        },
        {
          "type": "shape",
          "shape": "rect",
          "width": 500,
          "height": 6,
          "fill": "#e94560",
          "position": { "x": 960, "y": 580 },
          "start": 0.4
        }
      ],
      "transition": { "type": "crossfade", "duration": 0.75 }
    },
    {
      "duration": 12,
      "elements": [
        {
          "type": "video",
          "src": "assets/interview.mp4",
          "trim": { "start": 3.0, "end": 15.0 },
          "fit": "cover",
          "volume": 1.0
        },
        {
          "type": "text",
          "content": "Dana Reyes\nProduct Lead",
          "start": 1.0,
          "end": 6.0,
          "position": { "x": 400, "y": 920 },
          "size": 42,
          "align": "left"
        },
        {
          "type": "image",
          "src": "assets/logo.png",
          "position": "top-right",
          "scale": 0.4,
          "opacity": 0.8
        }
      ],
      "transition": { "type": "fade", "duration": 1.0 }
    },
    {
      "duration": 4,
      "background": { "type": "color", "color": "#1a1a2e" },
      "elements": [
        {
          "type": "text",
          "content": "See you next week",
          "position": "center",
          "size": 80
        }
      ]
    }
  ],
  "audio": [
    {
      "src": "assets/music.mp3",
      "start": 0,
      "volume": 0.35,
      "fade_in": 1.0,
      "fade_out": 2.5,
      "loop": true
    }
  ]
}
```

```bash
scenescript render weekly-update.json -o weekly-update.mp4
```

## Animated Bar Chart Example

There are no chart primitives — a bar chart is just shapes and text, animated. This example draws the axes in, then brings up each bar in turn: the label fades in, and the bar grows up out of the baseline.

It leans on one technique worth knowing. Elements are positioned by their **center**, so animating `scale_y` from `0` to `1` grows a shape vertically around its middle — both ends move. To grow it from one edge instead (a bar rising from the baseline, an axis sweeping out of the corner), add a second animation: `position`, from the pinned edge to the element's final center, with the **same keyframe times and easing**. The two interpolations cancel at the pinned edge, so it never moves. The bars below do this with `scale_y` (height only, constant width), and the x-axis does the same horizontally with `scale_x`.

```json
{
  "output": { "width": 1920, "height": 1080, "fps": 30 },
  "scenes": [
    {
      "duration": 6,
      "background": { "type": "color", "color": "#1a1a2e" },
      "elements": [
        {
          "type": "text",
          "content": "Signups per Quarter",
          "position": { "x": 960, "y": 140 },
          "size": 72
        },
        {
          "type": "shape",
          "shape": "rect",
          "width": 6,
          "height": 600,
          "fill": "#a0a0b8",
          "position": { "x": 360, "y": 550 },
          "animations": [
            {
              "property": "scale_y",
              "keyframes": [
                { "time": 0.0, "value": 0.0, "easing": "ease_out" },
                { "time": 0.6, "value": 1.0 }
              ]
            },
            {
              "property": "position",
              "keyframes": [
                { "time": 0.0, "value": { "x": 360, "y": 850 }, "easing": "ease_out" },
                { "time": 0.6, "value": { "x": 360, "y": 550 } }
              ]
            }
          ]
        },
        {
          "type": "shape",
          "shape": "rect",
          "width": 1200,
          "height": 6,
          "fill": "#a0a0b8",
          "position": { "x": 960, "y": 850 },
          "start": 0.4,
          "animations": [
            {
              "property": "scale_x",
              "keyframes": [
                { "time": 0.0, "value": 0.0, "easing": "ease_out" },
                { "time": 0.6, "value": 1.0 }
              ]
            },
            {
              "property": "position",
              "keyframes": [
                { "time": 0.0, "value": { "x": 360, "y": 850 }, "easing": "ease_out" },
                { "time": 0.6, "value": { "x": 960, "y": 850 } }
              ]
            }
          ]
        },
        {
          "type": "text",
          "content": "Q1",
          "position": { "x": 600, "y": 910 },
          "size": 44,
          "start": 1.2,
          "animations": [
            {
              "property": "opacity",
              "keyframes": [
                { "time": 0.0, "value": 0.0 },
                { "time": 0.3, "value": 1.0 }
              ]
            }
          ]
        },
        {
          "type": "shape",
          "shape": "rect",
          "width": 160,
          "height": 240,
          "fill": "#e94560",
          "position": { "x": 600, "y": 730 },
          "start": 1.4,
          "animations": [
            {
              "property": "scale_y",
              "keyframes": [
                { "time": 0.0, "value": 0.0, "easing": "ease_out" },
                { "time": 0.6, "value": 1.0 }
              ]
            },
            {
              "property": "position",
              "keyframes": [
                { "time": 0.0, "value": { "x": 600, "y": 850 }, "easing": "ease_out" },
                { "time": 0.6, "value": { "x": 600, "y": 730 } }
              ]
            }
          ]
        },
        {
          "type": "text",
          "content": "Q2",
          "position": { "x": 960, "y": 910 },
          "size": 44,
          "start": 2.0,
          "animations": [
            {
              "property": "opacity",
              "keyframes": [
                { "time": 0.0, "value": 0.0 },
                { "time": 0.3, "value": 1.0 }
              ]
            }
          ]
        },
        {
          "type": "shape",
          "shape": "rect",
          "width": 160,
          "height": 420,
          "fill": "#e94560",
          "position": { "x": 960, "y": 640 },
          "start": 2.2,
          "animations": [
            {
              "property": "scale_y",
              "keyframes": [
                { "time": 0.0, "value": 0.0, "easing": "ease_out" },
                { "time": 0.6, "value": 1.0 }
              ]
            },
            {
              "property": "position",
              "keyframes": [
                { "time": 0.0, "value": { "x": 960, "y": 850 }, "easing": "ease_out" },
                { "time": 0.6, "value": { "x": 960, "y": 640 } }
              ]
            }
          ]
        },
        {
          "type": "text",
          "content": "Q3",
          "position": { "x": 1320, "y": 910 },
          "size": 44,
          "start": 2.8,
          "animations": [
            {
              "property": "opacity",
              "keyframes": [
                { "time": 0.0, "value": 0.0 },
                { "time": 0.3, "value": 1.0 }
              ]
            }
          ]
        },
        {
          "type": "shape",
          "shape": "rect",
          "width": 160,
          "height": 540,
          "fill": "#e94560",
          "position": { "x": 1320, "y": 580 },
          "start": 3.0,
          "animations": [
            {
              "property": "scale_y",
              "keyframes": [
                { "time": 0.0, "value": 0.0, "easing": "ease_out" },
                { "time": 0.6, "value": 1.0 }
              ]
            },
            {
              "property": "position",
              "keyframes": [
                { "time": 0.0, "value": { "x": 1320, "y": 850 }, "easing": "ease_out" },
                { "time": 0.6, "value": { "x": 1320, "y": 580 } }
              ]
            }
          ]
        }
      ]
    }
  ]
}
```

```bash
scenescript render bar-chart.json -o bar-chart.mp4
```

How the timing reads: the y-axis sweeps up out of the corner over `0.0–0.6 s`, the x-axis sweeps right over `0.4–1.0 s`, then each bar takes its turn — label fades in, and `0.2 s` later the bar rises out of the baseline over `0.6 s` (labels at `1.2 s`, `2.0 s`, `2.8 s`). Every bar's baseline edge sits at `y = 850`, so its resting center is `850 − height / 2`, which is exactly the second `position` keyframe.

## CLI Reference

### `scenescript render`

Render a JSON file to a video.

```bash
scenescript render <input.json> -o <output>
```

| Option              | Description |
|---------------------|-------------|
| `-o, --output PATH` | Output file path. Extension may override `output.format`. |
| `--overwrite`       | Replace the output file if it already exists. |
| `-q, --quiet`       | Suppress the progress display. |

### `scenescript frames`

Render single frames to PNGs for spot-checking a spec without a full render.

```bash
scenescript frames <input.json> -o <directory> --at 3.5 --frame 105
```

| Option         | Description |
|----------------|-------------|
| `-o, --output DIR` | Directory for the PNGs (created if missing; existing frames are replaced). |
| `--at SECONDS` | Render the frame at this time. Repeatable. |
| `--frame N`    | Render this frame number. Repeatable. |

Frames are written as `frame_<number>.png` (zero-padded) and each path is printed. Transition frames blend exactly as a full render would.

### `scenescript validate`

Check a JSON file without rendering: schema correctness, asset paths, timing sanity (elements within scene bounds, transitions not longer than their scenes).

```bash
scenescript validate <input.json>
```

Exits non-zero with a list of problems, or prints a summary of the video (scene count, total duration, the beat grid when a `tempo` is declared, assets used) when valid.

## MCP Server

SceneScript ships an optional [MCP](https://modelcontextprotocol.io) server so AI agents can author and render videos through tool calls.

```bash
pip install "scenescript[mcp,code]"
scenescript-mcp
```

(The `code` extra is optional but recommended: it lets agents use
syntax-highlighted [`code` elements](#code) without a follow-up install.)

The server speaks stdio. To register it with Claude Code:

```bash
claude mcp add scenescript -- scenescript-mcp
```

The registration stores the `scenescript-mcp` command, not a version, so after [upgrading](#upgrading) there is nothing to re-register. A client launches the server once per session, so a running session keeps the old one; start a new session (or reconnect via `/mcp` in Claude Code) to pick up the new version.

Four tools:

| Tool | What it does |
|---|---|
| `get_format_reference` | Returns this document, so an agent can learn the JSON format before authoring a spec. |
| `validate_video` | Checks a spec without rendering. Pass the spec inline as `spec` (asset paths resolve against `assets_dir`) or as `spec_path` (a JSON file; asset paths resolve beside it). |
| `render_video` | Renders a spec to `output_path`. The extension picks the container; an existing file is only replaced when `overwrite` is true. |
| `render_frames` | Renders single frames as PNGs into `output_dir` to spot-check a spec, picked by `times` (seconds) and/or `frame_numbers`. Returns each frame's number, timestamp, and file path. |

Validation problems come back as data rather than tool errors, so an agent can fix its spec and call again. Rendering takes roughly the video's duration.

## Design Principles

- **The JSON is the video.** The input file fully determines the output: no hidden state, no interactive editing, no generation step. Version-control your JSON and you've version-controlled your video.
- **Deterministic.** The same JSON and assets always render the same output. That makes SceneScript safe to drop into automated pipelines that write JSON from data.
- **No editing frameworks.** Composition happens directly on Pillow/numpy frame buffers. ffmpeg is used strictly at the boundaries: decoding inserted clips, encoding the frame stream, and mixing audio.
- **Fail before rendering.** Validation runs up front so a typo in scene 9 doesn't surface after eight scenes have already rendered.

## License

MIT
