kinescript

Video you can diff.

Kinescript turns a JSON scene into video. Every layer, keyframe, cut and sound is a line of data, so a scene can be generated, reviewed in a pull request and changed by an agent, then checked and rendered from the command line. A voice-over can start as a line of text.

pip install kinescript Python 3.10+ and ffmpeg
0.0 s
  1. bg
  2. s1
  3. s2
  4. s3
  5. s4
  6. s5
  7. s6
  8. wipes
  9. cc
  10. vo1
  11. vo2
  12. vo3
  13. vo4
  14. vo5
  15. vo6
  16. bed
This video is one scene file, marketing/hero.json. Its six lines of voice-over were written as text and spoken by kine voice; each section starts on its line, and the clips, code and effects inside it start on the words. At 1080p it renders with its sound in about 5.3 seconds on the development machine; this copy is 1280x720. The bars under it are the scene's top-level layers and its sounds as kine probe reports them, with a dot where each child layer starts and each word is said.

Time is a reference.

A layer can start when another one ends: "at": "intro.out+0.2". Make the intro two seconds longer and everything cued after it moves, in a one-line diff. Cycles and missing ids are reported before anything renders.

demos/cues.jsonone line changed
 {   "canvas": {"w": 1920, "h": 1080, "fps": 30, "bg": "#0a0c10"},   "layers": [     {"id": "intro", "type": "text", "text": "October update", "x": "50%", "y": "50%", "size": 120, "weight": 700,-     "dur": 2.5, "enter": "snap", "exit": "fade"},+     "dur": 4.5, "enter": "snap", "exit": "fade"},     {"id": "title", "type": "text", "text": "Orders doubled", "x": "50%", "y": "30%", "size": 110, "weight": 700,      "at": "intro.out+0.2", "dur": 3.5, "enter": {"name": "left", "dist": 28, "dur": 0.32}, "exit": "fade"},     {"id": "bar", "type": "rect", "x": "50%", "y": "62%", "w": {"from": 0, "to": 900, "dur": 0.8}, "h": 60,      "fill": "$accent", "at": "title.in+0.4", "until": "title.out", "exit": "fade"},     {"id": "cta", "type": "text", "text": "example.com/q4", "font": "IBM Plex Mono", "x": "50%", "y": "50%",      "size": 72, "at": "title.out", "dur": 2.5, "enter": "snap"}   ] }

Before

  1. intro
  2. title
  3. bar
  4. cta

After

  1. intro
  2. title
  3. bar
  4. cta

The file beside the video.

Each demo is a scene file and the video Kinescript rendered from it with kine render -q high, here at half size. The bars under a video are its layers, and its sounds in orange; click them to jump.

Kinetic captions

Vertical video, 1080x1920. Words that replace each other on a beat, a per-letter wave written as an expression, a typed line with a caret, and karaoke captions timed in the scene.

0.0 s
  1. bg
  2. file
  3. words
  4. wave
  5. line
  6. cmd
  7. cap
  8. bar
captions.json1080 x 1920, 7 s
{  "canvas": {"w": 1080, "h": 1920, "fps": 30, "duration": 7, "bg": "#0a0c10"},  "grade": {"vignette": 0.3},  "layers": [    {"id": "bg", "type": "shader", "preset": "score", "opacity": 0.55},    {"id": "file", "type": "text", "text": "captions.json  1080 x 1920", "font": "IBM Plex Mono", "x": "50%", "y": "9%",     "size": 44, "weight": 500, "fill": "$muted", "at": 0.1, "enter": "fade"},    {"id": "words", "use": "kinetic", "words": ["Captions", "that", "land", "on", "the"], "every": 0.45, "size": 180,     "y": "36%", "at": 0.3},    {"id": "wave", "type": "text", "text": "beat.", "x": "50%", "y": "36%", "size": 230, "weight": 800,     "fill": "$accent2", "split": "chars", "unitY": "=sin(T*5 + i*0.7)*16", "at": "words.out",     "enter": {"name": "rise", "dur": 0.4, "stagger": 0.04}},    {"id": "line", "type": "line", "points": [[-300, 0], [300, 0]], "x": "50%", "y": "47%", "stroke": "$muted",     "strokeWidth": 8, "at": "wave.in+0.3", "enter": {"name": "draw", "dur": 0.5}},    {"id": "cmd", "type": "text", "text": "\"mode\": \"karaoke\"", "font": "IBM Plex Mono", "x": "50%", "y": "54%",     "size": 48, "weight": 500, "at": "line.in+0.3", "enter": {"name": "type", "dur": 0.8}, "caret": true},    {"id": "cap", "type": "captions", "mode": "karaoke", "y": "70%", "size": 62, "maxWidth": 960, "at": 0.3,     "cues": [[0, 2.5, "Each word is timed in JSON"], [2.5, 4.6, "and lights up as it is said"],              [4.6, 6.7, "so a caption fix is one line"]]},    {"use": "progress", "id": "bar", "h": 10, "color": "$muted"}  ]}

A video edit

A cut list of three trimmed clips with a punch-in, a zoom and a wipe, then a lower third, a callout, a rounded picture-in-picture and captions. The clips are other demos on this page.

0.0 s
  1. cut
  2. lt
  3. co
  4. pip
  5. cap
  6. bar
edit.json1920 x 1080, 11.6 s
{  "canvas": {"w": 1920, "h": 1080, "fps": 30},  "grade": {"vignette": 0.25},  "layers": [    {"id": "cut", "type": "sequence",     "clips": [       {"src": "import.mp4", "trim": [0.8, 4.6], "zoom": [[0, 1], [3.2, 1.3, "inOutSine"]], "focus": [0.5, 0.32]},       {"src": "dolly.mp4", "trim": [2.6, 6.6]},       {"src": "materials.mp4", "trim": [1.2, 6.0], "enter": {"name": "wipe-right", "dur": 0.5, "ease": "inOutSine"}}     ],     "transition": {"type": "zoom", "dur": 0.5}},    {"use": "lowerThird", "id": "lt", "title": "A glTF file, trimmed", "subtitle": "\"trim\": [0.8, 4.6]",     "at": 0.4, "dur": 2.6},    {"use": "callout", "id": "co", "x": "50%", "y": "32%", "r": 70, "label": "punch-in focus", "at": 1.2, "dur": 1.9},    {"id": "pip", "type": "video", "src": "card-flip.mp4", "trim": [0.8, 4.8], "anchor": "center", "x": "79%",     "y": "24%", "w": 576, "h": 324, "radius": 18, "shadow": {"y": 10, "blur": 30, "color": "#0a0c1066"},     "at": "cut-3.in+0.5", "until": "cut-3.out", "enter": {"name": "pop", "dur": 0.4}, "exit": "fade"},    {"id": "cap", "type": "captions", "mode": "karaoke", "at": "cut-2.in",     "cues": [[0.3, 3.2, "Three clips from one cut list"], [3.7, 7.8, "and a fourth in the corner"]]},    {"use": "progress", "id": "bar", "h": 6, "color": "$muted"}  ]}

