skills/showtime/references/stage-api.mdStage API: the time contract for showtime pages
Read this when you write or debug a showtime page (index.html + showtime.json), add a
library (anime.js, Lottie, three.js, canvas, video) to a scene, or showtime check reports
a determinism, timing or seek problem.
The one rule#
Every frame is a pure function of time t (seconds). Nothing plays during a render: the
renderer calls ST.seek(t) for frame 0, 1, 2 ... and takes a screenshot after each seek. The
same page must produce the same pixels for the same t, whatever frame came before, on any
machine. Everything below is how to get there without thinking about it.
Minimal page#
<!doctype html>
<html><head>
<script src="/_st/stage.js"></script> <!-- first script in <head> -->
<link rel="stylesheet" href="/_lib/@fontsource-variable/inter/index.css">
<style>
body { font-family: 'Inter Variable'; color: #fff; }
.scene { position: absolute; inset: 0; display: grid; place-items: center; }
h1 { font-size: 9vmin; animation: rise .8s cubic-bezier(.2,.8,.2,1) .2s both; }
@keyframes rise { from { opacity: 0; transform: translateY(4vmin); } }
</style></head>
<body>
<section class="scene" id="intro" data-start="0" data-dur="3"><h1>Hello</h1></section>
<section class="scene" id="next" data-start="#intro" data-dur="2"><h1>World</h1></section>
<script>
ST.onSeek((t) => { document.body.style.setProperty('--hue', String(200 + t * 20)); });
</script>
</body></html>
showtime.json next to it:
{ "width": 1920, "height": 1080, "fps": 30, "duration": 5, "background": "#0b0d12",
"title": "Hello", "poster": 1.5, "audio": "audio/mix.json" }
/_st/...is the showtime runtime,/_lib/<package>/...any installed npm package (animejs, three, lottie-web, d3, katex, roughjs, @fontsource...),/_assets/...files fetched byshowtime assets. Everything else is relative to the project folder. Nothing is fetched from the internet during a render (requests are blocked and reported byshowtime check).vw/vh/vminare the stage size, so a page written with them works at 16:9, 9:16 and 1:1.- If a page forgets the stage script, the server adds it, but put it first so the virtual clock is installed before any library reads the clock.
Configuration#
| Field | Meaning | Default |
|---|---|---|
width, height |
stage size in CSS px (even numbers) | 1920 x 1080 |
fps |
frames per second | 30 |
duration |
length in seconds; if missing: the latest clip end, else the longest finite CSS animation | required somewhere |
background |
colour behind everything (transparent with --alpha) |
#000 |
title |
used for the output folder name | folder name |
poster |
time (s) of the frame baked into frame 0 and saved as poster.jpg | none (auto-picked poster.jpg, not baked) |
audio |
a sound file, a mix spec (audio/mix.json), an inline {"tracks": [...]} or a list of files/tracks |
none |
loudness / master |
loudness target (-14) or {"lufs": -14, "true_peak": -1} |
-14 LUFS, -1 dBTP |
seed |
base seed for Math.random in render mode |
1 |
overlay |
the page is an overlay for render --alpha (lower thirds, step chips): gaps between its elements are intended, so check notes them instead of reporting dead_air; <body data-overlay> does the same. With --alpha the themes' scene and stage fills are transparent; a background a scene sets itself still paints |
false |
ST.config({...}) in the page sets the same fields. showtime.json wins when both set a
value (showtime check warns about the conflict); CLI flags (--fps, --from/--to) win over both.
Clips: data-start / data-dur#
Any element with data-start is a clip. It is shown (display restored) only inside its window
[start, end) and hidden otherwise; a clip that reaches the end of the video also owns the last
frame. The stage sets on each visible clip:
- attribute
data-active(style entrances with[data-active]if you like) - CSS variables
--t(seconds since the clip started) and--p(0..1 through the clip), and on:root:--st-t(video time) and--st-p(0..1 through the video)
Time grammar for data-start and data-end:
| Value | Means |
|---|---|
2.5 |
2.5 s into the video |
+0.5 |
0.5 s after the parent clip starts (nesting) |
#intro |
when the clip with id="intro" ends |
#intro+0.3, #intro-0.2 |
relative to that end (overlaps are fine) |
data-dur is a length in seconds; data-end is an alternative absolute end. No end means "until
the video ends". Add data-keep to hide with visibility instead of display (keeps layout, for
elements you measure). Bad values or circular references are reported by showtime check.
CSS animations and Web Animations inside a clip run on the clip's clock: animation-delay: .2s means 0.2 s after the clip starts. The stage pauses every animation and sets its
currentTime on each seek, so @keyframes just work, including infinite loops. Put
data-st-free on an element to leave its animations alone (rarely needed). showtime retime
rescales data-start/data-dur/data-at, not animation-delay: after a retime, check delays by
hand.
Reveals on a spoken word. showtime voice cues voice/timeline.json -o voice/cues.js writes a
VO table (line and word times). Load it with a classic <script> and set times from it before the
page is ready, instead of typing numbers: el.dataset.at = (VO.words.demo[3][1] - sceneStart).toFixed(3).
This holds for a voice that plays as one track (--offset = its start in the video); run voice cues
again after every re-voice. After retime --from-voice, which places each line in its scene, count
from the scene start: a word's time in its line (VO.words.<line>[i][1] - VO.lines.<line>.start)
plus the line's delay in the scene.
API#
| Call | What it does |
|---|---|
ST.onSeek(fn) |
fn(t, frame) runs on every seek, in registration order. Return nothing, or a real Promise for async work (e.g. a texture). Returns an off() function. |
ST.adapter(name, fn) |
same as onSeek, named (shown in errors and ST.info().handlers) |
ST.waitFor(promise, label?) |
before the first frame: delays readiness (data, images, models). Inside a seek handler: delays that frame's screenshot. |
ST.rand(seed) |
deterministic generator: r() in [0,1), r.range(a,b), r.int(a,b), r.pick(arr), r.sign(). Seeds can be numbers or strings. |
ST.noise(x, seed), ST.noise2(x, y, seed) |
smooth deterministic noise in [-1, 1] (drift, wobble, handheld camera) |
ST.progress(t, a, b, ease?) |
0..1 position of t between a and b, optionally eased |
ST.ease.* |
linear inQuad outQuad inOutQuad outCubic inOutCubic outExpo inOutExpo outBack outElastic |
ST.clamp(x, a, b), ST.lerp(a, b, p) |
helpers |
ST.score = async (ctx, dest, info) => {...} |
optional score written in Web Audio: schedule everything from time 0 on ctx into dest. Rendered offline to a WAV by the renderer and played in sync by the preview player. info = {duration, sampleRate, offline} |
ST.config(obj) |
set/merge config (see above); returns the effective config |
ST.mode |
'render' (renderer, check, snap), 'preview' (inside the player or a plain browser), 'player' |
ST.t, ST.frame, ST.cfg |
current time, frame, config |
ST.info() |
size, fps, duration (and where it came from), frame count, clip count, handler names |
ST.clips() |
resolved clip windows [{name, id, start, end}] |
ST.diag() |
what the runtime noticed: timer callbacks during playback, CSS transitions, video problems, handler errors |
ST.seek(t), ST.ready() |
used by the renderer and the player. Never call them from scene code. |
Library helpers (built-in adapters)#
// anime.js v4: create paused, the stage seeks it. offset = when it starts in the video.
import { createTimeline, stagger } from '/_lib/animejs/dist/bundles/anime.esm.min.js';
const tl = createTimeline({ autoplay: false }).add('.card', { opacity: [0, 1], translateY: [40, 0], delay: stagger(120) });
ST.anime(tl, { offset: 3.5 });
// Lottie (lottie-web): autoplay false; readiness waits for the file.
const anim = lottie.loadAnimation({ container: el, renderer: 'svg', autoplay: false, loop: false, path: 'logo.json' });
ST.lottie(anim, { offset: 1.0, loop: false, speed: 1 });
// three.js: the stage renders after your update.
ST.three(renderer, scene, camera, (t) => { mesh.rotation.y = t * 0.8; }); // WebGLRenderer({preserveDrawingBuffer: true})
// GSAP (only if you installed it yourself; it is not part of showtime)
ST.gsap(timeline, { offset: 0 });
// Canvas / SVG / anything else: draw from t
ST.onSeek((t) => { ctx.clearRect(0, 0, W, H); drawScene(ctx, t); });
The helpers never await the library object: several animation libraries return objects with a
then method that only settles when playback ends. If you write your own adapter, return
undefined or a real Promise, never the timeline.
<video> in a page#
Every <video> is paused, muted and seeked to the middle of the right source frame on each seek
(seeking to the exact frame boundary shows the previous frame in Chromium). Attributes:
data-offset (in-point in the source, s), data-rate, loop / data-loop, data-fps (source
fps if it differs from the video's), data-st="off" (leave it alone). Its clock is its own
data-start, else the nearest clip's.
- Use VP9/WebM (or AV1) for footage inside pages: Chromium builds without proprietary codecs
(Playwright's Chromium, some Linux packages) cannot decode H.264. Convert with
showtime footage trim in.mp4 --webm --no-audio -o media/clip.webm(never bare ffmpeg). - Every seek decodes from the previous keyframe, so a large H.264 source with long keyframe
intervals costs 100-300 ms per frame per render worker. Make a proxy the size the page shows it,
with a keyframe every 0.5 s:
showtime footage trim demo.mp4 --width 1280 -o media/demo.mp4(add--from/--toto keep only the part you use).showtime checkraises its render estimate for pages with<video>layers. - Keep sound out of the page: put it in the
audiomix instead (the page is always muted). - For long footage, prefer editing it with the footage tools and compositing graphics rendered
with
--alphaon top.
Determinism: what the render mode does and what to avoid#
In render mode (render, check, snap) a small runtime is installed before any page script:
Date,Date.now()andperformance.now()return the frame time (Date.now()is 2026-01-01T00:00:00Z plust).requestAnimationFramecallbacks are queued and run once per seek with the frame time, so libraries with their own loop are driven by the seeks.Math.randomandcrypto.getRandomValuesare seeded again on every frame from the frame number, so they give the same values for the same frame in any order or worker.setTimeout/setIntervalstay real (loading needs them); callbacks that run during playback are counted and reported with their source line.- Requests to anything other than the local server are blocked.
| Don't | Do instead |
|---|---|
animate with setTimeout, setInterval, a counter incremented per frame |
compute the value from t in ST.onSeek, or use @keyframes / a paused timeline |
CSS transition triggered by toggling a class |
@keyframes, or set the value from t (transitions are jumped to their end) |
keep state between frames (x += v), physics stepping |
closed-form x = f(t), or pre-simulate once at load and look up by frame |
Math.random() for layout that must stay put across frames |
const r = ST.rand('stars') created once, at load |
| read the DOM in a seek handler and build on the previous frame's layout | measure once at load (after ST.waitFor(document.fonts.ready)) |
| remote fonts, images, scripts or map tiles | /_lib/@fontsource..., files in the project, /_assets/... |
system fonts (Helvetica, Arial, system-ui, emoji fonts) |
@font-face files; they render the same on macOS, Windows and Linux |
| emoji typed as text drawn by the OS emoji font | nothing to do: stage.js swaps text emoji (🚀, ❤️, 👍🏽, flags) for Noto SVGs from /_st/emoji/<codepoints>.svg; install each once with showtime assets emoji 🚀 (check reports a 404 with that command when one is missing). data-st-emoji="off" on an element keeps its emoji as text |
| ⌘ ⇧ ⌥ ↵ and arrows as text in keycaps | the keystrokes component and Film.keyCombo draw them as vector icons |
| animated GIFs | a VP9 <video> or an image sequence driven by t |
animating left/top/width/height/margin for motion |
transform (layout properties snap to whole pixels and judder) |
canvas.getImageData every frame on a GPU canvas |
getContext('2d', {willReadFrequently: true}) or avoid readback |
backface-visibility tricks in 3D CSS |
real geometry, or render both faces explicitly |
showtime check probes all of this for real: it captures frames, captures them again after a
real delay and in another order, and compares the pixels.
Readiness#
ST.ready() (called by the renderer and the player, never by scenes) waits for: the page
load event, every @font-face in the document (all are loaded up front), <img> decode,
<video> data, and every ST.waitFor promise; then it resolves the duration and seeks to 0.
A page that never becomes ready fails with the list of pending waitFor labels.
Preview mode#
Opening a page through showtime preview (or the server's /_st/preview?page=/index.html) shows
the player: the page runs in a frame at its real size scaled to fit, driven by ST.seek with a
real-time clock, with the same virtual clock installed as in render mode, so what you scrub is
what renders. Player keys: Space play/pause, Left/Right one frame (Shift: 1 s), Home/End, L loop,
M mute, S safe-zone guides, F fit/100%. Clip names (data-name, else id) are marked on the
scrubber. Audio: ST.score is rendered offline once and played in sync from any position,
together with work/mix.wav (built from the audio field by showtime preview). Saving a project
file reloads the page at the same time position.
Opening the page itself (/index.html) redirects to the player; /index.html?st=raw plays the page
alone in a loop (Space pauses).
Scene transitions with shaders (layer protocol)#
Pages can expose window.__stLayers = { pending(), solo(id), put(id, dataUrl), compose(), unsolo() }
(the transitions module does). After each seek the renderer asks pending(); for each layer id it
calls solo(id), screenshots the page, hands the image back with put(), and finally compose()s
before the real screenshot. The page sees window.__ST_RENDER__.layers === true when this is active.
Frames without pending layers cost nothing extra.