stingo

The script

Every field of a video document, and how a scene gets its length.

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.

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

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.

taste: bootdev        # built-in
taste: ./taste.json   # relative to this document

See taste profiles. --taste overrides it per command.

#audio

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 <file> 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

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
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
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

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

#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:

#B-roll

Every scene sits on an animated, procedurally generated background. No footage, deterministic from a seed.

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.

- 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:

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

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:

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.

bun stingo render film.ts