A voice from a line of text.

Write what the voice says in the scene. kine voice speaks it with a plugin and writes the time of every word, so "at": "vo:text" starts a layer on that word and captions follow the voice. Tags such as [whispers] or [laughs] in the text say how to deliver the line. Give sounds a role and the mix is levelled for you. Rendering never calls the speech engine.

Narration that cues the picture

A Kokoro voice spoken from the scene's text. Five of its words each start a layer, and each layer's sound plays a click as it appears. The captions are lit from the word times, and the music dips 14 dB while the voice speaks. [[pic]] in the text names the word after it, so vo:pic stays on that word if the line is rewritten. Turn the sound on.

0.0 s
  1. b0
  2. b1
  3. b2
  4. b3
  5. b4
  6. cc
  7. progress
  8. vo
  9. bed
voice.json1920 x 1080, 9.95 s
{  "canvas": {"w": 1920, "h": 1080, "fps": 30, "duration": "auto", "bg": "$void"},  "audio": [    {"id": "vo", "role": "voice", "at": 0.6, "speaker": "af_heart",     "text": "This voice began as a line of text. Each word it says is a cue, so the picture [[pic]]follows it, and the music [[duck]]dips while it speaks."},    {"id": "bed", "role": "music", "src": "sound/bed.m4a", "loop": true, "until": "end", "fadeIn": 1, "fadeOut": 1.5}  ],  "components": {    "beat": {"params": {"word": "", "cue": ""}, "layers": [      {"type": "text", "text": "{{word}}", "x": "50%", "y": "42%", "size": 220, "weight": 800,       "enter": {"name": "pop", "dur": 0.35}, "exit": "fade"},      {"type": "text", "text": "at {{cue}}", "font": "IBM Plex Mono", "x": "50%", "y": "60%", "size": 44,       "fill": "$muted", "at": 0.1, "enter": "rise", "exit": "fade"}    ]}  },  "layers": [    {"use": "beat", "id": "b0", "word": "Voice", "cue": "vo:voice", "at": "vo:voice", "until": "vo:text-0.1",     "sound": "sound/click.wav"},    {"use": "beat", "id": "b1", "word": "Text", "cue": "vo:text", "at": "vo:text", "until": "vo:cue-0.1",     "sound": "sound/click.wav"},    {"use": "beat", "id": "b2", "word": "Cue", "cue": "vo:cue", "at": "vo:cue", "until": "vo:pic-0.1",     "sound": "sound/click.wav"},    {"use": "beat", "id": "b3", "word": "Picture", "cue": "vo:pic", "at": "vo:pic", "until": "vo:duck-0.1",     "sound": "sound/click.wav"},    {"use": "beat", "id": "b4", "word": "Mix", "cue": "vo:duck", "at": "vo:duck", "until": "vo.out+1.2",     "sound": "sound/click.wav"},    {"id": "cc", "type": "captions", "voice": "vo", "mode": "karaoke", "active": "$key"},    {"use": "progress", "id": "progress", "color": "$key", "h": 6}  ]}

One line, three backends

The same line, tags included, spoken by each plugin. Only ElevenLabs acts the tags: it whispers the first sentence and laughs. Kokoro leaves tags out. Chatterbox's Turbo model takes them, but here it speaks the whisper in its normal voice and leaves a silent pause for the laugh. In test takes it made a sound where the laugh goes in six of nine and whispered in none of seven. Each card's time is how long kine voice --force took for that one sound, as the median of three runs. Each run is a new process that loads the model or calls the API. Measured with bench/voices.py on an Intel Core Ultra 7 265 with a GeForce RTX 5060 Ti, Windows 11, October 2026. Turn the sound on.

0.0 s
  1. bg
  2. kicker
  3. title
  4. line
  5. k
  6. c
  7. e
  8. end
  9. end-code
  10. progress
  11. vo-k
  12. vo-c
  13. vo-e
  14. bed
