Metadata-Version: 2.5
Name: scenescript
Version: 0.4.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. |

#### `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`. |

### 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. |
| `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. |
| `end`        | number        | scene duration | Seconds into the scene when the element disappears. |
| `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. |

If the trimmed clip 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`. 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. |
| `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 }
  ]
}
```

### 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 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. |
| `end`      | number  | video end    | Seconds into the video when the track stops. |
| `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. |
| `fade_out` | number  | `0`          | Fade-out length in seconds. |
| `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, 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
