# stingo > Declarative video for people who ship content. A video is a text file: a > script says what you are talking about, a taste profile says how it looks and > moves, and stingo renders an MP4 with every cut landing on a downbeat. It > also composites recorded takes, so an explainer can contain the explainer. "Stingo" is Sheng — the Swahili-English creole spoken in Nairobi — for *aesthetics*, which is the central idea: the look of a film is a named thing that lives in its own file and can be swapped without touching the script. stingo is a Bun and TypeScript library and CLI. It renders with satori and resvg (no browser, no headless Chrome) and encodes with ffmpeg. A frame is a pure function of its index, which is what makes scrubbing instant, rendering parallel, and output deterministic. The project is written and maintained entirely by Claude, Anthropic's coding model, working from direction by Hersi (github.com/aynaash). Every page below is also served as HTML at the same path without the .md. --- # Connect your AI > Give an agent the tools to write a script, render a frame, and look at what it made. > Source: https://aynaash.github.io/stingo/mcp.md The fastest way to make a video with stingo is to not write one. Connect an agent, describe what you want, and let it write the script — then **render a frame and look at it**. That last part is the whole reason this works. A model writing video it never sees produces scripts that validate and read badly: a headline that wrapped, a chart bar invisible against its background, a scene over before anyone could read it. `stingo_still` returns the PNG itself, so the model can check. ## Connect it Point your agent at the published package: ```json { "mcpServers": { "stingo": { "command": "bunx", "args": ["--bun", "--package=@hersidev/stingo", "stingo-mcp"] } } } ``` `--package` is needed because the binary and the package have different names: npm would not accept `stingo` as a package name, so the library publishes under a scope while the commands stay `stingo` and `stingo-mcp`. Working in a clone of the repository instead? `.mcp.json` is already there and Claude Code picks it up, no install required. It speaks MCP over stdio. ffmpeg still has to be on the `PATH`. [`AGENTS.md`](https://github.com/aynaash/stingo/blob/main/AGENTS.md) in the repository is written for the agent itself — the loop that works, and the mistakes worth not making (never put a colour in a script, do not set `dur` first, look at frames). ## The loop it is built for 1. `stingo_docs` — read how a field actually behaves instead of guessing 2. `stingo_validate` — catch schema errors instantly 3. `stingo_plan` — check the pacing before spending minutes 4. `stingo_still` — render one frame **and look at it** 5. `stingo_render` — only once the frames read correctly Steps 3 and 4 are the ones that matter. They cost a second or two and catch the things a schema cannot: a line too long for the frame, a chart whose highlighted bar is invisible, a scene that is over before it can be read. ## Tools | Tool | | |---|---| | `stingo_docs` | fetch a documentation page as markdown | | `stingo_blocks` | the registered scene types and what they take | | `stingo_validate` | parse a script; report the resolved document or exact errors | | `stingo_plan` | the resolved timeline, in seconds and in beats | | `stingo_still` | render one frame, **returned as an image** | | `stingo_render` | render the MP4 | | `stingo_takes` | inspect camera takes: resolution, fps, length, audio | | `stingo_beats` | tempo, downbeat, onsets, loudness of a track | | `stingo_tastes` | list built-in taste profiles | | `stingo_derive_taste` | derive a full profile from one brand colour | | `stingo_audit_taste` | check a profile against the contrast floors | Every tool that takes a script accepts either `path` (a file) or `source` (inline YAML). Inline source is written to a scratch file inside `baseDir`, so relative references — `./taste.json`, `takes/01.mp4` — resolve exactly as they would on disk. ## Guards worth knowing `stingo_render` refuses films longer than ten minutes unless you raise `maxSeconds` deliberately, and defaults to `draft: true`. Rendering is the expensive operation in this system and an agent should be nudged toward stills. `stingo_still` accepts `noCamera: true`, so a script whose footage does not exist yet still produces a frame — with the camera box drawn as a placeholder carrying its source timecode. ## Example ``` stingo_still { source: | title: From an agent canvas: { preset: horizontal, fps: 30 } taste: bootdev scenes: - block: stat value: "7x" label: faster sub: Cropping before scaling, not after. at: 2.0 } ``` Returns the frame as a PNG, plus which scene it landed in. ## Machine-readable documentation Outside MCP, the documentation is published for language models directly: - [`/llms.txt`](https://aynaash.github.io/stingo/llms.txt) — an index, in the llms.txt convention - [`/llms-full.txt`](https://aynaash.github.io/stingo/llms-full.txt) — every page inlined, one fetch - every page as markdown at its own path, e.g. [`/camera.md`](https://aynaash.github.io/stingo/camera.md) --- # Getting started > Install it, render the example, and change one line to see what happens. > Source: https://aynaash.github.io/stingo/start.md stingo turns a text file into an MP4. This page gets one rendering on your machine, then changes a line so you can see the loop it is built around. ## What you need **Bun 1.3 or newer** and **ffmpeg 6 or newer** on your `PATH`. ffmpeg is not optional — it decodes footage, mixes audio and encodes the video. ```bash bun --version ffmpeg -version | head -1 ``` Bun is a hard requirement rather than a preference: the pipeline drives ffmpeg through `Bun.spawn` and reads files through `Bun.file`. ## Install it ```bash bun add @hersidev/stingo ``` That gives you the library and two commands, `stingo` and `stingo-mcp`. The package is scoped because npm refused the bare name — *stingo* is two letters from *string*, so it read as typosquatting. The commands are unaffected. ## Your first film ```yaml # film.yaml title: It works canvas: { preset: vertical, fps: 30 } taste: bootdev scenes: - block: title kicker: first render text: It works sub: That is the whole file. - block: stat value: 33ms label: per frame ``` ```bash bunx stingo render film.yaml ``` Fonts ship with the package, so there is nothing else to install. Every key above `scenes:` is optional — they are spelled out here so you can see where the shape, the frame rate and the look are set. ## Or run the example from a clone The repository carries a five-minute example film and everything used to make the launch video, which is more interesting than a two-scene script: ```bash git clone https://github.com/aynaash/stingo cd stingo && bun install bun stingo render examples/goroutines/video.yaml ``` That writes `out/video.mp4` — about five minutes of vertical video, cut to a 128.9 BPM track. The first render is the slow one: fonts load, the syntax highlighter warms up, and every frame is drawn from scratch. Add `--draft` while you are iterating. It drops the encoder to `ultrafast` and the quality to `crf 30`, which is unwatchable for publishing and perfectly fine for checking a layout. ## See the cut before you render it Rendering thousands of frames to find out a scene is too short is a waste. `plan` resolves the whole timeline and prints it, rendering nothing: ```bash bun stingo plan examples/goroutines/video.yaml ``` ``` 0 title 0:00→0:04 4.17s (8.9 beats) title-0 1 statement 0:04→0:07 3.72s (8.0 beats) statement-1 2 stat 0:07→0:11 3.72s (8.0 beats) stat-2 ``` Every scene is a whole number of beats because the taste profile says `cutOn: bar`, so scene ends snap to the detected downbeat grid. ## Look at one frame ```bash bun stingo still examples/goroutines/video.yaml --at 45 -o frame.png ``` A still goes through the same pixel path as a render — same fonts, same texture pass — so what you see is what the video will contain. This is the fastest way to check a layout. ## Change something Open `examples/goroutines/video.yaml` and find the first scene: ```yaml - block: title kicker: concurrency text: Your program is waiting sub: Most of the time, it is doing nothing at all. bg: { kind: beams, opacity: 0.6, speed: 0.8 } ``` Change `text`, then render a still of it: ```bash bun stingo still examples/goroutines/video.yaml --at 2.5 -o frame.png ``` No timeline to nudge, no keyframe to find. That loop — edit a line, look at a frame — is the entire premise. ## Change how it looks The script above contains no colours. Those live in a separate file, so you can swap the whole look without touching a word of content: ```bash bun stingo still examples/goroutines/video.yaml --at 23 --taste dusk -o dusk.png bun stingo still examples/goroutines/video.yaml --at 23 --taste bootdev -o boot.png ``` Or derive a fresh profile from a single brand colour: ```bash bun stingo taste "#ff7a18" --name Ember --mood bouncy --save ember.json bun stingo still examples/goroutines/video.yaml --at 23 --taste ember.json -o ember.png ``` `stingo taste` does not just tint things. It places your hue on a measured lightness ramp, bleeds a trace of it into the neutrals, and lifts any colour that fails the contrast floors — it will tell you when it does. ## Scrub it live ```bash bun stingo preview examples/goroutines/video.yaml ``` Serves a scrubbable preview on `localhost:4321` that re-plans when you save the file. Because any frame is one function call, seeking is not a render — it is a lookup. ## Write your own The smallest document that renders: ```yaml scenes: - block: title text: It works sub: That is the whole file. ``` ```bash bun stingo render my.yaml ``` That is the whole file. `title` defaults, the canvas defaults to vertical at 30fps, and `taste` defaults to the house profile — so every line above the `scenes:` key in the earlier example was optional. Scene length is inferred from content and clamped by the taste's pacing, so you can leave `dur` off until a scene actually feels wrong. ## Check it before you render it ```bash bun stingo doctor my.yaml ``` One pass over everything a render needs: ffmpeg, the fonts your taste asks for, the taste itself, the music track, every camera take, every image, the timing, and whether your words fit their frames and their scenes. It reports all of it at once and names a fix for each failure, so you are not discovering one problem per render. ```bash bun stingo sheet my.yaml -o sheet.png ``` And once there is more than a handful of scenes, a contact sheet — one frame from the middle of each, in a grid. Nobody can hold a six-minute video in their head, and scrubbing only ever shows you one moment at a time. ## Where to go next - [The script](script) — every field of a video document - [Blocks](blocks) — the fourteen scene types - [Taste profiles](taste) — palette, motion, pacing, texture - [Talking head](camera) — putting yourself in the frame ## The examples Four in the repo, each answering a different question. | | | |---|---| | `examples/quickstart` | the smallest thing that renders — a film written in TypeScript | | `examples/goroutines` | a full explainer: 50-odd scenes, code, charts, a diagram, cut to a track | | `examples/demo` | the fifty-second demo on the front of this site, narrated | | `examples/building-stingo` | a talking-head film, with a shot list and a recording guide | `quickstart` and `goroutines` render with nothing but the repo. `demo` and `building-stingo` need takes you record — each has a README saying exactly what to shoot, and `tools/ingest.ts` turns a folder of phone clips into the files their scripts name. --- # The script > Every field of a video document, and how a scene gets its length. > Source: https://aynaash.github.io/stingo/script.md A video document is YAML, JSON, or a TypeScript module. It says what the film is called, what shape it is, which taste profile it wears, what it sounds like, whether it is captioned — and, the bulk of it, the scenes. ```yaml title: Concurrency in Go, in five minutes canvas: { preset: vertical, fps: 30 } taste: ./taste.json audio: music: assets/music/loop128.mp3 bpm: auto scenes: - block: title text: Your program is waiting ``` ## title The film's name. Shown by `plan` and `render`, and used for the default output filename. Defaults to `untitled`. ## canvas ```yaml canvas: { preset: vertical, fps: 30 } canvas: { width: 1440, height: 1080, fps: 24 } ``` | Field | Type | Default | | |---|---|---|---| | `preset` | `vertical` · `horizontal` · `square` | — | 1080×1920, 1920×1080, 1080×1080 | | `width` | integer | `1080` | overrides the preset | | `height` | integer | `1920` | overrides the preset | | `fps` | integer | `30` | | Orientation is derived from the dimensions, not declared, and every block sizes itself from it. The same script renders to any shape without a second layout. `--vertical`, `--horizontal` and `--square` override the canvas per command, which is how one script produces both a YouTube cut and a reel. ## taste Either the name of a built-in profile, a path to a taste file, or a profile written inline. ```yaml taste: bootdev # built-in taste: ./taste.json # relative to this document ``` See [taste profiles](taste). `--taste` overrides it per command. ## audio ```yaml audio: music: assets/music/loop128.mp3 bpm: auto musicGainDb: -19 vo: narration.m4a ``` | Field | Type | Default | | |---|---|---|---| | `music` | path | — | the bed; also sets the beat grid | | `bpm` | number · `auto` | `auto` | `auto` detects it | | `musicGainDb` | number | `-18` | lower it when there is speech | | `vo` | path | — | a narration track for the whole film | Paths resolve next to the document first, then against the working directory. Pointing at music does two things: it mixes the bed, and it sets the grid that scene ends snap to. Run `stingo beats ` to see what was detected before committing to a render. Music is looped or trimmed to length, faded at both ends, ducked under any speech by a sidechain compressor, and normalised to the taste's target loudness — −14 LUFS by default, which is what the platforms want. ## captions ```yaml captions: enabled: true burn: true ``` | Field | Type | Default | | |---|---|---|---| | `enabled` | boolean | `false` | off unless you ask for it | | `burn` | boolean | `true` | draw them into the frame | | `style` | `word` · `line` | `word` | see below | Captions come from the `say` field on each scene — there is no second script to keep in sync. Words are spread across the scene at the taste's `wordsPerMinute`, grouped into short chunks, and drawn above the platform safe area with the word being spoken picked out. `style: word` shows a few words at a time and picks out the one being spoken — the short-form look, and what you want when the timing comes from text you wrote. `style: line` is a plain subtitle: a whole readable line at once, no highlight. Use it over a recorded take, where the timing is estimated and a highlight landing on the wrong word is worse than no highlight at all. This is **estimated timing, not forced alignment**. It tracks text-to-speech generated from the same words; against a recorded take it will drift. Whenever `enabled` is set, `.srt` and `.vtt` sidecars are written beside the MP4 — burned in or not, because a burned-in caption is invisible to search. If no scene carries a `say`, `render` says so and writes nothing. From TypeScript, `.captions()` on the film builder turns them on. ## scenes An ordered list. Every scene declares its `block`, and every block accepts these in addition to its own fields: | Field | Type | | |---|---|---| | `block` | string | which renderer draws it — see [blocks](blocks) | | `id` | string | a stable name, shown in `plan` | | `dur` | time | override the inferred length | | `at` | time | pin an absolute start instead of following the previous scene | | `say` | string | narration text; drives the length estimate | | `bg` | b-roll | the animated background layer | | `enter` / `exit` | animation | override the taste's motion for this scene | | `camera` | camera | a recorded take — see [talking head](camera) | | `cut` | `free` · `beat` · `bar` | override the taste's snapping for this scene | | `plain` | boolean | drop the art direction for this scene — see below | | `note` | string | a comment that travels with the scene | ### Plain scenes ```yaml - block: statement plain: true text: A video is a text file ``` `plain: true` removes the art direction from one scene: no anchored composition, no rule bleeding off the edge, no corner marks, no scene numeral. The block gets the whole frame and nothing else is drawn. That furniture exists to orient someone travelling through a film. In a frame that is not part of one — a thumbnail, a title card, a still you are exporting for a blog post — it is clutter, and a scene numeral is actively wrong. See [the thumbnails example](https://github.com/aynaash/stingo/tree/main/examples/building-stingo/thumbnails). ### Time values Anywhere a duration is accepted: | Form | Means | |---|---| | `4` | four seconds | | `"4s"` | four seconds | | `"8b"` | eight beats, at the detected tempo | | `"2bar"` | two bars | | `"1:30"` | ninety seconds | Musical units are the useful ones. `dur: 8b` stays eight beats whatever tempo the track turns out to be. ## How a scene gets its length In order: 1. **`dur`, if you set one.** Nothing else applies. 2. **`say`, if present.** Word count at the taste's `wordsPerMinute`, plus a breath. 3. **The block's default,** raised by what is actually on screen — lines of code, number of list items, length of terminal output. Then it is clamped to the taste's `sceneMin` and `sceneMax`, and the scene *end* is snapped to the grid, so cuts land on the music rather than wherever the text happened to run out. Two exceptions, both about speech: - A `camera` scene takes its length from the take and is **not** clamped — pacing bounds exist to stop a caption lingering, not to cut a sentence short. - A `camera` scene cuts `free` by default, because snapping speech to a downbeat clips the last word. Set `cut: bar` to opt back in. ## B-roll Every scene sits on an animated, procedurally generated background. No footage, deterministic from a seed. ```yaml bg: { kind: orbits, opacity: 0.55, speed: 0.8, seed: 3 } ``` | Field | Type | Default | | |---|---|---|---| | `kind` | see below | `grid` | which generator | | `seed` | integer | `1` | same seed, same background, always | | `speed` | number | `1` | | | `density` | 0–2 | `1` | | | `opacity` | 0–1 | `0.5` | | | `color` / `color2` | hex | palette | override the palette for this layer | | `drift` | number | `0.04` | slow push-in across the scene | `grid` · `dots` · `waves` · `particles` · `codeRain` · `orbits` · `mesh` · `beams` · `terrain` · `pulse` · `noise` · `none` Leave `bg` off and each block picks a sensible default, so a scene never sits on flat colour. ## Your own media behind a scene Give `bg` a `src` and it plays your footage or your picture instead of a generator. Everything else about the scene is unchanged. ```yaml - block: title text: Shipping on Friday bg: { src: ./b-roll/office.mp4, from: 4s, scrim: 0.6 } - block: statement text: The numbers after one week. bg: { src: ./shots/dashboard.png, fit: cover, zoom: 1.2 } ``` | Field | Type | Default | | |---|---|---|---| | `src` | path | — | a clip or a still; setting it ignores `kind` | | `from` | time | `0` | in-point within the clip | | `fit` | `cover` · `contain` | `cover` | crop to fill, or letterbox | | `zoom` | 1–4 | `1` | push in past the fit | | `offsetX` / `offsetY` | −1–1 | `0` | pan, as a fraction of the frame | | `mirror` | boolean | `false` | | | `scrim` | 0–1 | `0.55` | darken it so type stays readable | | `loop` | boolean | `true` | repeat a clip shorter than the scene | A still and a clip take the same path — a picture is a one-frame clip that holds, which is what a backdrop wants anyway. `loop: false` freezes on the last frame instead of repeating. **On `scrim`.** A taste profile guarantees contrast between the colours it chooses, and it cannot make that promise about your footage — stingo has no idea what is in the frame. So the scrim defaults high rather than to nothing. Turn it down once you have looked at a still: ```bash stingo still script.yaml --at 3 -o check.png ``` **Cost.** A backdrop is composited into the rasterised frame rather than placed in the SVG, so it is *cheaper* than a procedural background, not dearer — measured at 1080×1920, an image backdrop is about 138 ms a frame against 192 ms for the `grid` generator. That is the opposite of the `image` *block*, which does go through the SVG and pays for it. ## Entrance and exit ```yaml enter: { kind: rise, delay: 0.1 } exit: { kind: fade } ``` `fade` · `rise` · `fall` · `pop` · `slideL` · `slideR` · `wipe` · `typewriter` · `blur` · `none` Duration and easing come from the taste's motion personality unless you set `dur` or `ease` here. Usually you should not — that is what makes a film hold together. ## Writing it in TypeScript A document can be a module that default-exports a film. You get types, loops, and real composition instead of hand-written YAML: ```ts import { film, title, code, list, outro } from '@hersidev/stingo'; const MISTAKES = [ 'A goroutine nobody receives from leaks forever', 'Unsynchronised writes are a race, not a visible bug', ]; export default film('Concurrency in Go') .vertical() .taste('bootdev') .music('assets/music/loop128.mp3', { bpm: 'auto' }) .add( title('Your program is waiting').kicker('concurrency'), code('go', 'go download("a.txt")') .highlight(1) .caption('This runs concurrently.'), // scenes are ordinary values, so ordinary code composes them list(...MISTAKES).title('Two ways to get hurt'), outro('Now go write something concurrent').handle('@stingo'), ); ``` Every block has a constructor — `title`, `statement`, `code`, `terminal`, `stat`, `list`, `chart`, `diagram`, `image`, `quote`, `compare`, `broll`, `camera`, `outro` — and the shared scene options (`dur`, `at`, `id`, `say`, `bg`, `enter`, `exit`, `camera`, `cut`) are methods on all of them. ```bash bun stingo render film.ts ``` --- # Blocks > The fourteen scene types, what each one takes, and what it looks like. > Source: https://aynaash.github.io/stingo/blocks.md A block is a kind of scene: a title card, a code window, a chart, a diagram. Fourteen ship with stingo, and adding a fifteenth is one file. ```bash stingo blocks # what is registered, with every field stingo blocks chart # just one ``` ## The set Every frame below is rendered from the real pipeline by `bun run tools/gallery.ts`, so the gallery cannot drift from what the code does. Captions are an overlay rather than a block — any scene with a `say` field gets them: ## Every block ### title A headline with an optional kicker and subtitle.
A headline with an optional kicker and subtitle.
| field | type | default | |---|---|---| | `text` | `string` | **yes** | | `kicker` | `string` | — | | `sub` | `string` | — | | `align` | `left` \| `center` | `"center"` | 4.5s by default. Background defaults to `beams`. ```yaml scenes: - block: title kicker: concurrency text: Goroutines are not threads sub: And that difference is the whole point. ``` ### statement One sentence, large, with optional words picked out in the accent.
One sentence, large, with optional words picked out in the accent.
| field | type | default | |---|---|---| | `text` | `string` | **yes** | | `emphasis` | array of `string` | `[]` | 4s by default. Background defaults to `mesh`. ```yaml scenes: - block: statement text: Waiting is not working. emphasis: [ working. ] ``` ### code Syntax-highlighted source in a window frame, revealed line by line.
Syntax-highlighted source in a window frame, revealed line by line.
| field | type | default | |---|---|---| | `lang` | `string` | `"ts"` | | `code` | `string` | — | | `file` | `string` | — | | `highlight` | array of `number` | `[]` | | `caption` | `string` | — | | `reveal` | `all` \| `lines` \| `typewriter` | `"lines"` | 8s by default, longer as the content grows. Background defaults to `grid`. ```yaml scenes: - block: code lang: go code: |- func main() { ch := make(chan string) go func() { ch <- "done" }() fmt.Println(<-ch) } highlight: [ 4, 5 ] caption: go starts it; the channel says when it finished. ``` ### terminal A shell session: commands type themselves in, output follows.
A shell session: commands type themselves in, output follows.
| field | type | default | |---|---|---| | `lines` | array of `{ prompt, cmd, out, delay }` | **yes** | | `title` | `string` | `"bash"` | 9s by default, longer as the content grows. Background defaults to `codeRain`. ```yaml scenes: - block: terminal title: bench lines: - prompt: $ cmd: go run serial.go out: processed 200 jobs in 20.4s - prompt: $ cmd: go run parallel.go out: processed 200 jobs in 1.1s ``` ### diagram Boxes and arrows on an explicit grid — architecture, data flow, state.
Boxes and arrows on an explicit grid — architecture, data flow, state.
| field | type | default | |---|---|---| | `title` | `string` | — | | `nodes` | array of `{ id, label, at, kind, span, note, accent }` | **yes** | | `edges` | array of `{ from, to, label, style, bend, both, accent }` | `[]` | 5s by default, longer as the content grows. Background defaults to `grid`. ```yaml scenes: - block: diagram title: fan out, fan in nodes: - id: main label: main at: [ 0, 0 ] span: 2 accent: true - id: w1 label: worker at: [ 0, 1 ] - id: w2 label: worker at: [ 1, 1 ] - id: ch label: results chan at: [ 0, 2 ] span: 2 kind: queue edges: - from: main to: w1 label: go - from: main to: w2 label: go - from: w1 to: ch - from: w2 to: ch ``` ### chart An animated bar or line chart.
An animated bar or line chart.
| field | type | default | |---|---|---| | `kind` | `bar` \| `line` | `"bar"` | | `title` | `string` | — | | `data` | array of `{ label, value }` | **yes** | | `unit` | `string` | `""` | | `highlightIndex` | `number` | — | 7s by default, longer as the content grows. Background defaults to `grid`. ```yaml scenes: - block: chart kind: bar title: 200 jobs, same machine unit: s data: - label: serial value: 20.4 - label: parallel value: 1.1 highlightIndex: 1 ``` ### stat One big number that counts up, with a label.
One big number that counts up, with a label.
| field | type | default | |---|---|---| | `value` | `string` | **yes** | | `label` | `string` | **yes** | | `sub` | `string` | — | | `countFrom` | `string` | — | 4.5s by default. Background defaults to `pulse`. ```yaml scenes: - block: stat value: 18x label: faster sub: Same CPU. It simply stopped waiting in line. ``` ### list Bulleted points that arrive one at a time.
Bulleted points that arrive one at a time.
| field | type | default | |---|---|---| | `title` | `string` | — | | `items` | array of `string` | **yes** | | `marker` | `num` \| `dot` \| `arrow` \| `check` | `"arrow"` | 7s by default, longer as the content grows. Background defaults to `dots`. ```yaml scenes: - block: list title: Why Sleep fails marker: arrow items: - You are guessing how long work takes - Too short, and you drop results - Too long, and you waste the speedup - It will break on a slower machine ``` ### compare Two columns set against each other.
Two columns set against each other.
| field | type | default | |---|---|---| | `left` | object `{ title, items }` | **yes** | | `right` | object `{ title, items }` | **yes** | 7.5s by default, longer as the content grows. Background defaults to `grid`. ```yaml scenes: - block: compare left: title: OS thread items: - 1 MB stack, reserved up front - Kernel schedules it - Thousands is a lot right: title: goroutine items: - 2 KB stack, grows on demand - Go runtime schedules it - Millions is fine ``` ### quote A pull quote with an attribution.
A pull quote with an attribution.
| field | type | default | |---|---|---| | `text` | `string` | **yes** | | `attrib` | `string` | — | 6s by default. Background defaults to `mesh`. ```yaml scenes: - block: quote text: Do not communicate by sharing memory; share memory by communicating. attrib: Rob Pike ``` ### image A still — screenshot, photo or diagram — with an optional frame and caption.
A still — screenshot, photo or diagram — with an optional frame and caption.
| field | type | default | |---|---|---| | `src` | `string` | **yes** | | `fit` | `contain` \| `cover` | `"contain"` | | `frame` | `none` \| `plain` \| `window` | `"plain"` | | `title` | `string` | — | | `kicker` | `string` | — | | `caption` | `string` | — | | `drift` | `number` | `0.05` | 5s by default. Background defaults to `mesh`. ```yaml scenes: - block: image src: ./diagram.png frame: window title: diagram.png caption: A still, framed like a window. ``` ### broll A breathing beat: background only, with an optional caption.
A breathing beat: background only, with an optional caption.
| field | type | default | |---|---|---| | `caption` | `string` | — | 3.5s by default. Background defaults to `particles`. ```yaml scenes: - block: broll caption: a breathing beat between sections bg: kind: particles opacity: 0.8 ``` ### camera A recorded take composited into the scene — full frame, corner pip, or split.
A recorded take composited into the scene — full frame, corner pip, or split.
| field | type | default | |---|---|---| | `camera` | object `{ src, from, layout, fit, zoom, offsetX, offsetY, mirror, corner, size, aspect, shape, margin, side, ratio, ring, scrim, mute, gainDb }` | **yes** | | `caption` | `string` | — | | `lower` | object `{ name, role }` | — | 8s by default, longer as the content grows, and exempt from the pacing clamp. Background defaults to `none`. ```yaml scenes: - block: camera camera: src: takes/01.mp4 layout: pip lower: name: Your name role: the person explaining ``` ### outro Closing card with a handle or call to action.
Closing card with a handle or call to action.
| field | type | default | |---|---|---| | `text` | `string` | **yes** | | `sub` | `string` | — | | `handle` | `string` | — | 5s by default. Background defaults to `particles`. ```yaml scenes: - block: outro text: Now go write something concurrent sub: Then run it with -race. handle: "@stingo" ``` ## Adding one A block declares its own fields, how long it wants to be on screen, what plays behind it, and how to draw itself. Importing the file is what registers it — there is no list to add yourself to, and nothing central to edit. ```ts // packages/blocks/src/countdown.ts import { z } from 'zod'; import { defineBlock } from './define'; import { box, text } from '@stingo/render'; import { typeStyle } from './ctx'; import { T } from './stage'; import { lifecycle } from './anim'; export default defineBlock({ name: 'countdown', describe: 'A number ticking down to zero.', // block-specific fields. The base fields — id, dur, at, bg, enter, exit, // say, camera, cut — are added for you. fields: { from: z.number().int().default(3), label: z.string().optional(), }, // base is a floor, not a fallback: a content estimate can push past it, // narration replaces it, and the taste's pacing bounds clamp the result. duration: { base: 4, estimate: (s) => 0.8 + s.from * 0.9 }, // what plays behind, when the scene does not say broll: { kind: 'pulse', opacity: 0.5 }, render: (s, c) => { const n = Math.max(0, s.from - Math.floor(c.t)); return box( { width: c.stage.w, height: c.stage.h, flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: c.stage.unit }, text({ ...typeStyle(c, 'display', T.huge(c.stage), c.taste.palette.accent) }, String(n)), s.label ? text({ ...typeStyle(c, 'body', T.body(c.stage), c.taste.palette.muted), ...lifecycle(c.t, c.dur, c.taste, 'fade', 0.2) }, s.label) : null, ); }, }); ``` Add `import './countdown';` to `packages/blocks/src/registry.ts` so the built-in set picks it up, and it works everywhere at once — in YAML, in the planner, in the preview, in `stingo blocks`: ```yaml - block: countdown from: 5 label: until launch ``` ## What `render` receives `render(scene, ctx)` is a **pure function of time**. It must not depend on any previous frame: `framePixels(4821)` is called without frame 4820 ever existing. That is what makes scrubbing, parallel rendering and deterministic output work. `scene` is your fields plus the base ones, already validated and defaulted. `ctx` carries: | | | |---|---| | `t` | seconds since this scene started | | `dur` | how long the scene runs | | `abs` | seconds since the film started | | `stage` | `w`, `h`, `unit`, `padX/padY`, `orientation` — size from these, never from raw pixels | | `taste` | the resolved profile: palette, type, motion, texture | | `grid` | the beat grid, for anything that should land on the music | | `index` | scene number, useful as a deterministic seed | | `family` | resolve a font family name against what is actually loaded | | `fit` | the largest size at or below one you ask for at which text fits a box | | `tokens` | the words that will actually be laid out, already broken to fit | satori will not shrink text to fit, so a block that renders a headline without asking `fit` for a size is a block that can run it off the edge of the frame. Size everything from `stage.unit` and the `T.*` scale. That is what lets one composition render at 1080×1920 and 1920×1080 without a second layout. ## A typed builder The YAML path works as soon as the block is registered. To use it from TypeScript, add a builder beside it: ```ts class CountdownBuilder extends SceneBuilder { label(v: string) { this.s.label = v; return this; } } export const countdown = (from: number) => new CountdownBuilder({ block: 'countdown', from }); ``` ```ts countdown(5).label('until launch').bg('pulse') ``` ## Blocks from outside the repo `defineBlock` is exported from the package, so a block does not have to live here: ```ts import { defineBlock } from '@hersidev/stingo'; export default defineBlock({ name: 'myblock', /* … */ }); ``` Import it before you render, and the Scene schema, the planner and the compositor all pick it up. Names must be unique; registering an existing name replaces it, which is how you override a built-in. --- # Showcase > Every frame stingo makes, generated rather than curated. > Source: https://aynaash.github.io/stingo/showcase.md Everything on this page was rendered by stingo, and regenerated by one command: ```bash bun run tools/gallery.ts ``` Nothing here is a mockup or a screenshot taken by hand. If a frame below looks wrong, that is what the current code produces — which is the point of generating it rather than curating it. ## The demo
Fifty seconds, cut to a 128 BPM track. Unmute it.
## Blocks Fourteen kinds of scene. Full field reference on the [blocks](blocks) page. Captions are an overlay rather than a block — any scene with a `say` field gets them, and the spoken word is picked out as it lands. ## B-roll Eleven generators — plus `none`, which has nothing to show. Deterministic from a seed, no footage required. Set `bg: { kind: … }` on any scene, or give `bg` a `src` and play your own media instead. ## Taste The same scene under three profiles. Only the taste file changed. ## Transitions Five frames spanning a cut. The outgoing scene plays the first half of the move, the incoming scene the second, both pushing the same way.
fade
fade
glitch
glitch
slide
slide
whip
whip
wipe
wipe
## One script, three shapes Blocks size themselves from the stage, so the same scene renders to any canvas without a second layout. Same taste, same scene, three canvases.
horizontal
horizontal
square
square
vertical
vertical
## Talking head Three layouts for a recorded take. See [talking head](camera). ## The films The long examples are too large to serve from this site. They render from the repo in a couple of minutes: ```bash bun stingo render examples/goroutines/video.yaml # 1080x1920 bun stingo render examples/goroutines/video.yaml --horizontal # 1920x1080 ``` Roughly five minutes, 53 scenes, every cut on a bar line. --- # Taste profiles > Palette, type, motion, pacing and texture — and what stingo decides for you. > Source: https://aynaash.github.io/stingo/taste.md A taste profile is the other half of a film. The script says what you are talking about; the taste says what it looks and feels like. Neither mentions the other. ```bash stingo render script.yaml --taste dusk stingo render script.yaml --taste ./ember.json ``` Same script, different film. ## What a taste sets, and what it does not This is the distinction the whole system rests on. A taste declares **intent**: a brand colour, light or dark, a mood, a density. Everything a designer would call a *relationship* — type ratios, spacing rhythm, contrast floors, the lightness ramp of the neutrals, the order things animate in — lives in the house style and is not the taste's to set. That is why two different profiles still look like stingo videos, and why a badly chosen palette still produces a readable one. ## Deriving one You should almost never write a palette by hand: ```bash stingo taste "#ff7a18" --name Ember --mood bouncy --texture film --save ember.json ``` ``` ████ bg #130905 ground ████ text #fef1ea 17.8:1 ████ muted #a9826e 5.7:1 ████ accent #ef7e39 7.2:1 ████ accent2 #2ecaf5 10.2:1 ``` What it actually does with your hex: - Places every neutral on a **measured OKLab lightness ramp**, taken from the reference theme rather than guessed, so derived palettes feel the same. - Bleeds a trace of your hue into those neutrals. That is what makes a palette read as authored rather than as grey with an accent bolted on. - Normalises the accent to a target lightness and chroma, so any brand hue lands with comparable punch instead of whatever you happened to type. - Derives a support hue by rotation, unless you give one with `--support`. - Locks semantic colours to fixed hues, so green reads as success in every taste. - **Lifts anything failing the contrast floors**, and tells you what it moved. The colours on this documentation site are the output of that command. ## palette | Key | | |---|---| | `bg` | the ground everything sits on | | `surface` · `surfaceAlt` | raised panels — code windows, cards | | `border` | hairlines and outlines | | `text` · `muted` | body and secondary text | | `accent` · `accent2` | the brand hue and its support | | `ok` · `warn` · `danger` | hue-locked semantics | ## type ```yaml type: display: { family: JetBrains Mono, weight: 800, tracking: -0.03, lineHeight: 1.05 } body: { family: Inter, weight: 400, lineHeight: 1.45 } mono: { family: JetBrains Mono, weight: 400, lineHeight: 1.55 } scale: 1 ``` `transform` (`none` · `upper` · `lower`) is available per face. `scale` multiplies every size at once. Sizes themselves are not yours to set: they come from one modular ratio applied consistently, which is most of what separates designed typography from arbitrary font sizes. Fonts are read from `assets/fonts` as static `.ttf`/`.otf`. Variable fonts are skipped with a warning — satori's parser cannot read an `fvar` table. ## motion The single knob that most changes how a video feels. | Field | | Default | |---|---|---| | `personality` | `snappy` · `smooth` · `bouncy` · `mechanical` | `snappy` | | `enter` / `exit` | seconds for a standard entrance | `0.45` / `0.3` | | `stagger` | seconds between staggered siblings | `0.07` | | `ease` | easing curve name | `expo.out` | | `travel` | entrance distance in px at 1080 wide | `48` | | `spring` | stiffness, damping, mass — used when `ease: spring` | | Personality maps to a curve and a travel distance, and the mapping is the house's, so `snappy` means the same thing in every profile. ## pacing | Field | | Default | |---|---|---| | `sceneMin` / `sceneMax` | seconds; the planner clamps and warns | `3` / `14` | | `wordsPerMinute` | narration speed, for estimating from `say` | `155` | | `cutOn` | `free` · `beat` · `bar` | `bar` | | `breath` | dead air held after a line lands | `0.35` | `cutOn: bar` is what makes every cut land on a downbeat. Camera scenes ignore it by default — see [talking head](camera). ## texture Cheap to render, enormous effect on whether a video looks made. | Field | Range | Default | |---|---|---| | `grain` | 0–1 | `0.05` | | `scanlines` | 0–1 | `0` | | `vignette` | 0–1 | `0.35` | | `glow` | 0–1 | `0.4` | | `grid` | 0–1 | `0.08` | | `cornerRadius` | px | `20` | None of these are SVG filters. A `feGaussianBlur` over two megapixels costs about 9.5 seconds a frame. Grain, vignette and scanlines are a lookup table built once and applied to the RGBA buffer in a tight loop — a few milliseconds. ## transition and music ```yaml transition: { kind: fade, duration: 0.25 } music: { energy: medium, duckDb: -12, targetLufs: -14 } ``` `kind` is `cut` · `fade` · `wipe` · `whip` · `glitch` · `slide`, and `duration` is **half** the move. | | | |---|---| | `cut` | nothing; the scenes simply change | | `fade` | a brief dip toward the background colour | | `wipe` | a bar sweeps across, accent edge leading | | `whip` | the scene is thrown sideways under a directional streak | | `slide` | the scene is pushed sideways | | `glitch` | channel split and torn bands | stingo renders one scene per frame, so a true crossfade — two scenes alive at once — would double the cost of every boundary. Each move is split instead: the outgoing scene plays the first half over its last `duration` seconds, the incoming scene plays the second half over its first, and both push the same way so the pair reads as one gesture. That is why `duration: 0.25` gives a half-second transition. `glitch` is the exception that runs on pixels rather than markup, because SVG cannot express a channel split cheaply. It is seeded from the scene index and the frame, so every worker in a parallel render tears the same frame identically. One interaction to know about: a block's own elements already fade out over `motion.exit` at the end of a scene. With a short scene and a long `motion.exit`, the type can be gone before a `slide` or `whip` has travelled far, and the move reads as weaker than it is. Shorten `motion.exit` if you want the push to carry the content rather than an empty frame. `targetLufs` is the loudness the final mix is normalised to; −14 is what the platforms want. ## Checking one ```bash stingo doctor ./ember.json ``` Audits against the house floors — body 7:1, muted 4.5:1, accents 4.5:1 — and reports which failures are repaired automatically at render time. Body text sits well above the WCAG 4.5 minimum on purpose: video is watched small, on phones, often in daylight. --- # Talking head > Putting yourself in the frame: three layouts, take audio, and writing before you shoot. > Source: https://aynaash.github.io/stingo/camera.md Everything else stingo draws is generated from text. A take is not — it is decoded video, composited into the frame. This is how an explainer gets the explainer in it. ## The short version ```yaml - block: camera camera: { src: takes/01-hook.mp4, layout: full, scrim: 0.2 } lower: { name: Hersi, role: hersietech.com } ``` `camera:` is not limited to the `camera` block. It sits on **any** scene: ```yaml - block: code lang: go code: | go download("a.txt") camera: { src: takes/02.mp4, from: 6, layout: pip, corner: br } ``` ## Three layouts ```text full pip split ┌───────────┐ ┌───────────┐ ┌─────┬─────┐ │ │ │ code {} │ │ │code │ │ ( you ) │ │ ┌──┤ │(you)│ {} │ │ │ │ │yo│ │ │ │ └───────────┘ └────────┴──┘ └─────┴─────┘ delivery over code side by side ``` Those are three moments of one recording, picked with `from`. The take is a **generated stand-in**, not footage of anyone — it says so in the frame. Shoot your own: [RECORDING.md](https://github.com/aynaash/stingo/blob/main/examples/building-stingo/RECORDING.md). | Layout | The take | The block content | |---|---|---| | `full` | fills the frame | draws **over** it — captions stay readable | | `pip` | a corner inset | draws **under** it — the inset sits on top | | `split` | one side | the other side, in its own smaller stage | Choose per scene. One long recording can feed all three, because `from` picks the in-point. ## Every field | Field | Type | Default | | |---|---|---|---| | `src` | path | — | the take, relative to the document | | `from` | time | `0` | in-point within the take | | `layout` | `full` · `pip` · `split` | `full` | | | `fit` | `cover` · `contain` | `cover` | crop to fill, or letterbox | | `zoom` | 1–4 | `1` | push in past the fit | | `offsetX` / `offsetY` | −1–1 | `0` | pan, as a fraction of the box | | `mirror` | boolean | `false` | matches the webcam preview you recorded against | | `scrim` | 0–1 | `0` | darken the take so text over it stays readable | | `ring` | boolean | `true` | accent outline (pip and split) | | `mute` | boolean | `false` | drop this take's audio from the mix | | `gainDb` | number | `0` | | **pip only** | Field | Type | Default | | |---|---|---|---| | `corner` | `tl` · `tr` · `bl` · `br` | `br` | | | `size` | 0.08–0.9 | `0.3` | width as a fraction of the canvas | | `aspect` | 0.2–4 | `1` | width ÷ height | | `shape` | `rounded` · `circle` · `square` | `rounded` | | | `margin` | 0–0.3 | `0.055` | gap from the frame edge | **split only** | Field | Type | Default | | |---|---|---|---| | `side` | `left` · `right` · `top` · `bottom` | `left` | | | `ratio` | 0.2–0.8 | `0.5` | share of the frame the take takes | ## Audio comes from the take There is no sync step. Each take is trimmed to the slice its scene uses, delayed to that scene's position on the timeline, mixed with any other speech, ducked under the music by a sidechain compressor, and normalised to the taste's target loudness. The timeline already knows when the scene starts, so alignment is arithmetic rather than nudging. That is why the advice is to **record clean audio into the same file as the picture**. A separate recorder puts you back in the business of aligning two files by hand. If a take's sound is unusable, set `mute: true` and put the words in `audio.vo` instead. ## Length comes from the take too A `camera` scene with no `dur:` runs exactly as long as its clip, measured at render time. Two rules are suspended for it, both because it contains speech: - **Pacing bounds do not apply.** `sceneMax` exists to stop a caption lingering on screen, not to cut a sentence in half. - **It cuts `free`, not on the bar.** Snapping the end of speech to a downbeat clips the last word. Set `cut: bar` on the scene to opt back in. A take that runs out before its scene does holds on its last frame rather than failing the render. `stingo takes` warns you first. ## Writing before you shoot This is the part worth internalising: **every command works with no footage at all.** Lock the edit first, then record to fit it. ```bash stingo plan script.yaml # the cut, with nothing shot stingo render script.yaml --no-camera # a watchable rough ``` With `--no-camera`, each take's box is drawn as a placeholder carrying the source timecode it would be reading — so you can see the shape of the edit and know exactly what to record. Once footage exists: ```bash stingo takes script.yaml ``` ``` take.mp4 1920x1080 · 30.00fps · 4:12 · has audio scene 0 full 0:00→0:14 reading from 0:00 scene 3 pip 1:07→1:14 reading from 0:06 ``` It warns when a take is **shorter** than the scenes reading from it, was shot **below** the box it feeds, or was recorded at a **lower frame rate** than the canvas. ## Framing for each layout The box a take lands in decides how close you need to sit: - **full** fills the canvas. Anything smaller than it gets upscaled. - **pip** is around 28% of the frame width — roughly 540px on a 1920 canvas. Sit closer than feels natural; a wide shot at inset size is a dot. - **split** is 34–50% of the width. Frame yourself off-centre toward the side the take sits on, so you are not looking out of the panel. Leave headroom. `zoom`, `offsetX` and `offsetY` reframe after the fact, but they cannot invent picture outside what you shot. ## In TypeScript ```ts import { film, camera, code } from '@hersidev/stingo'; export default film('Launch') .horizontal() .add( camera('takes/01-hook.mp4', { scrim: 0.2 }) .lower('Hersi', 'hersietech.com') .say('I got tired of dragging rectangles around a timeline.'), code('go', 'go download("a.txt")') .camera('takes/02.mp4', { layout: 'pip', corner: 'br', shape: 'circle' }), ); ``` ## How it actually works The take is never drawn into the SVG — it is pixels, and an SVG document cannot carry them cheaply. Instead: 1. The renderer builds the frame with a **mask** punching a hole where the take goes. 2. resvg rasterises that to RGBA, leaving the hole transparent. 3. The decoded take is blended in **underneath**. Which layers the hole cuts through is what sets z-order. Mask only the background and the take sits behind the block content — that is `full`, and why a caption stays on top. Mask the block content too and the take sits over it — that is `pip`. resvg returns **premultiplied** RGBA, so compositing under it is `dst + src × (1 − dst.a)`: one multiply and one add per channel, no division. Decoding is a `VideoSource`, which presents a file as a pure function of time. It holds one ffmpeg process at a cursor, walks it forward when a render sweeps through a scene, and restarts only on a seek. Each render worker owns its own, so contiguous frame ranges stay on the fast path. The one ordering that matters is cropping **before** scaling. Scale first and swscale resizes the whole source up to cover the box — a 1280×720 webcam feeding a 1080×1920 canvas becomes 3413×1920, six megapixels a frame, most of it discarded. Cropping to the box's aspect at source resolution and scaling that once measures **7× faster** on exactly that case: 120 frames in 0.73 s against 5.1 s. --- # Commands > Every command and flag, and what each one is for. > Source: https://aynaash.github.io/stingo/cli.md Every command takes a video document except `beats` (an audio file), `taste` (a brand colour), and `blocks` and `tastes` (nothing, or one block name). `doctor` takes either a document or a taste, and does something different with each. ## render ```bash stingo render [-o out.mp4] ``` Renders the whole film. Frames are split into contiguous ranges across processes; each worker encodes its own MP4 segment and the segments are concatenated with stream copy, so no pixel data crosses a process boundary and nothing is re-encoded. Defaults to `out/.mp4`. ```bash stingo render script.yaml --draft # fast, ugly, for iterating stingo render script.yaml --horizontal # same script, different shape stingo render script.yaml --taste dusk # same script, different look stingo render script.yaml --no-camera # skip decoding footage ``` When the film has audio, the finished file is measured and the two numbers that matter are printed: integrated loudness against the taste's target, and how far the speech sits above the music bed. ``` audio -14.2 LUFS · true peak -1.3 dBTP · speech +13.1 LU over music ``` Bad audio is what loses viewers, and it is the one fault a still cannot show you — a frame can be checked by looking at it, a mix can only be checked by listening to all of it, which nobody does on the tenth render. Speech below about 8 LU over the bed gets a warning with how many dB to drop `audio.musicGainDb` by. `--no-verify` skips the measurement. ## still ```bash stingo still --at 12.5 -o frame.png stingo still --frame 375 -o frame.png ``` One frame, through the same pixel path as a render — same fonts, same texture pass, same camera compositing. The fastest way to check a layout, and it prints which scene the frame landed in. ```bash stingo still --at 12.5 --guides -o frame.png ``` `--guides` draws the title-safe and action-safe boxes over the frame, plus the zones each platform covers with its own interface: in portrait, the column of buttons up the right edge and the caption-and-handle strip along the bottom. None of that furniture is in the frame you render, which is why a headline parked under the Shorts right rail looks fine in a still and is unreadable in the app — and you find out after publishing. Anything you need read belongs inside the title-safe box and outside the red zones. The overlay is never encoded into a video; it exists to be looked at. ## sheet ```bash stingo sheet -o sheet.png ``` A contact sheet: one still from the middle of every scene, in a grid, labelled with the scene id and its place on the timeline. Nobody can hold a six-minute video in their head, and scrubbing is a serial act — you see one moment at a time and have to remember the rest. Twelve frames side by side is a different kind of looking. It is how you notice that three scenes in a row are dark, that two of them say nearly the same thing, or that the one bright scene is in the wrong place. The frame is taken from the middle of each scene rather than the start, because at frame zero every entrance is still animating and a sheet of first frames is a sheet of half-drawn scenes. ```bash stingo sheet --cols 4 # force the grid stingo sheet --width 3000 # bigger thumbnails stingo sheet --at 0.8 # later in each scene, 0..1 stingo sheet --no-camera # placeholders instead of footage ``` ## plan ```bash stingo plan ``` Resolves the entire timeline and prints it, rendering nothing. Shows each scene's start, end, duration in seconds and in beats, and marks scenes carrying a camera take. Use it before every long render. Finding out a scene is two seconds too short costs one second here and several minutes there. It also warns where a scene's `say` cannot be said in the time the scene has — with how many words to cut — and prints the take budget: every camera scene and the seconds of footage it needs. ``` to camera 3 scenes · 0:21 of footage 01-hook 8s · 02-broke 7s · 03-verdict 6s ``` That is the line to write down before you sit in front of the camera. `plan` already knows how long each camera scene is; this is that arithmetic in a form you can keep next to you while shooting. A document whose `audio.music` does not exist still plans. `plan` renders nothing and decodes nothing — it needs a tempo, not audio — so a missing track is a warning, cuts fall back to free timing, and the command continues. Snapping to a nominal 120 BPM that no track has would give you a plan that is wrong in a way that looks right. ## preview ```bash stingo preview [--port 4321] ``` A scrubbable preview at `localhost:4321` that re-plans when the document is saved. Seeking is not a render — any frame is one function call — so the scrubber is instant. ## takes ```bash stingo takes ``` Every camera take the script references: resolution, frame rate, duration, whether it carries audio, and which scenes read from it. Warns when a take is shorter than the scenes using it, was shot below the box it feeds, or was recorded at a lower frame rate than the canvas. See [talking head](camera). ## beats ```bash stingo beats