voices.json1920 x 1080, 22.18 s
{  "canvas": {"w": 1920, "h": 1080, "fps": 30, "duration": "auto", "bg": "$void"},  "theme": {"panel": "#12151b", "lit": "#1a1e26"},  "audio": [    {"id": "vo-k", "role": "voice", "backend": "kokoro", "speaker": "af_heart", "at": 3.0,     "text": "[whispers] Same line, three [[voices]]voices. [laughs] [[guess]]Guess which one costs [[money]]money."},    {"id": "vo-c", "role": "voice", "backend": "chatterbox", "at": "vo-k.out+0.8",     "text": "[whispers] Same line, three [[voices]]voices. [laughs] [[guess]]Guess which one costs [[money]]money."},    {"id": "vo-e", "role": "voice", "backend": "elevenlabs", "speaker": "George", "at": "vo-c.out+0.8",     "text": "[whispers] Same line, three [[voices]]voices. [laughs] [[guess]]Guess which one costs [[money]]money."},    {"id": "bed", "role": "music", "src": "sound/bed.m4a", "loop": true, "until": "end", "fadeIn": 1, "fadeOut": 1.5}  ],  "components": {    "card": {"params": {"name": "", "sub": "", "vo": "", "made": 0, "speech": "", "on": "$key", "ink": "$void", "tags": "",                        "clone": "", "words": "", "cost": "", "hi": "$key"},     "layers": [      {"id": "box", "type": "group", "anchor": "topleft", "x": -280, "y": -320, "w": 560, "h": 640, "radius": 24, "fill": "$panel", "children": [        {"id": "edge", "type": "rect", "x": "50%", "y": "50%", "w": 560, "h": 640, "radius": 24, "stroke": "$track",         "strokeWidth": 2},        {"id": "live", "type": "rect", "x": "50%", "y": "50%", "w": 560, "h": 640, "radius": 24, "stroke": "$cue",         "strokeWidth": 4, "at": "{{vo}}.in-0.15", "until": "{{vo}}.out+0.25", "enter": {"name": "fade", "dur": 0.2},         "exit": {"name": "fade", "dur": 0.3}, "sound": "sound/click.wav"},        {"id": "dot", "type": "circle", "x": 516, "y": 46, "r": 9, "fill": "$cue", "at": "{{vo}}.in", "until": "{{vo}}.out",         "scale": "=1 + 0.25 * sin(t * 9)", "enter": "pop", "exit": "fade"},        {"id": "name", "type": "text", "text": "{{name}}", "x": "50%", "y": 62, "size": 60, "weight": 700},        {"id": "sub", "type": "text", "text": "{{sub}}", "font": "IBM Plex Mono", "x": "50%", "y": 110, "size": 26,         "weight": 500, "fill": "$muted"},        {"id": "wait", "type": "text", "text": "made in  -", "font": "IBM Plex Mono", "x": "50%", "y": 180, "size": 46,         "weight": 500, "fill": "$muted", "until": "{{vo}}.in"},        {"id": "made", "type": "counter", "font": "IBM Plex Mono", "x": "50%", "y": 180, "size": 46, "weight": 600,         "format": "made in {:.1f} s", "value": {"from": 0, "to": "{{made}}", "dur": 0.9}, "at": "{{vo}}.in"},        {"id": "track", "type": "rect", "x": 48, "y": 226, "anchor": "left", "w": 464, "h": 10, "radius": 5, "fill": "$lit"},        {"id": "bar", "type": "rect", "x": 48, "y": 226, "anchor": "left", "w": "={{made}} / 18 * 464", "h": 10, "radius": 5,         "fill": "$sheet", "at": "{{vo}}.in", "enter": {"name": "wipe", "dur": 0.9}},        {"id": "speech", "type": "text", "text": "for {{speech}} s of speech", "font": "IBM Plex Mono", "x": "50%", "y": 264,         "size": 26, "weight": 500, "fill": "$muted", "at": "{{vo}}.in+0.5", "enter": "fade"},        {"id": "cc", "type": "captions", "voice": "{{vo}}", "mode": "karaoke", "active": "$key", "x": "50%", "y": 334,         "size": 32, "maxWidth": 500, "box": {"fill": "$panel", "pad": [0, 0]}},        {"id": "t1", "type": "text", "text": "[whispers]", "font": "IBM Plex Mono", "x": "30%", "y": 396, "size": 28,         "weight": 500, "fill": "$muted", "box": {"fill": "$lit", "pad": [8, 16], "radius": 999}, "until": "{{vo}}.in"},        {"id": "t1-on", "type": "text", "text": "[whispers]", "font": "IBM Plex Mono", "x": "30%", "y": 396, "size": 28,         "weight": 600, "fill": "{{ink}}", "box": {"fill": "{{on}}", "pad": [8, 16], "radius": 999},         "at": "{{vo}}.in", "until": "{{vo}}:voices.out", "enter": {"name": "pop", "dur": 0.25}},        {"id": "t1-done", "type": "text", "text": "[whispers]", "font": "IBM Plex Mono", "x": "30%", "y": 396, "size": 28,         "weight": 500, "fill": "$muted", "box": {"fill": "$lit", "pad": [8, 16], "radius": 999}, "at": "{{vo}}:voices.out"},        {"id": "t2", "type": "text", "text": "[laughs]", "font": "IBM Plex Mono", "x": "70%", "y": 396, "size": 28,         "weight": 500, "fill": "$muted", "box": {"fill": "$lit", "pad": [8, 16], "radius": 999}, "until": "{{vo}}:voices.out"},        {"id": "t2-on", "type": "text", "text": "[laughs]", "font": "IBM Plex Mono", "x": "70%", "y": 396, "size": 28,         "weight": 600, "fill": "{{ink}}", "box": {"fill": "{{on}}", "pad": [8, 16], "radius": 999},         "at": "{{vo}}:voices.out", "until": "{{vo}}:guess.in+0.2", "enter": {"name": "pop", "dur": 0.25}},        {"id": "t2-done", "type": "text", "text": "[laughs]", "font": "IBM Plex Mono", "x": "70%", "y": 396, "size": 28,         "weight": 500, "fill": "$muted", "box": {"fill": "$lit", "pad": [8, 16], "radius": 999}, "at": "{{vo}}:guess.in+0.2"},        {"id": "r1", "type": "text", "text": "tags", "font": "IBM Plex Mono", "x": 48, "y": 462, "anchor": "left",         "size": 26, "weight": 500, "fill": "$muted"},        {"id": "v1", "type": "text", "text": "{{tags}}", "font": "IBM Plex Mono", "x": 512, "y": 462, "anchor": "right",         "size": 26, "weight": 500},        {"id": "r2", "type": "text", "text": "copies a voice", "font": "IBM Plex Mono", "x": 48, "y": 506, "anchor": "left",         "size": 26, "weight": 500, "fill": "$muted"},        {"id": "v2", "type": "text", "text": "{{clone}}", "font": "IBM Plex Mono", "x": 512, "y": 506, "anchor": "right",         "size": 26, "weight": 500},        {"id": "r3", "type": "text", "text": "word times", "font": "IBM Plex Mono", "x": 48, "y": 550, "anchor": "left",         "size": 26, "weight": 500, "fill": "$muted"},        {"id": "v3", "type": "text", "text": "{{words}}", "font": "IBM Plex Mono", "x": 512, "y": 550, "anchor": "right",         "size": 26, "weight": 500},        {"id": "r4", "type": "text", "text": "cost", "font": "IBM Plex Mono", "x": 48, "y": 594, "anchor": "left",         "size": 26, "weight": 500, "fill": "$muted"},        {"id": "v4", "type": "text", "text": "{{cost}}", "font": "IBM Plex Mono", "x": 512, "y": 594, "anchor": "right",         "size": 26, "weight": 500, "until": "vo-e:money"},        {"id": "v4-on", "type": "text", "text": "{{cost}}", "font": "IBM Plex Mono", "x": 512, "y": 594, "anchor": "right",         "size": 26, "weight": 700, "fill": "{{hi}}", "at": "vo-e:money", "enter": {"name": "pop", "dur": 0.35}}      ]}    ]}  },  "layers": [    {"id": "bg", "type": "shader", "preset": "score", "opacity": 0.35},    {"id": "kicker", "type": "text", "text": "kine voice, one backend per sound", "font": "IBM Plex Mono", "x": "50%",     "y": 74, "size": 30, "weight": 500, "fill": "$muted", "at": 0.2, "until": "vo-e.out+0.2", "enter": "fade",     "exit": "fade"},    {"id": "title", "type": "text", "text": "One line, three voices.", "x": "50%", "y": 140, "size": 84, "weight": 700,     "at": 0.3, "until": "vo-e.out+0.2", "enter": {"name": "rise", "dur": 0.5}, "exit": "fade"},    {"id": "line", "type": "text", "text": "**[whispers]** Same line, three voices. **[laughs]** Guess which one costs money.",     "font": "IBM Plex Mono", "x": "50%", "y": 240, "size": 34, "weight": 500, "fill": "$sheet", "emph": "$key",     "at": 0.9, "until": "vo-e.out+0.2", "enter": {"name": "type", "dur": 1.4}, "exit": "fade"},    {"use": "card", "id": "k", "x": 345, "y": 660, "at": 1.6, "enter": {"name": "rise", "dur": 0.6},     "name": "Kokoro", "sub": "82M, on the CPU", "vo": "vo-k", "made": 9.47, "speech": "4.0", "on": "$panel", "ink": "$track",     "tags": "left out", "clone": "no, blends", "words": "built in", "cost": "free"},    {"use": "card", "id": "c", "x": 960, "y": 660, "at": 1.75, "enter": {"name": "rise", "dur": 0.6},     "name": "Chatterbox", "sub": "Turbo, on the GPU", "vo": "vo-c", "made": 17.89, "speech": "5.0", "on": "$panel", "ink": "$track",     "tags": "not acted", "clone": "5 s clip", "words": "wav2vec2", "cost": "free"},    {"use": "card", "id": "e", "x": 1575, "y": 660, "at": 1.9, "enter": {"name": "rise", "dur": 0.6},     "name": "ElevenLabs", "sub": "Eleven v4, over the API", "vo": "vo-e", "made": 1.97, "speech": "5.1",     "tags": "acted", "clone": "your voices", "words": "from the API", "cost": "credits", "hi": "$cue"},    {"id": "end", "type": "text", "text": "Pick one per sound.", "x": "50%", "y": 140, "size": 84, "weight": 700,     "at": "vo-e.out+0.35", "dur": 3.2, "enter": {"name": "rise", "dur": 0.5}},    {"id": "end-code", "type": "text", "text": "\"backend\": \"kokoro\" | \"chatterbox\" | \"elevenlabs\"", "font": "IBM Plex Mono",     "x": "50%", "y": 240, "size": 34, "weight": 500, "fill": "$key", "at": "end.in+0.4", "until": "end.out",     "enter": {"name": "type", "dur": 1.0}},    {"use": "progress", "id": "progress", "color": "$key", "h": 6}  ]}
terminal
kine voice voice.json   # speak the text, time the words
kine check voice.json   # also mixes and measures it
kine look voice.json --audio
kine render voice.json -o voice.mp4

