Stage 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.
Essentials#
- Every frame is a pure function of
t: the renderer callsST.seek(t)per frame and screenshots; the sametmust give the same pixels in any order, on any machine (§ The one rule) <script src="/_st/stage.js"></script>is the first script in<head>; fonts and packages from/_lib/, fetched assets from/_assets/, the rest relative; nothing from the internet (§ Minimal page)showtime.json:width/height(even),fps(30),duration,background,title,poster,audio,loudness(-14 LUFS, -1 dBTP). It wins overST.config; CLI flags win over both (§ Configuration)- Clips: an element with
data-startshows only inside[start, end);data-durordata-end; values2.5,+0.5,#intro,#intro+0.3. Visible clips getdata-active,--tand--p(§ Clips) - CSS and Web Animations in a clip run on the clip's clock (
animation-delayfrom the clip start);showtime retimedoes not rescaleanimation-delay, check delays by hand after it (§ Clips) - Time reveals to words from the
VOtable (showtime voice cues voice/timeline.json -o voice/cues.js), not typed numbers; runvoice cuesagain after every re-voice (§ Clips) - Draw anything else in
ST.onSeek((t, frame) => ...), returning nothing or a realPromise, never a library timeline.ST.waitForfor loading,ST.rand(seed),ST.noise,ST.progress,ST.ease.*(§ API) - Never call
ST.seekorST.readyfrom scene code (§ API, § Readiness) - Libraries: create them paused and hand them over:
ST.anime(tl, {offset}),ST.lottie(anim, ...),ST.three(renderer, scene, camera, fn)withpreserveDrawingBuffer: true,ST.gsap(§ API) - Never:
setTimeout/setIntervalanimation, class-toggled CSStransition, state between frames (x += v),Math.random()for layout, remote files, system fonts, symbols as text, animated GIFs, animatingleft/top/width/heightinstead oftransform(§ Determinism) - Motion blur on a snap beat:
data-st-bluron the one element that moves ("shutter 180; samples 8; threshold 6; max 50%", all optional) orST.blur(el, {pose}); copies only on the fast frames, it lands sharp (§ Shutter blur) - Footage: VP9/WebM made with
showtime footage trim in.mp4 --webm --no-audio -o media/clip.webm(never bare ffmpeg), a proxy at the shown size; the page is always muted, sound goes in theaudiomix (§ API) - Emoji in text become images: install each once with
showtime assets emoji(§ Determinism) - Overlay pages for
render --alpha:"overlay": trueor<body data-overlay>(§ Configuration) showtime checkrecaptures frames after a delay and in another order to prove determinism; scrub inshowtime preview(§ Determinism, § Preview mode)
| Section | Lines |
|---|---|
| The one rule | 52-57 |
| Minimal page | 59-94 |
| Configuration | 96-112 |
| Clips: data-start / data-dur | 114-154 |
| API: Library helpers (built-in adapters), <video> in a page | 156-241 |
| Shutter blur: data-st-blur and ST.blur | 243-296 |
| Determinism: what the render mode does and what to avoid | 298-329 |
| Readiness | 331-339 |
| Preview mode | 341-353 |
| Scene transitions with shaders (layer protocol) | 355-361 |
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)
Outside its window a clip's --t/--p still depend only on the time: 0 before it starts, its last
frame's values after it ends. A transition that keeps the outgoing scene on screen therefore shows
the same frame however the video is seeked.
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, in/out/inOut + Sine Quad Cubic Quart Expo Circ Back (the same names as Film's F.E), 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.questions |
showtime.json "questions" with times resolved (voice cues included): [{id, t, think, resume, from, prompt, choices, answer, reply}], for a page that draws its own pause and think beat (html-export.md § Questions); await ST.configReady() first in a preview |
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}], frame-exact: a clip is on screen exactly while t >= start && t < end (a time written within 1 ms of a frame, like 1.9667 at 60 fps, is reported as that frame's time, as the stage shows it); end is null for a clip that runs to the end of the video. Gate canvas drawing on these, not on times typed again in the script |
ST.diag() |
what the runtime noticed: timer callbacks during playback, CSS transitions, video problems, handler errors |
ST.pace() |
how much the waits are scaled for this page: {factor, frame_ms, seek_ms, seeks, ceiling, on} (see Readiness) |
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.
Renders (and check, snap, studio frames) draw every <video> on a canvas: on a busy or slow
machine Chrome can put a paused video's seeked frame on screen after the capture (the frame before,
or nothing for the first one), while a canvas is captured with the rest of the page. The canvas goes
right after the video and gets the video's computed style every frame (size, position, flex/grid
place, object-fit, transforms, opacity, filters, border-radius, masks, z-index, visibility), so it
lays out and paints where the video did; the video steps out of the flow, hidden, and its boxes
(getBoundingClientRect, offsetWidth...) report the canvas's. Transparent (alpha) clips stay
transparent; a clip up to Chrome's largest canvas (268 M pixels) is drawn at full size, a bigger one
stays a plain video. Nothing to write for it; the preview plays the plain <video>. Two things
differ: an extra element after each video shifts :nth-child / :last-child / video + x
selectors (use classes or :nth-of-type), and the video's own CSS transitions are off in renders
(they finish at once there anyway). data-st-video="native" on a <video> keeps it a plain video
in renders too (an edge case the canvas gets wrong).
A <canvas data-st-video="ID"> of your own, styled like <video id="ID">, takes the frame of
<video id="ID"> instead (the video is hidden; in the preview the canvas stays empty).
showtime adopt writes its pages this way.
- 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.
Shutter blur: data-st-blur and ST.blur#
Motion blur for a snap beat (a whip, a slam, a scale punch) on the one element that moves. While it moves
faster than threshold px a frame on screen, the stage draws samples copies of it posed at sub-frame times
from t back to t - shutter, averaged in its place (a 180° shutter is half a frame, as on a film camera).
At rest, moving slowly, and on the frame it comes to rest, the element draws itself, sharp. Nothing is
captured twice: the poses are computed from the element's own motion, so a blurred frame costs a few
milliseconds more (render.md § Speed), never samples captures. Four snap beats, and the same whip without and
with the blur: the _blur demo in the showtime repository's examples folder.
<h1 class="word" data-st-blur>SNAP</h1> <!-- shutter 180, samples 8, threshold 6, max 50% -->
<div class="card" data-st-blur="shutter 270; max 120">...</div>
| Option | Default | Meaning |
|---|---|---|
shutter |
180 |
shutter angle in degrees: 180 is half a frame, 360 a whole frame (a longer smear) |
samples |
8 |
copies averaged on a blurred frame; each one is a copy of the element to draw |
threshold |
6 |
px a frame (on screen) under which the element is drawn sharp |
max |
50% |
the longest smear: px, or % of the element's shorter side on screen; a faster frame gets a shorter shutter |
Where its motion may come from, because the stage can set each one to any time and read the element back:
CSS @keyframes and Web Animations on the element and its descendants, the showtime component around it,
timelines handed to ST.anime / ST.gsap, and a pose(t) function. A pose replaces the element's
ST.onSeek code: it runs on every seek and between frames.
const logo = document.querySelector('#logo');
ST.blur(logo, { pose: (t) => { // ST.blur(el | selector, {shutter, samples, threshold, max, pose}) -> off()
const p = ST.progress(t, 2.0, 2.25, ST.ease.outExpo);
logo.style.transform = `translateX(${((1 - p) * -900).toFixed(1)}px) rotate(${((1 - p) * -30).toFixed(2)}deg)`;
} });
Motion from a plain ST.onSeek handler or from a moving ancestor is not seen: check says so
(blur_unsampled). Canvas films: F.motionBlur (film-api.md §12).
How it is drawn: each copy is a clone of the element posed at its sample time; where the page's rules no
longer reach it in its new place (.line > span, .scene > .card b), the element's computed values are pinned
on it and on its descendants, so it looks the same. All copies are averaged with mix-blend-mode: plus-lighter in an isolated group (in pairs of halves, so 8-bit rounding adds a level or two, not one per
copy) and smoothed along the motion by a gaussian half a step wide. An opaque element stays opaque where
every copy covers it. The copies live in an <st-blur> made once, right after the element, so selectors
such as :last-child, + and :nth-child see one more sibling on every frame (as after a <video>); an
overflow: hidden parent clips them as it clips the element (a mask reveal). Page code never meets them
(they are removed before each seek's handlers run), the same frame gives the same pixels in any worker, and
check's text audits read the element itself, as blurred, on those frames.
Taste (motion-craft.md §5): 1-3 snap beats a video, on the element that snaps; never on text being read, a
slow drift or a whole scene (scenes move with a transition: push with blur: true, whip-pan). check
warns blur_slow, blur_text, blur_container, blur_unsampled and blur_inline (render.md). Limits: a
non-replaced inline box is never transformed (use inline-block); a blend mode or backdrop filter inside the
element blends within its copies; a <video> inside it shows only in renders (canvases are copied).
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.
Each of these waits (60 s for fonts and for the waitFor gates, 15 s per image or video, 30 s for
the load event) is multiplied by the page's pace factor, 1 to 5, measured from the gaps between its
frames while it gets ready and from its first seeks; ST.pace() returns it (render.md explains the factor).
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.
View or edit skills/showtime/references/stage-api.md on GitHub