kine voice speaks a line again only when its text, speaker, rate, backend or options changed, and kine check says when one is missing or out of date. Each sound picks its plugin with backend: Kokoro runs locally and fast, on Python 3.10 to 3.12, and says the line without its tags; Chatterbox runs locally and copies a voice from a clip, and takes tags that it acts only some of the time; ElevenLabs acts on tags through its API and needs a key. Each is pip install kinescript-<name>, and any other engine can be a plugin or a command.

  • voice-18 LUFS, and the music and ambience dip 14 dB under it
  • music-16 LUFS, looped to the end of the scene
  • sfxpeaks at -6 dBFS; leading silence skipped, so it lands on its cue
  • mixbrought to -16 LUFS, peaks under -1.5 dBTP, in MP4, MOV and WebM

Roles and targets are defaults; a top-level mix changes them. A layer's sound moves when the layer moves. Scenes whose sounds have no role are mixed as given.

kine look voice.json --audio

An agent cannot hear the mix, so it reads it. Under the frames, on the same time axis: a lane for each role and one for the mix, each word the voice says, where the music dips, each click on its cue, and any problem in red. The JSON that comes with it gives each sound's loudness before and after the mix.

A contact sheet of eight frames from the voice demo with the sound drawn under them: a voice lane with each spoken word marked, a music lane that dips while the voice speaks, an effects lane with five clicks, and the mix.
The actual output for the voice demo above. The mix measures -16.0 LUFS with a true peak of -1.9 dBTP. Open the image for full size.

Depth on any layer.

3D is drawn by the same Skia renderer as everything else, with no extra dependency. Scenes that use no depth draw exactly as before.

Camera and depth
Any layer takes z, rotateX and rotateY. A scene camera pans, dollies, orbits and rolls with perspective, follows a target, shakes like a handheld camera and pulls focus with depth of field; fixed layers ignore it, and fog fades far layers.
Stages
A group with "sort": "pixel" shows the nearest of its layers at each pixel, so models and cards pass through each other with a clean edge. With "shadows" its models cast shadows on the floor, the walls and each other.
Lit models
Cubes (rounded with r), spheres, cylinders, cones, capsules, tori and planes, a lathe turned from a profile and a tube along a path, or several merged as parts that animate. repeat draws copies of a model, each placed and coloured by an expression of its number. Materials take shine, metal, rim, toon bands, glow and transparency. Lights come as presets or your own amounts, with up to three more lights and an env to reflect; edges draws outlines and creases.
Textures
An image, a video that plays on the model, or a group of layers drawn on it at each frame's time, wrapped round any shape with repeat and offset.
Extruded text and paths
Letters in any font, shaped as a text layer is so Arabic joins and right-to-left runs read in order, and SVG path data, filled or stroked, pushed out to a depth with an optional bevel.
Model files
.glb, .gltf and .obj with their colours, textures, emission, transparency, metal and roughness, and a soft ground shadow under any model. animation plays a glTF file's clips, skins and morph targets on the layer's clock. Meshopt-compressed files are read as they are, Draco-compressed ones with the draco extra.

Triangles are sorted by depth within each model rather than drawn through a depth buffer: separate layers hide each other per pixel only inside a stage, and parts of one model that cut through each other meet at a stepped seam.

Card flip

Each card is a group turned by rotateY, and its two sides hide their backs with "backface": "hidden". Cards two and three start at card-1.in+0.25 and card-2.in+0.25, so the flips are staggered by reference.

0.0 s
  1. bg
  2. title
  3. card-1
  4. card-2
  5. card-3
  6. how
card-flip.json1920 x 1080, 6 s
{  "canvas": {"w": 1920, "h": 1080, "fps": 30, "duration": 6, "bg": "#0a0c10"},  "grade": {"vignette": 0.3},  "camera": {"z": [[0, 260], ["end", 0]], "rotateY": [[0, -18], ["end", 14]], "rotateX": [[0, 12], ["end", 4]]},  "layers": [    {"id": "bg", "type": "shader", "preset": "score", "opacity": 0.5, "fixed": true},    {"id": "title", "type": "text", "text": "Change a line. Flip a card.", "x": "50%", "y": "19%", "size": 80,     "weight": 700, "fixed": true, "at": 0.2, "enter": {"name": "left", "dist": 28, "dur": 0.32}},    {"id": "card-1", "type": "group", "x": "20%", "y": "54%", "at": 0.4, "enter": {"name": "push", "dur": 0.6},     "rotateY": [[0.9, 0], [1.7, 180, "outBack"], [3.9, 180], [4.7, 360, "outBack"]],     "children": [       {"id": "card-1-front", "type": "group", "anchor": "center", "w": 500, "h": 290, "radius": 24,        "fill": "#f2f0e8", "backface": "hidden",        "children": [{"id": "card-1-old", "type": "text", "text": "- \"dur\": 2.5", "font": "IBM Plex Mono",                      "size": 52, "weight": 500, "fill": "#0a0c10"}]},       {"id": "card-1-back", "type": "group", "anchor": "center", "w": 500, "h": 290, "radius": 24,        "fill": "#b9f45d", "rotateY": 180, "backface": "hidden",        "children": [{"id": "card-1-new", "type": "text", "text": "+ \"dur\": 4.5", "font": "IBM Plex Mono",                      "size": 52, "weight": 500, "fill": "#0a0c10"}]}     ]},    {"id": "card-2", "type": "group", "x": "50%", "y": "54%", "at": "card-1.in+0.25",     "enter": {"name": "push", "dur": 0.6},     "rotateY": [[0.9, 0], [1.7, 180, "outBack"], [3.9, 180], [4.7, 360, "outBack"]],     "children": [       {"id": "card-2-front", "type": "group", "anchor": "center", "w": 500, "h": 290, "radius": 24,        "fill": "#f2f0e8", "backface": "hidden",        "children": [{"id": "card-2-old", "type": "text", "text": "- \"text\": \"Q3\"", "font": "IBM Plex Mono",                      "size": 52, "weight": 500, "fill": "#0a0c10"}]},       {"id": "card-2-back", "type": "group", "anchor": "center", "w": 500, "h": 290, "radius": 24,        "fill": "#b9f45d", "rotateY": 180, "backface": "hidden",        "children": [{"id": "card-2-new", "type": "text", "text": "+ \"text\": \"Q4\"", "font": "IBM Plex Mono",                      "size": 52, "weight": 500, "fill": "#0a0c10"}]}     ]},    {"id": "card-3", "type": "group", "x": "80%", "y": "54%", "at": "card-2.in+0.25",     "enter": {"name": "push", "dur": 0.6},     "rotateY": [[0.9, 0], [1.7, 180, "outBack"], [3.9, 180], [4.7, 360, "outBack"]],     "children": [       {"id": "card-3-front", "type": "group", "anchor": "center", "w": 500, "h": 290, "radius": 24,        "fill": "#f2f0e8", "backface": "hidden",        "children": [{"id": "card-3-old", "type": "text", "text": "- \"size\": 64", "font": "IBM Plex Mono",                      "size": 52, "weight": 500, "fill": "#0a0c10"}]},       {"id": "card-3-back", "type": "group", "anchor": "center", "w": 500, "h": 290, "radius": 24,        "fill": "#b9f45d", "rotateY": 180, "backface": "hidden",        "children": [{"id": "card-3-new", "type": "text", "text": "+ \"size\": 96", "font": "IBM Plex Mono",                      "size": 52, "weight": 500, "fill": "#0a0c10"}]}     ]},    {"id": "how", "type": "text", "text": "\"rotateY\": [[0.9, 0], [1.7, 180, \"outBack\"]]   \"backface\": \"hidden\"",     "font": "IBM Plex Mono", "x": "50%", "y": "86%", "size": 32, "weight": 400, "fill": "$muted", "fixed": true,     "at": 1.0, "enter": "fade"}  ]}

Camera dolly

Four planes at z 0, -1600, -3200 and -4800. The camera's z is keyframed to hold at each plane, and each plane ends before the camera reaches it.

0.0 s
  1. bg
  2. render
  3. look
  4. check
  5. write
dolly.json1920 x 1080, 9 s
{  "canvas": {"w": 1920, "h": 1080, "fps": 30, "duration": 9, "bg": "#0a0c10"},  "grade": {"vignette": 0.3},  "camera": {"z": [[0, 500], [1, 0, "outCubic"], [2, 0], [3, -1600], [4, -1600], [5, -3200], [6, -3200], [7, -4800]],             "x": [[0, -50], ["end", 50, "linear"]], "y": [[0, 20], ["end", -20, "linear"]]},  "layers": [    {"id": "bg", "type": "shader", "preset": "score", "opacity": 0.45, "fixed": true},    {"id": "render", "type": "group", "x": "50%", "y": "50%", "z": -4800, "allowOffscreen": true,     "children": [       {"id": "render-frame", "type": "rect", "w": 1500, "h": 820, "radius": 28, "stroke": "#f2f0e84d", "strokeWidth": 4,        "opacity": [[4, 0.4], [5, 1]]},       {"id": "render-z", "type": "text", "text": "\"z\": -4800", "font": "IBM Plex Mono", "x": -700, "y": -360,        "anchor": "topleft", "size": 34, "weight": 500, "fill": "$muted", "opacity": [[6.5, 0], [7, 1]]},       {"id": "render-verb", "type": "text", "text": "Render.", "y": -40, "size": 230, "weight": 700,        "opacity": [[6.5, 0], [7, 1]]},       {"id": "render-cmd", "type": "text", "text": "kine render scene.json", "font": "IBM Plex Mono", "y": 150,        "size": 48, "weight": 500, "fill": "$accent2", "opacity": [[6.5, 0], [7, 1]]}     ]},    {"id": "look", "type": "group", "x": "50%", "y": "50%", "z": -3200, "until": 6.5, "allowOffscreen": true,     "children": [       {"id": "look-frame", "type": "rect", "w": 1500, "h": 820, "radius": 28, "stroke": "#f2f0e84d", "strokeWidth": 4,        "opacity": [[2, 0.4], [3, 1]]},       {"id": "look-z", "type": "text", "text": "\"z\": -3200", "font": "IBM Plex Mono", "x": -700, "y": -360,        "anchor": "topleft", "size": 34, "weight": 500, "fill": "$muted", "opacity": [[4.5, 0], [5, 1], [6, 1], [6.25, 0]]},       {"id": "look-verb", "type": "text", "text": "Look.", "y": -40, "size": 230, "weight": 700,        "opacity": [[4.5, 0], [5, 1], [6, 1], [6.25, 0]]},       {"id": "look-cmd", "type": "text", "text": "kine look scene.json --boxes", "font": "IBM Plex Mono", "y": 150,        "size": 48, "weight": 500, "fill": "$accent2", "opacity": [[4.5, 0], [5, 1], [6, 1], [6.25, 0]]}     ]},    {"id": "check", "type": "group", "x": "50%", "y": "50%", "z": -1600, "until": 4.5, "allowOffscreen": true,     "children": [       {"id": "check-frame", "type": "rect", "w": 1500, "h": 820, "radius": 28, "stroke": "#f2f0e84d", "strokeWidth": 4},       {"id": "check-z", "type": "text", "text": "\"z\": -1600", "font": "IBM Plex Mono", "x": -700, "y": -360,        "anchor": "topleft", "size": 34, "weight": 500, "fill": "$muted", "opacity": [[2.5, 0], [3, 1], [4, 1], [4.25, 0]]},       {"id": "check-verb", "type": "text", "text": "Check.", "y": -40, "size": 230, "weight": 700,        "opacity": [[2.5, 0], [3, 1], [4, 1], [4.25, 0]]},       {"id": "check-cmd", "type": "text", "text": "kine check scene.json", "font": "IBM Plex Mono", "y": 150,        "size": 48, "weight": 500, "fill": "$accent2", "opacity": [[2.5, 0], [3, 1], [4, 1], [4.25, 0]]}     ]},    {"id": "write", "type": "group", "x": "50%", "y": "50%", "z": 0, "until": 2.5, "allowOffscreen": true,     "children": [       {"id": "write-frame", "type": "rect", "w": 1500, "h": 820, "radius": 28, "stroke": "#f2f0e84d", "strokeWidth": 4},       {"id": "write-z", "type": "text", "text": "\"z\": 0", "font": "IBM Plex Mono", "x": -700, "y": -360,        "anchor": "topleft", "size": 34, "weight": 500, "fill": "$muted", "opacity": [[0.2, 0], [0.6, 1], [2, 1], [2.25, 0]]},       {"id": "write-verb", "type": "text", "text": "Write.", "y": -40, "size": 230, "weight": 700,        "opacity": [[0.2, 0], [0.6, 1], [2, 1], [2.25, 0]]},       {"id": "write-cmd", "type": "text", "text": "scene.json", "font": "IBM Plex Mono", "y": 150,        "size": 48, "weight": 500, "fill": "$accent2", "opacity": [[0.2, 0], [0.6, 1], [2, 1], [2.25, 0]]}     ]}  ]}

Lit models

Five primitives on one floor, each showing one material key: shine, metal, toon bands, rim light, and a cone with its own dramatic light. Each one builds in over a soft ground shadow.

0.0 s
  1. light
  2. gloss
  3. gloss-key
  4. steel
  5. steel-key
  6. toon
  7. toon-key
  8. rim
  9. rim-key
  10. drama
  11. drama-key
materials.json1920 x 1080, 6 s
{  "canvas": {"w": 1920, "h": 1080, "fps": 30, "duration": 6, "bg": "#f2f0e8"},  "camera": {"rotateX": 14, "rotateY": [[0, -10], ["end", 10]], "z": [[0, 160], ["end", 0]]},  "light": "studio",  "layers": [    {"id": "light", "type": "text", "text": "\"light\": \"studio\"", "font": "IBM Plex Mono", "x": "50%", "y": "17%",     "size": 40, "weight": 500, "fill": "#4a4f5c", "fixed": true, "at": 0.2, "enter": "fade"},    {"id": "gloss", "type": "model", "shape": "sphere", "r": 125, "x": 330, "y": 560, "detail": "high",     "fill": "$accent", "shine": 0.9, "ground": {"opacity": 0.5, "y": 690}, "enter": {"name": "build", "dur": 1.0}},    {"id": "gloss-key", "type": "text", "text": "\"shine\": 0.9", "font": "IBM Plex Mono", "x": 330, "y": 795,     "size": 32, "weight": 500, "fill": "#0a0c10", "at": 0.6, "enter": "fade"},    {"id": "steel", "type": "model", "shape": "sphere", "r": 125, "x": 645, "y": 560, "detail": "high",     "fill": "#e2e4ea", "metal": 0.6, "shine": 0.9, "ground": {"opacity": 0.5, "y": 690}, "at": 0.15,     "enter": {"name": "build", "dur": 1.0}},    {"id": "steel-key", "type": "text", "text": "\"metal\": 0.6", "font": "IBM Plex Mono", "x": 645, "y": 795,     "size": 32, "weight": 500, "fill": "#0a0c10", "at": 0.75, "enter": "fade"},    {"id": "toon", "type": "model", "shape": "torus", "r": 115, "tube": 46, "x": 960, "y": 560, "detail": "high",     "fill": "$accent2", "bands": 3, "rotateX": [[0, 70], ["end", -20]], "rotateY": [[0, 0], ["end", 90]],     "ground": {"opacity": 0.5, "y": 690}, "at": 0.3, "enter": {"name": "build", "dur": 1.0}},    {"id": "toon-key", "type": "text", "text": "\"bands\": 3", "font": "IBM Plex Mono", "x": 960, "y": 795,     "size": 32, "weight": 500, "fill": "#0a0c10", "at": 0.9, "enter": "fade"},    {"id": "rim", "type": "model", "shape": "cube", "w": 180, "x": 1275, "y": 560, "fill": "#242832", "rim": 1,     "rotateX": -20, "rotateY": [[0, 20], ["end", 200]], "ground": {"opacity": 0.5, "y": 690}, "at": 0.45,     "enter": {"name": "build", "dur": 1.0}},    {"id": "rim-key", "type": "text", "text": "\"rim\": 1", "font": "IBM Plex Mono", "x": 1275, "y": 795,     "size": 32, "weight": 500, "fill": "#0a0c10", "at": 1.05, "enter": "fade"},    {"id": "drama", "type": "model", "shape": "cone", "r": 100, "h": 220, "x": 1580, "y": 570, "fill": "#9299a8",     "light": "dramatic", "rotate": [[0, -8], ["end", 8]], "ground": {"opacity": 0.5, "y": 690}, "at": 0.6,     "enter": {"name": "build", "dur": 1.0}},    {"id": "drama-key", "type": "text", "text": "\"light\": \"dramatic\"", "font": "IBM Plex Mono", "x": 1580, "y": 795,     "size": 32, "weight": 500, "fill": "#0a0c10", "at": 1.2, "enter": "fade"}  ]}

Extruded title

The brand mark as a stroked SVG path pushed out to 56 px with a sphere for the cue dot, merged as parts of one model, and the wordmark as extruded text with a 3 px bevel.

0.0 s
  1. mark
  2. word
{  "canvas": {"w": 1920, "h": 1080, "fps": 30, "duration": 5, "bg": "#0a0c10"},  "camera": {"rotateY": [[0, 8], ["end", -6]], "rotateX": [[0, -4], ["end", 2]]},  "light": "studio",  "layers": [    {"id": "mark", "type": "model", "x": "50%", "y": "36%",     "rotateY": [[0, -70], [2.2, 0, "outCubic"]], "rotateX": [[0, 25], [2.2, 0, "outCubic"]],     "enter": {"name": "build", "dur": 1.2},     "parts": [       {"shape": "path", "d": "M52 36L52 164M150 36C117 36 117 100 84 100C117 100 117 164 150 164",        "strokeWidth": 18, "h": 380, "depth": 56, "bevel": 4, "fill": "#f2f0e8", "shine": 0.5},       {"shape": "sphere", "r": 44, "x": -44, "z": 74, "fill": "$accent", "shine": 0.8}     ]},    {"id": "word", "type": "model", "shape": "text", "text": "kinescript", "x": "50%", "y": "77%", "size": 190,     "weight": 700, "letterSpacing": -4, "depth": 46, "bevel": 3, "fill": "#f2f0e8", "shine": 0.5, "at": 0.6,     "rotateX": [[0, 70], [2, 0, "outCubic"]], "enter": {"name": "build", "dur": 1.2}}  ]}

A glTF model

A rocket read from a .glb file with 15,264 triangles and its own colours. It builds in from the bottom and turns over its ground shadow. examples/make_media.py writes the file.

0.0 s
  1. rocket
  2. label
{  "canvas": {"w": 1920, "h": 1080, "fps": 30, "duration": 6, "bg": "#f2f0e8"},  "camera": {"rotateX": 16, "rotateY": [[0, -10], ["end", 10]]},  "light": "studio",  "layers": [    {"id": "rocket", "type": "model", "src": "media/rocket.glb", "x": "50%", "y": "46%", "h": 600,     "rotateY": [[0, -30], ["end", 330]], "rotate": [[0, 0], [3, 5, "inOutSine"], ["end", 0, "inOutSine"]],     "ground": {"opacity": 0.6}, "enter": {"name": "build", "dur": 1.6}},    {"id": "label", "type": "text", "text": "media/rocket.glb", "x": "50%", "y": "92%", "size": 34, "weight": 500,     "fill": "#0a0c10", "fixed": true, "at": 1.2, "enter": "fade"}  ]}

Product turntable

A can read from a .glb file, its label a texture inside the file, turns on a table in a stage. "shadows" puts its shadow on the table, env gives the steel lid something to reflect, and aperture with "focus": 110, the can's front, keeps its label sharp while two more cans blur behind it. examples/make_media.py writes the file.

0.0 s
  1. stage
  2. title
{  "canvas": {"w": 1920, "h": 1080, "fps": 30, "duration": 6, "bg": {"radial": [0.5, 0.3], "stops": ["#2a2e38", "#0b0c10"]}},  "camera": {"rotateX": 12, "y": -20, "aperture": 22, "focus": 110},  "light": {"preset": "studio", "follow": "world", "azimuth": -40, "elevation": 42},  "env": "studio",  "layers": [    {"id": "stage", "type": "group", "x": "50%", "y": "50%", "sort": "pixel", "shadows": {"opacity": 0.5, "soft": 4},     "children": [      {"id": "floor", "type": "rect", "y": 261, "z": -600, "w": 4200, "h": 2400, "rotateX": 90, "fill": "#1d2129"},      {"id": "turntable", "type": "model", "shape": "cylinder", "r": 300, "h": 24, "y": 249, "fill": "#e8e2d4",       "shine": 0.25, "rotateY": [[0, 0], ["end", 300]]},      {"id": "can", "type": "model", "src": "media/can.glb", "h": 470, "rotateY": [[0, -30], ["end", 270]]},      {"id": "left", "type": "model", "src": "media/can.glb", "h": 470, "x": -760, "y": 26, "z": -1300, "rotateY": 40},      {"id": "right", "type": "model", "src": "media/can.glb", "h": 470, "x": 820, "y": 26, "z": -1600, "rotateY": -70}]},    {"id": "title", "type": "text", "text": "one glTF file, its label a texture, turning on a table",     "x": "50%", "y": "92%", "size": 34, "weight": 400, "fill": "$muted", "fixed": true, "at": 0.4, "enter": "fade"}  ]}

Checked without watching.

An agent cannot watch a video. Each command returns something it can read: one image of many frames, a JSON list of problems, the box of every layer at a given time.

kine look card-flip.json --boxes

Frames spread over the scene in one image, each with its time and frame number. With --boxes every layer is outlined and the top-level ones are named.

A contact sheet of eight frames from the card flip demo. Each frame is labelled with its time and frame number, and each layer is outlined in green with its id.
The actual output for the card flip demo above, eight frames. Open the image for full size.

kine check problems.json

Every problem at once, errors first, each with its layer, time range and a fix. Contrast is measured against the pixels actually behind the text. When the scene has sound it mixes and measures it too, and reports a voice cut off or masked by the music, an effect that lands late, and a mix off its loudness target. The command exits with status 1 while there are errors.

problems.jsonfour problems
{  "canvas": {"w": 1920, "h": 1080, "fps": 30, "bg": "#f2f0e8"},  "layers": [    {"id": "panel", "type": "rect", "x": "50%", "y": "62%", "w": 1300, "h": 260, "radius": 20, "fill": "#ff633f"},    {"id": "title", "type": "text", "text": "Quarterly results", "x": "50%", "y": "6%", "size": 120,     "fill": "#0a0c10", "dur": 4},    {"id": "label", "type": "text", "text": "Orders up 18 percent on last year", "x": "50%", "y": "62%",     "size": 60, "fill": "#ff9a80", "dur": 1.2},    {"id": "note", "type": "text", "text": "Source: annual report", "x": "50%", "y": "88%", "fontSize": 40,     "fill": "#4a4f5c", "dur": 4}  ]}
output
{
  "ok": false,
  "errors": 1,
  "warnings": 2,
  "issues": [
    {
      "level": "error",
      "code": "text-offscreen",
      "layer": "title",
      "from": 0.0,
      "to": 4.0,
      "message": "text box [469.7, -6.0, 1450.3, 135.6] leaves the 1920x1080 canvas",
      "fix": "move it inward, lower size, or set maxWidth"
    },
    {
      "level": "warning",
      "code": "read-time",
      "layer": "label",
      "from": 0.0,
      "to": 1.2,
      "message": "7 words on screen for 1.20s; viewers need ~2.5s",
      "fix": "extend dur or cut words"
    },
    {
      "level": "warning",
      "code": "contrast",
      "layer": "label",
      "from": 0.72,
      "to": 0.72,
      "message": "contrast against what is behind it is 1.4:1 (want 4.5:1); it overlaps 'panel'",
      "fix": "move it clear of 'panel', or add a box or a shadow"
    },
    {
      "level": "info",
      "code": "renamed-key",
      "layer": "note",
      "message": "'fontSize' read as 'size'",
      "fix": "write 'size'"
    }
  ],
  "duration": 4.0
}

kine probe card-flip.json --at 1.5

Each layer's start and end, and at a given time, where every visible layer is on screen in pixels. Trimmed here to four layers: the title, the back of the first card, which has turned past halfway, and the text on the second card.

{
  "at": 1.5,
  "visible": {
    "title": {
      "rect": [467, 158, 1453, 252],
      "alpha": 1.0,
      "type": "text",
      "scale": 1.0,
      "text": "Change a line. Flip a card."
    },
    "card-1-back": {
      "rect": [235, 464, 657, 762],
      "alpha": 1.0,
      "type": "group",
      "scale": 0.9
    },
    "card-1-new": {
      "rect": [292, 570, 598, 651],
      "alpha": 1.0,
      "type": "text",
      "scale": 0.9,
      "text": "+ \"dur\": 4.5"
    },
    "card-2-old": {
      "rect": [774, 540, 1134, 614],
      "alpha": 1.0,
      "type": "text",
      "scale": 1.1,
      "text": "- \"text\": \"Q3\""
    }
  }
}

MCP

kine mcp serves the same tools to MCP clients over stdio. Tools take a scene as a file path or as the JSON object itself, and the look and still tools return the image inline.

MCP client config
{
  "mcpServers": {
    "kinescript": {"command": "kine", "args": ["mcp"]}
  }
}
  • kine_docsthe scene reference
  • kine_checkproblems as JSON
  • kine_looka contact sheet, as an image
  • kine_stillone frame, as an image
  • kine_probetimeline and layer boxes
  • kine_rendera draft, a range or the final file
  • kine_voicespeaks a scene's text and times its words

kine docs prints the whole scene reference, about 400 lines: rules, a complete scene, every layer type with its defaults, and recipes. It is written to fit in a model's context.

Fast on a GPU.

Skia draws on the GPU through a hidden OpenGL context, one per worker. Frames are split into chunks, encoded in parallel and joined without re-encoding, while another ffmpeg process mixes the audio.

  • 2.0 msmedian to draw one 1080p frame of the launch video and read it back
  • 290frames per second, rendering the launch video at 1080p with audio
  • 3Python dependencies: skia-python, numpy and glfw, plus ffmpeg

Measured on an Intel Core Ultra 7 265 with an NVIDIA RTX 5060 Ti, Windows 11, on 2026-10-31. Without a GPU, Kinescript falls back to Skia's CPU rasteriser, which is much slower; the README has the numbers.

Install.

terminal
pip install kinescript
kine doctor

Needs Python 3.10 or newer and ffmpeg on your PATH. kine doctor prints the ffmpeg path, whether a GPU context opened, and the default font. Space Grotesk and IBM Plex Mono ship with the package, so default text looks the same on every machine. For voice-over from text, add a voice plugin: kinescript-kokoro, kinescript-chatterbox or kinescript-elevenlabs.

a first scene
kine new scene.json
kine check scene.json
kine look scene.json --boxes
kine render scene.json -o scene.mp4

More scenes in examples/. The README covers every feature and the limits.