Made with reelscript, by reelscript: CI renders examples/basic.ts inside the published container on every push to main and commits the result.
Product demos as code. Write a script, render a demo, re-run it in CI when your UI changes.
Screen-recording tools (Screen Studio, Arcade, Tango) all rot the same way: your product UI changes and your beautiful demo is now a lie, so you re-record it by hand. reelscript makes a demo a build artifact. The video is generated from a script, so when your UI updates you just re-run it, and reelscript check tells you in CI when a UI change has broken a demo.
import { createDemo } from "@reelscript/cli";
const demo = createDemo({ viewport: [1280, 800], camera: "follow" });
demo.browser.goto("https://app.local");
demo.cursor.moveTo("#new-project");
demo.cursor.click();
demo.say("Give it a name, and hit Create.");
demo.type("#project-name", "Acme Q3 Launch", { wpm: 400 });
demo.cursor.moveTo("#create");
demo.cursor.click();
demo.waitForNarration();
await demo.render("out/demo.mp4");- Code-first, not a UI timeline. The demo is a script you version, diff, and review.
- Deterministic offline rendering. Frames are produced one at a time: drive a headless Chromium to the state for frame n, capture it, composite the animated cursor and zoom, and pipe it to ffmpeg. Every frame is there, the same frames every time, and it runs headless in CI. (Chromium's text rasterizing and video decoding can vary pixel values invisibly between runs, so compare renders by eye or by PSNR, not byte for byte. What's on screen at each frame, and when, doesn't vary.)
- The page's clock is virtual and pinned. reelscript replaces timers,
requestAnimationFrame,Date,performance.now, and the timeIntlandTemporal.Nowread inside the page and its iframes, seedsMath.random, steps CSS and Web Animations, and moves<video>,<audio>and SVG animations, one frame per rendered frame, inside web components' shadow roots too. A 200ms fade is 12 frames at 60fps no matter how slow capture is, and the page's date is the same on every render. - A camera that follows the action.
camera: "follow"eases in toward each click and the typing caret, holds, and eases back out when things go quiet. Or place zooms by hand withzoom.to(). - Cinematic layer. Eased cursor motion, click ripples, zoom-to-element, accelerated typing, done as math over frames rather than captured motion.
- Own the DOM. Targets are Playwright selectors, and
browser.mockAPI()returns canned JSON so demos needn't depend on a live backend. - A clean desktop. The
macostheme puts windows on a mocked macOS desktop, so there's nothing to tidy up before recording. - Browser, terminal, and VS Code windows. Open them side by side, click between them, and the one you click comes to the front. The editor is a real VS Code that loads real extensions, including the one you're building.
- Narration as code.
say()speaks a line while the actions continue, andwaitForNarration()paces the timeline to the voice. The default voice is Kokoro, an open-weights model that runs on CPU with no account, so it works offline and in CI.
npm install -D @reelscript/cli
npx reelscript warmup browserThe package installs a reelscript command, and warmup browser downloads the Chromium build it drives. (Use it rather than npx playwright install, which installs the build for your project's own Playwright if it has one.) On Linux, add --with-deps to install the system libraries Chromium needs too, with apt-get on Debian and Ubuntu; reelscript names the missing ones if a launch fails. Scripts are ES modules that use top-level await; set "type": "module" in your package.json, or the CLI will run them as modules for you. Commit your lockfile: it pins reelscript and the browser it drives, so renders don't change under you. (The unscoped name is blocked by npm's similarity rule against rescript, hence the scope.)
Requires Node 20.11 or later, on macOS or Linux. ffmpeg is bundled; npm 12 blocks the install script that downloads it, so with npm 12 also run npm install-scripts approve ffmpeg-static && npm rebuild ffmpeg-static (without it, reelscript uses an ffmpeg on your PATH).
A script is a TypeScript file. Save this as demos/signup.ts, pointed at your app:
import { createDemo } from "@reelscript/cli";
const demo = createDemo();
demo.browser.goto("http://localhost:3000");
demo.cursor.moveTo("text=Sign up");
demo.cursor.click();
await demo.render("out/signup.mp4");npx reelscript check demos/signup.ts # drive the app through it in seconds, no video
npx reelscript preview demos/signup.ts --at 1.5 # one frame: preview-1.5s.png, in the working directory
npx reelscript render demos/signup.ts # the video, in demos/out/git clone https://github.com/trevin-lee/reelscript && cd reelscript
npm install
npx playwright install chromium # inside the repo, Playwright is reelscript's own, so this is the right build
npm run example # renders examples/basic.ts -> examples/out/basic.mp4The example drives a small dashboard app that ships with the repo, so it's fully self-contained. Rendering takes about three times the video's length on an M-series laptop; preview renders a single frame in a few seconds.
A script creates a demo with createDemo(), queues actions, and ends with await demo.render(path). Steps return nothing and only queue: nothing runs until render() plays the queue (under check, preview and record too), so render() is the one call to await (and a script that doesn't is waited for anyway). A step's own mistake, like a misspelt option, throws at its line right away; what happens when it runs (a selector that matches nothing) fails at render() or check, so a try/catch around a step can't handle it. For work that depends on what the page shows, use call(), which runs off camera at its point in the timeline. A few rules hold everywhere:
- Renamed options.
goto({ settle }),maxGapMs, cameraholdMsandmenubar.clockare nowhold,maxGap,holdandclockText. The old names work until 1.0, with a warning at the line, so--strictfinds them.demo.check()gives way todemo.render(), whichreelscript checkchecks: underreelscript checkit still runs, with the same warning, and under the other commands it fails at its line. - Paths in a script are relative to the script's folder, wherever you run it from: the render output,
session,recordingsDir, an editor'sworkspaceandextensions, and a local page inbrowser.goto("./app.html"), which reelscript serves from this machine over http so it behaves as it would on a site (itsfetch("/api/...")reachesmockAPI, its cookies stick, its modules load) (anything with a scheme, likehttps://orfile://, is a URL as is, and a path from the root,goto("/pricing"), is a page on the site the browser is showing, as it is inmockAPI). Paths you pass on the command line are relative to your working directory. The exception is Playwright's own calls insidedemo.call()(page.setInputFiles,page.screenshot), which take paths relative to the working directory; name a file beside the script withfileURLToPath(new URL("data.csv", import.meta.url)), for example to choose a file for an upload, which is Chromium's own dialog and isn't drawn:demo.call(({ page }) => page!.setInputFiles("#file", fileURLToPath(new URL("data.csv", import.meta.url)))). - Targets are Playwright selectors: CSS (
#create),text=Create,role=button[name="Create"], and so on. Prefer ids anddata-testidattributes; they survive redesigns. A target means its first visible match; when several visible elements match, reelscript warns at that line, since the first may not be the one you meant. A target can also be a point,{ x, y }, in the window's own coordinates. A target is in the window's own page, not inside an iframe; fill a field in an iframe (a payment form) off camera withdemo.call(({ page }) => page!.frameLocator("iframe#pay").locator("#card").fill("4242 4242 4242 4242")). A target counts as visible when a viewer could see it: an element inside something faded all the way out (a closed modal atopacity: 0) isn't, anddemo.type()fails on a field that can't take the keyboard (disabled, read-only, not a text field). - What you aim at must be on screen, and a click must land on it. Moving to or zooming on an element outside the visible part of its window fails, with a hint to
demo.scroll()to it first. A click on an element covered by another window or by something in the page (an overlay, a toast) fails at its line instead of clicking whatever is on top. - Windows. There is at most one browser, one terminal, one editor, and one keyboard window (which shows keys and never takes them). The browser has no tabs: a link or
window.open()that would open a new tab opens in the browser window, as if the demo had switched to it. The first window opened takes theviewportsize and the main position on the desktop; later ones open smaller, at the lower right, unless you give themx,y,width,height. A window is kept on the desktop, below the macOS menu bar, and at least 200x120; when that changes what a script asked for, it's a warning at the line saying why (a biggerdesktop, ormenubar: false, makes room).browser.goto()opens the browser window if it isn't open. Any window can be moved withplace()and taken away withclose(); the browser, terminal and editor are brought forward withfocus()(the keyboard window is always in front). Callingopen()on a window that's open applies what you pass and keeps the rest from its lastopen(): the browser moves, the terminal starts over with the new settings, and the editor reopens (a new VS Code if its workspace, extensions or settings changed). Afterclose(),open()starts from the defaults. Selectors resolve in the focused window unless you name one withwindow. - The desktop is a Mac, on every host (or, with the PC laptop, a Windows computer under the same desktop). The browser is Chrome on macOS and VS Code has the macOS keybindings, like the desktop they sit on, so a local preview and a render in CI show the same ⌘ shortcut hints and take the same keys: press
Meta+K,Meta+P, and in a web page's fieldsMeta+AorAlt+ArrowLeft, which do what they do on a Mac on every host. Withdesk: { laptop: "pc" }the browser is Chrome on Windows and VS Code has the Windows keybindings instead, so pages show and answer Ctrl shortcuts: pressControl+K,Control+P,Control+A. Fonts come from the machine, though: a page that asks forsystem-uigets San Francisco on a Mac and a Linux font in the container, so text can look different between a local preview and a CI render. Give the page a web font, or judge the final look from CI. - Time. Actions run one after another.
zoom.to()andsay()start now and keep going while later actions run.wait(ms)andwaitForNarration()hold on camera;waitFor(selector)holds off camera, so a slow load isn't filmed. Every duration is in milliseconds, and option names don't repeat the unit (hold,maxGap,duration). - Scrolling is
demo.scroll(selector)to bring an element into view, ordemo.scroll({ by: 600 })anddemo.scroll({ to: 0 })for the page (the document, or in an app whose document doesn't scroll, its main scrolling area), stepped with the frame clock like everything else. It scrolls web pages; in the editor, use keys or an editor command. Chromium's smooth scrolling is off, so keys like PageDown jump rather than glide on their own clock. - What the clock can't reach. Animated GIF, APNG and WebP images play on their own and differ between renders; use a
<video>or CSS animation for motion that must match. Network responses arrive in real time, so data a page fetches after it opens can appear at a different moment inpreview,checkandrender: wait for it off camera withwaitFor(), or answer the request withmockAPI().crypto.randomUUID()andcrypto.getRandomValues()stay random, timers in a Web Worker run on real time, and so does a closed shadow root written into the HTML (<template shadowrootmode="closed">); other shadow roots are covered. A<video>from a site follows the clock only if its server answers Range requests (python -m http.serverdoesn't; reelscript warns when a video stands still); a local page opened withgoto("./page.html")is served with them. VS Code runs on real time (see Editor demos). Data from a live server is stamped with the real date, so a page that shows how long ago something happened reads it against the pinned one ("in 5 days" for something just made): answer those requests withmockAPI(), or give the democlock: "now". - What isn't drawn. Chromium draws some things outside the page, and they aren't in the video: a
<select>'s list (pick an option off camera withdemo.call(({ page }) => page!.selectOption("#plan", "pro"))), date and colour pickers, file choosers, context menus, andtitletooltips.alert(),confirm()andprompt()are answered OK, with a warning at the line. A page's own sound isn't recorded; narration is. - The clock. Every demo happens at the same moment, Tuesday, September 23, 2025, 9:41 AM UTC, unless you set
clockandtimezone. The page'sDatestarts there and the menu bar shows it.
Sign in once, by hand, in a real browser. reelscript saves the session (cookies and local storage) to a file:
npx reelscript login https://app.example.com/login --out demos/session.jsonThen every browser window in a demo starts signed in. From a script in demos/:
const demo = createDemo({ session: "session.json" });
demo.browser.goto("https://app.example.com/dashboard");It keeps cookies, local storage and IndexedDB, where apps such as Firebase keep their sign-in. It signs in with the browser a demo uses, Chrome on a Mac; for a demo with desk: { laptop: "pc" }, add --laptop pc so it's Chrome on Windows, as the demo's is. The file signs in as you, so add it to .gitignore. In CI, store it as a secret and write it out before rendering, for example echo "$SESSION_JSON" > demos/session.json. Sessions expire like any login; when a demo starts landing on the sign-in page, run reelscript login again. The page's date is pinned (see the clock, above), so an app that checks its sign-in token against the page's clock (Supabase and Firebase do) takes an expired token for a fresh one, doesn't refresh it, and the server turns it away: with a session, pass clock: new Date() so the page's date is the real one. For anything else a demo needs set up off camera, demo.call(({ page, context }) => ...) gets the Playwright page and browser context.
const demo = createDemo({ viewport: [1180, 720], desktop: [1600, 1000] });
demo.browser.goto("https://app.local");
demo.terminal.open({ title: "acme", x: 700, y: 560, width: 840, height: 360 });
demo.terminal.run("npm run deploy", { output: "Live at https://acme.app\n" });
demo.terminal.close();
demo.cursor.moveTo("#new-project", { window: "browser" });
demo.cursor.click();Each window is its own Chromium page; the desktop composites them in z-order with the theme's frames and shadows. Clicking a window raises it, and typing into a named window (demo.type(selector, text, { window }), editor.type(), terminal.run()) brings it forward first. See examples/desktop.ts.
demo.editor.open({
workspace: "acme",
extensions: ["esbenp.prettier-vscode@12.4.0"],
settings: { "editor.defaultFormatter": "esbenp.prettier-vscode" }, // or VS Code asks which formatter to use
});
demo.cursor.moveTo(demo.editor.file("src")); // expand the folder
demo.cursor.click();
demo.cursor.moveTo(demo.editor.file("app.ts"));
demo.cursor.click();
demo.cursor.moveTo(".monaco-editor .view-lines"); // click into the editor to move keyboard focus
demo.cursor.click();
demo.press("Meta+ArrowDown"); // the end of the file
demo.editor.type('server.get("/health", () => ({ ok: true }));');
demo.editor.command("Format Document");openFile() and command() type into Quick Open and the Command Palette the way a person would. The file must match by name (and by folder, if you give one) and the command by its whole name, with or without its category (View: Toggle Word Wrap or Toggle Word Wrap). If VS Code can't find it, the render and check fail at that line instead of pressing Enter on the near match VS Code offered, so a renamed file or command breaks the build rather than the demo.
The editor is code-server, a standalone build of Code - OSS. It's downloaded on first use, about 200 MB, and built into the container image. Each render starts it on a random local port with its own settings and a copy of the workspace, so your files are never edited, and stops it afterwards. VS Code runs as it would on a Mac, like the browser beside it: its keybindings are the macOS ones (Meta+P, Meta+ArrowDown) on every host, and its menus show ⌘ (with the PC laptop, the Windows ones: Control+P, Control+End). VS Code runs on real time, not reelscript's frame-stepped clock, and shows the real date rather than the demo's clock. Its own animations are off by default, but anything VS Code or an extension does on a timer (a build, a deploy, a toast) happens in real time, so how far along it is at a given second can differ between preview, check and render, and between machines. Follow it with waitFor() on what it shows rather than a fixed wait(), as examples/extension.ts does. See examples/editor.ts.
Point extensions at the extension's folder (a directory with a package.json), a .vsix, or an Open VSX id. Folders are copied in and registered, so no packaging step is needed, but build the extension first: VS Code runs its main as it is, and a command it contributes is still listed (and runs nothing) when that file is missing.
// demo/extension.ts in the extension's repo. Paths are from this script's folder:
// ".." is the repo root (the extension itself), and the workspace is demo/fixtures/project.
demo.editor.open({ workspace: "fixtures/project", extensions: [".."], notifications: true });
demo.editor.command("Acme: Deploy to Production");Each demo gets only the extensions it asks for. Open VSX extensions and .vsix files are installed once and cached; an Open VSX id without a version is cached at the version first installed, so pin one with publisher.name@1.2.3, or clear the cache to update (npx reelscript cache clear extensions). The extension runs in a real extension host with its Node dependencies, so keep node_modules present (or bundle) as you would for vsce package. See examples/extension.ts and the sample extension in examples/acme-ext.
demo.terminal.open({ title: "acme", prompt: "acme % " });
demo.terminal.run("npm install -D @reelscript/cli", { output: "\nadded 38 packages in 2s\n" });
demo.terminal.run("node --version"); // replayed from recordings/node-version-<hash>.jsonDeclared output never executes anything, so it renders identically everywhere. For real commands, run
npx reelscript record demos/terminal.tsonce, or in CI whenever your CLI changes. It executes every terminal.run that has no output, in the script's folder unless the run gives a cwd, captures stdout and stderr with timestamps, and saves recordings/<command-slug>.json next to the script. A command that exits with an error is still saved, since a demo may mean to show a failure, and record warns about it. A command that appears twice (ls, touch new.txt, ls) or runs in two folders gets a recording for each run, so each replays what it showed at that point. When you change a command, its old recording stays until you run record --prune with that script; each recording names the script that made it, so other scripts' recordings in the same folder are left alone. Commit the recordings; they're small JSON.
Rendering replays a recording with long silences capped (maxGap, 700 ms by default; Infinity keeps them) and an optional speed, and never needs the tool installed. record stops a command, and anything it started, after two minutes and keeps the output up to then, with a warning, or as soon as its output shows the run's until text, which is how to record a server starting; when a command exits, anything it left running in the background is stopped too. Commands run at the size of the terminal they run in when it has cols and rows (terminal.open({ cols: 100 })), so their lines wrap as they will on screen, and at 120×36 otherwise, since a terminal that fits its window has no size until it opens. They run through a shell with FORCE_COLOR=1 and a 256-color TERM, but without a pseudo-terminal, so a tool that checks for one prints its plain output: no progress bars, ls one name per line, git without colour. Ask for what you mean to show: ls -C --color=always, git -c color.ui=always status. A full-screen program (an agent's TUI, an editor) needs a real terminal: record it with asciinema and play the recording:
import { createDemo, readAsciicast } from "@reelscript/cli";
const cast = readAsciicast("claude.cast"); // asciinema rec claude.cast, v2 or v3; relative to the script
demo.terminal.open({ cols: cast.cols, rows: cast.rows, lineHeight: 1 });
demo.terminal.run("claude", { events: cast.events });A recording as wide as your own terminal needs a window as big: give open() a width and height, or a smaller fontSize. check warns when the terminal's cols and rows don't fit its window, since the rest would be cut off.
See examples/terminal.ts.
createDemo({ keystrokes: true }) names each shortcut as it's pressed, in a pill near the bottom of the frame: ⌘K, ⇧ ⌘ P, Esc, ↩, as the laptop writes them (⌘ on the Mac, Ctrl on the PC). It shows the shortcuts press() presses and the ones the editor's openFile() and command() press, not typing, whose text is on screen already. It's drawn over the finished frame, so a zoom, the follow camera or a desk view never moves or crops it, and costs next to nothing to render.
const demo = createDemo({
keystrokes: {
show: "chords", // or "all", for every press()
position: "bottom", // "bottom-left", "bottom-right", "top", or { x, y }
hold: 1000, // ms on screen after the key goes down
css: ".keystrokes { background: #4f46e5 }", // restyle it: .keystrokes is the pill, .key each key
},
});
demo.press("Meta+K");
demo.press("Meta+Shift+P", { keystroke: false }); // not this onedemo.keyboard.open(); // a panel, lower right: the laptop's keys with the hands on them
demo.press("Meta+Shift+P"); // the hands press it, the keys light, and "⇧ ⌘ P" shows under them
demo.type("#name", "Acme Q3 Launch"); // typed on the keys too
demo.desk.to("wide"); // the whole frame becomes the desk, the demo on the laptop's screen
demo.wait(2000);
demo.desk.out(); // and flies back to the flat desktopThe desk is a laptop on an oak table in a daylit room, with the demo on its screen and two hands on its keys, drawn in 3D a frame at a time like everything else, so it renders the same every time. createDemo({ desk }) sets it up once: laptop is "mac" (a silver MacBook, ⌘, a Mac's key names) or "pc" (a slim graphite Windows laptop with a PC's keys; the browser and VS Code act as on Windows, so a PC demo presses PC shortcuts, Control+K; the keys shown are the keys pressed, Meta being its Windows key, and ControlOrMeta is the laptop's own (⌘ on the Mac, Ctrl on the PC), for a script that runs on either; the desktop's look stays the theme's); hands: false leaves the hands out; handColor, sleeve and backdrop are CSS colours for the skin, the sleeves and the float view's studio; surface is the desk, "oak" (the default, a light oak table) or a CSS colour.
keyboard.open() shows it as a panel over the keys. While the panel is open, every key the demo presses or types plays on it: press(), type(), a terminal command as it's typed, and the editor's Quick Open and Command Palette. A chord shows as a label under the keys (labels: false leaves it out). The panel stays in front of the other windows, and over the camera: a zoom or the follow camera never moves or crops it. It takes no input: keys still go to the window that had them, and a click where the panel is fails at its line (move it with keyboard.place() or close it first). It's placed, moved and closed like any window, and keyboard.open() on the open panel changes what it's given and keeps the rest. A key a laptop leaves out shows as fn with the key it shares, as a person presses it (PageDown is fn ↓ on a MacBook; Home, End and forward Delete too); a key the laptop hasn't got at all (press("Meta+F13")) fails at the line while the panel is open or a desk view is on, and a character it can't type (é), a key pressed in a desk view while the lid is down, or a step aimed at something the panel covers, is a warning, since the viewer would be shown nothing.
desk.to(view) flies the whole frame to the desk, like a zoom, alongside the actions that follow: "screen" (the laptop's screen, a little from the side), "wide" (the laptop and the hands, on the desk), "keys" (over the keys) or "float" (the laptop and an iPad floating in a studio, both showing the demo). The flat desktop, with everything on it, is what's on the laptop's screen, so the flight starts from the frame as it was; close the keyboard window first if you don't want the panel on the laptop's screen too. desk.out() flies back. Both take duration (default 1400) and ease; a flight begun while another is under way carries on from where that one had got to. The hands press keys in the desk views too.
demo.desk.to("float"); // the MacBook and the iPad, floating
demo.desk.to("float", { orbit: 35, tilt: 8 }); // the camera swings round them
demo.desk.lid(0, { duration: 1200 }); // the lid shuts...
demo.desk.lid(110); // ...and opens
demo.desk.to("wide", { hands: false }); // the laptop alone on its deskA view takes the camera somewhere else with orbit (degrees round what it looks at, to the right; negative to the left), tilt (degrees up, or down, from -30 to 80; the camera stays above the table, and the hands step back from a camera that comes right up to them) and distance (times as far, from 0.3 to 4); hands: false sends the hands out of the shot, and they're never in "float". Between two shots of the same view the camera travels round through the angles between, so desk.to("float", { orbit: 360 }) after desk.to("float") is a full turn. The iPad shows the same picture as the laptop, cut to its screen from the middle; ipad: false leaves it out, with the laptop alone in the middle of the shot. desk.lid(degrees) moves the lid in the desk views, from 0 (shut) to 135, alongside what follows (on the flat desktop the laptop's screen is the frame, plainly on, so the keyboard window shows it open); it starts at 107, and the hands leave the keys as it comes down. The flat desktop is the screen seen square on, so a flight to or from it opens a lower lid as the camera comes to the screen, and lowers it again as it leaves. desk.backdrop is the studio's colour. In a desk view the camera is the desk's: a zoom carried in eases to rest, the follow camera rests on the whole desktop, and zoom.to() and zoom.out() are errors at their line (move in with distance), until desk.out() has landed back on the flat desktop. A camera low over the hands looks along the forearms, so the hands step back for it. See examples/keyboard.ts, examples/devices.ts and examples/spin.ts.
The desk is drawn in 3D in software (the render needs no GPU, and so renders the same everywhere), which is slow: while the keyboard window or a desk view is on screen, a second of video takes about a minute to render on an M-series laptop, where a plain desktop takes about three seconds. preview --at still renders a frame in a few seconds, so tune the shots with it and render once.
The laptops are based on "MacBook Pro 16" 2021" by rtql8d and "Modern Slim Laptop" by Blaž Mraz (both CC-BY 4.0, see assets/macbook and assets/pclaptop), with reelscript's own keys on them. The hands are the WebXR generic hand models (MIT, see assets/hands), posed by reelscript. The light is photographed: a daylit loft lights the desk and is the room behind it, out of focus, and a photo studio's softboxes light the float view, both HDR panoramas from Poly Haven (CC0), as is the oak (see assets/studio). The laptop's screen takes the desktop's aspect ratio, whatever desktop size is set.
say() uses Kokoro-82M through the optional kokoro-js dependency, loaded only when a script calls say(). The model, about 90 MB, downloads on first use; npx reelscript warmup narration fetches it ahead of time, and the container has it built in. Spoken lines are cached by text and voice, so re-renders don't re-synthesize unchanged lines. Voices include af_heart (the default), af_bella, am_michael, bf_emma, and bm_george. pronunciations respells words the voice gets wrong, e.g. { Reelscript: "Reel script" }.
GIF output has no audio track; narration still paces the timeline. Render to .mp4 for sound.
To use another voice engine, pass tts: any object with a stable id (part of the cache key) and synthesize(text, { voice, speed }) returning { audio, sampleRate }, where audio is mono Float32Array samples in [-1, 1]. Give it a voices list so check rejects a misspelt voice, a defaultVoice for lines that name none (otherwise the first of voices), a ready() that throws as synthesize would if the engine can't run here (a missing package, say) so check catches it without synthesizing, and a dispose() if it holds a model to release once narration is synthesized. kokoro(model?) builds the default engine. Don't install with --omit=optional to skip Kokoro: npm also ships sharp's platform binaries as optional dependencies, and image processing breaks without them.
check is the guard. It drives the real app through the whole timeline, cursor and all, with nothing captured or encoded, in a few seconds, and fails when the UI changes under a script, pointing at the line. Things that may or may not be what the script means (a page that answered 404, a mock no request used, several elements matching a selector) are warnings at their line; check --strict makes them fail too, as CI should. Narration is timed with the real length of lines already spoken in an earlier render or preview, and estimated for new ones:
reelscript: target "#new-project" was not found in the browser window
at demos/signup.ts:14:19 (cursor.moveTo)
Run it on every pull request next to your tests, and render on main.
The container is the canonical render environment, for amd64 and arm64. Chromium, ffmpeg, fonts, VS Code, and the narration model are built in, so a script renders the same demo on every machine (in browser and terminal windows; see Editor demos for VS Code's real-time clock):
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/work" -v reelscript-cache:/cache \
ghcr.io/trevin-lee/reelscript:0.6.0 render demos/signup.tsInside the container, localhost is the container itself, not your machine: reach an app running on your machine at http://host.docker.internal:3000 (Docker Desktop), or run the container with --network host (Linux), or point the demo at a preview deployment. Use the tag that matches your npm version (latest is the newest release). Ctrl-C or a stopped CI job cleans up after itself: VS Code is stopped and no partial video is left beside the output. --user makes the files it writes yours; without it, on Linux they belong to root. What a render adds, editor extensions and spoken lines, goes in /cache; mount a volume there, as above, to keep it between runs. In GitHub Actions:
jobs:
demo:
runs-on: ubuntu-latest
container: ghcr.io/trevin-lee/reelscript:0.6.0
steps:
- uses: actions/checkout@v5
# Start the app the demos drive (or point them at a preview deployment instead).
- run: npm ci && (npm start &) && npx --yes wait-on http://localhost:3000
- run: reelscript check --strict demos/*.ts # --strict: warnings fail too
- run: reelscript render demos/signup.ts
- uses: actions/upload-artifact@v5
with:
name: demo
path: demos/out/Scripts may import { createDemo } from "@reelscript/cli" without installing the package; the CLI resolves it to its own copy.
reelscript mcp is a Model Context Protocol server, so Claude Code or any MCP client can write and iterate on demos itself:
claude mcp add reelscript -- npx -y @reelscript/cli mcp
npx @reelscript/cli warmup browser # once: the browser it drives| Tool | What the agent gets |
|---|---|
reelscript_docs |
This README. |
inspect_page |
Visible buttons, links, and inputs on a URL or a local page, each with a suggested selector and position, plus a screenshot, as a demo's browser sees them. Takes a session for pages behind a sign-in, a clock ("now" or an ISO date) and timezone like a demo's, and the demo's laptop ("pc" sees the page as Chrome on Windows does). |
record_script |
Runs scripts' real terminal commands and saves recordings; with prune, also removes recordings the listed scripts made and no longer use. Strict by default: a command that failed without the run's exitCode saying so fails it, and the previous recordings stay. |
check_script |
Runs the scripts' timelines without rendering; passes, or names the script line that failed. Strict by default, as check --strict in CI: warnings fail too. |
preview_frame |
The frame at a given second, as an image the agent can look at (imageWidth scales it down). |
render_script |
The final .mp4 or .gif of each script. With strict, warnings fail and the previous video stays. |
check_script, preview_frame, and render_script click through your real app and run the script's call() code, and record_script runs shell commands, so none of them is marked read-only.
reelscript render <script> [more...] [--out demo.mp4] [--strict]
render scripts to .mp4 or .gif
reelscript preview <script> [--at <seconds>] [--out frame.png]
render one frame as a PNG (default:
preview-<seconds>s.png here)
reelscript check <script> [more...] [--strict] run the timeline without rendering; fail on
anything missing, with the script line;
--strict fails on warnings too (for CI)
reelscript record <script> [more...] [--prune] [--strict]
run terminal commands for real and save
recordings; --prune deletes unused ones
reelscript login <url> [--out session.json] [--laptop pc] sign in once in a real browser; save the session
reelscript warmup [browser] [narration] [editor] [--with-deps]
download the browser, voice model and VS Code
(all three when no part is named, ~800 MB);
--with-deps adds Chromium's Linux libraries
reelscript cache [clear <part...|all>] show or clear what's cached on disk
reelscript mcp MCP server (stdio) for coding agents
reelscript --version
A script must call demo.render(); if it finishes without doing so, every command fails rather than passing silently. Options a command doesn't take are errors, and --help works after any command.
| Environment variable | Effect |
|---|---|
REELSCRIPT_CACHE |
Cache folder. Default: ~/.cache/reelscript |
REELSCRIPT_FFMPEG |
ffmpeg to use instead of the bundled one |
REELSCRIPT_CODE_SERVER |
code-server binary to use instead of downloading one |
REELSCRIPT_MODELS |
Folder for the narration model |
reelscript keeps downloads and generated audio in one folder, ~/.cache/reelscript unless REELSCRIPT_CACHE says otherwise, except the browser, which lives in Playwright's folder (~/Library/Caches/ms-playwright on macOS, ~/.cache/ms-playwright on Linux, or PLAYWRIGHT_BROWSERS_PATH). reelscript cache lists them all:
| Part | What it holds |
|---|---|
browser |
Chromium and its headless shell, about 500 MB, shared with other projects on the same Playwright version |
editor |
VS Code (code-server), about 200 MB compressed |
extensions |
Editor extensions installed from Open VSX or .vsix |
narration |
Voice models: Kokoro, about 90 MB, and any other downloaded with kokoro(model) |
narration-clips |
Spoken lines, reused while their text and voice are unchanged |
reelscript cache clear <part...> deletes the parts you name and reelscript cache clear all deletes everything in reelscript's folder; each is downloaded or generated again when next needed. The browser, shared with other projects in Playwright's folder, is deleted only by name, reelscript cache clear browser, and reelscript warmup browser installs it again. In the container, the browser, VS Code and the voice model are built in and aren't cleared. If you set REELSCRIPT_MODELS, clearing narration removes only the default Kokoro model's folder inside it, and a code-server set with REELSCRIPT_CODE_SERVER is never deleted.
| Call | What it does |
|---|---|
createDemo(options) |
Start a demo. Options below. |
demo.render(path) |
Render to .mp4 (H.264, with narration) or .gif (palette-optimized, silent); any other extension is an error. path is relative to the script. Resolves once the video is written, with { command, out, durationMs, frames, width, height }; out is where it went (--out, when given), so the script can go on to cut or caption it. The script also runs under check, preview and record, which write no video (preview writes a frame): command says which ran it, so cut or caption when it's "render". |
demo.getTimeline() |
The actions queued so far, for tests. |
readAsciicast(path) |
An asciinema recording as { events, cols, rows } for terminal.run(cmd, { events }). |
demo.cursor.moveTo(target, { ease, duration, window }) |
Glide to a target. duration defaults from the distance, ease to smooth. Eases: smooth, snappy, overshoot, linear. |
demo.cursor.click({ button, duration, dialog }) |
Click at the cursor, with a ripple, taking duration ms of video (default 180). Focuses and raises the window under the cursor. duration: 0 takes no video time, so a following waitFor cuts straight to the page the click led to. dialog: "accept" or "dismiss" answers an alert(), confirm() or prompt() the click opens, as meant (no warning); otherwise it's answered OK with a warning. |
demo.type(selector, text, { wpm, window }) |
Focus a field and type at wpm (default 300), in the focused window or the one named by window. Like moveTo, the field must be on screen. |
demo.press(key, { window, keystroke }) |
Press a key or chord, e.g. "Enter", "Meta+K", in the focused window or the one named by window. Not in the terminal window, whose input is terminal.run(). With the keystroke overlay on, keystroke: false hides this press from it and true shows it whatever its show says. |
demo.wait(ms) |
Hold, on camera. |
demo.scroll(selector | { by, to }, { duration, ease, window }) |
Scroll, on camera, stepped with the frame clock: to center an element (scrolling its nearest scrollable container), or by / to a position on the page. duration defaults from the distance, ease to smooth. |
demo.waitFor(selector, { window, timeout, settle }) |
Hold, off camera, until the selector is visible (for timeout ms of real time, default 15000), then run the page's clock settle ms more (default 0). The page keeps running, so its loading isn't filmed. |
demo.call(({ page, context }) => ...) |
Run your code at this point, off camera, and wait for it. Gets the focused window's Playwright page and the browser context. |
demo.zoom.to(target, { scale, duration, ease, window, within }) |
Animate a zoom centred on a target (scale 1.6, duration 700, ease smooth by default); runs alongside the actions that follow. within: "window" keeps the view inside the target's window. With camera: "follow", it takes the camera over until zoom.out(). |
demo.zoom.out({ duration, ease }) |
Return to the whole desktop (duration 700, ease smooth by default). |
demo.say(text, { voice, speed }) |
Queue narration. Starts now or after the previous sentence, while following actions run. voice defaults to the demo's (af_heart), speed to 1. |
demo.waitForNarration() |
Hold until everything queued with say() has been spoken. |
demo.browser.open({ x, y, width, height }) |
Open the browser window without navigating. Optional; goto() opens it too. |
demo.browser.goto(url, { hold, status }) |
Navigate to a URL, or to a local page by its path relative to the script (a ?query or #hash is kept), then hold on the loaded page for hold ms (default 400). A page that answers with an HTTP error is a warning unless status says it's meant (a demo of your 404 page); a local page that isn't there fails. settle is the old name for hold and still works. |
demo.browser.mockAPI(pattern, json, { status }) |
Answer matching requests from the browser window with canned JSON and status (default 200). pattern is a path ("/api/projects": that path on any origin, with any query), a URL glob ("**/api/projects*"), or a whole URL; a mock no request matched is a warning. It answers every method (a POST and a GET to the path get the same JSON), and a later mockAPI() on the same pattern replaces it from that point: mock the list again after the step that creates something. A mock lasts to the end of the demo; it can be replaced, not removed. Opens no window. (The editor is VS Code's own page and isn't mocked.) |
demo.terminal.open({ title, prompt, fontSize, lineHeight, cols, rows, x, y, width, height }) |
Open a terminal window (title "zsh", prompt "~ % ", fontSize 15 by default). cols and rows fix its size in characters; lineHeight: 1 (default 1.3) joins block characters, as full-screen programs expect. |
demo.terminal.run(cmd, { output, events, duration, wpm, speed, maxGap, prompt, cwd, exitCode, until }) |
Type cmd at wpm (default 300). With output, stream that text over duration (default 150 ms plus 60 a line, at most 2500); with events, play those timed chunks; with neither, replay its recording. Recorded and timed output play at speed (default 1) with silences capped at maxGap ms (default 700). prompt: false leaves the command running for print(), and a string changes the prompt from then on (after a cd, say), as open({ prompt }) sets it. cwd, relative to the script, is where reelscript record runs it; exitCode is the code it's meant to exit with, for a demo of a failure (otherwise a non-zero exit is a warning, at record and at every check); until is text that tells record a command that keeps running (a dev server) is up, so it stops there instead of at its two-minute limit. |
demo.terminal.print(text, { duration, events, speed, maxGap, prompt }) |
More output with no command typed, after a run with prompt: false; duration, speed and maxGap as on run. |
demo.editor.open({ workspace, extensions, settings, notifications, x, y, width, height }) |
Open VS Code on a copy of a folder. extensions are Open VSX ids, .vsix files, or extension folders. settings merge over demo-friendly defaults; notifications: true shows VS Code's toasts (off by default). Reopening with a different workspace, extensions, or settings starts a fresh VS Code. |
demo.editor.openFile(path, { wpm }), demo.editor.command(name, { wpm }) |
Quick Open (⌘P) or the Command Palette (F1), typed visibly at wpm (default 300). Fails if VS Code finds no match. |
demo.editor.type(text, { wpm }) |
Bring the editor forward and type at its caret, at wpm (default 300). If the keyboard is elsewhere (the Explorer after clicking a file), it goes to the open file first; with no file open, it fails. Auto-closing brackets and auto-indent are off, so typed code lands as written. |
demo.keyboard.open({ labels, x, y, width, height }) |
Open the keyboard window: the desk's keys with the hands on them, as a panel (lower right, 42% of the desktop wide and at most 560 pixels, by default). Every key pressed or typed while it's open plays on it; a chord shows as a label unless labels: false. It never takes typing. |
demo.desk.to(view, { duration, ease, orbit, tilt, distance, hands, ipad }), demo.desk.out({ duration, ease }) |
Fly the frame to a view of the desk ("screen", "wide", "keys", "float") and back, alongside following actions (duration 1400, ease smooth by default). orbit and tilt move the camera round the view in degrees, distance times as far; hands: false leaves the hands out of the shot, ipad: false the iPad out of "float" (it's an error on another view). |
demo.desk.lid(degrees, { duration, ease }) |
Move the laptop's lid, from 0 (shut) to 135; it starts at 107. Alongside following actions (duration 1400). |
demo.editor.file(name), demo.editor.tab(name) |
Selectors for an Explorer row and an editor tab, by the file's exact name, or the folder it's directly in and its name ("src/app.ts") when two files share a name. A row is on screen only once its folder is expanded. |
.focus(), .place({ x, y, width, height }), .close() on browser, terminal, editor (and .place(), .close() on keyboard, which is always in front) |
Bring forward, move or resize (x, y is the frame's corner on the desktop; width, height the content size), or take off the desktop; a closed window can be opened again. |
createDemo options:
| Option | Default | What it does |
|---|---|---|
viewport |
[1280, 800] |
Content size of the first window opened. |
desktop |
first window plus margins | Output size, rounded up to even numbers as H.264 needs. |
theme |
"macos" |
"macos" draws a desktop, menu bar, and window frames; "bare" shows window content only. |
fps |
60 |
Output frame rate. |
camera |
"manual" |
"follow" zooms toward clicks and typing on its own, except between a zoom.to() and the next zoom.out(); { scale, hold } tunes it (defaults 1.5 and 1200 ms). |
clock |
"2025-09-23T09:41:00" |
The moment the demo happens at: a Date, or an ISO string, read in timezone unless it has an offset. new Date() or "now" gives the real time. |
timezone |
"UTC" |
IANA timezone for the page and the menu bar. |
session |
none | A saved login from reelscript login, relative to the script. |
desk |
{ laptop: "mac" } |
The desk behind the keyboard window and the desk views: laptop "mac" or "pc", hands (default true), handColor, sleeve and backdrop as CSS colours, and surface ("oak" or a CSS colour). |
wallpaper |
the theme's gradient | A CSS colour or gradient, or an image file (relative to the script), covering the macOS theme's desktop. |
keystrokes |
off | true, or { show, position, hold, css }: name each shortcut pressed in a pill over the frame. show is "chords" (a key with ⌘, Ctrl or ⌥, or a key such as Enter, Escape, Tab, an arrow or an F key; the default) or "all"; position is "bottom" (default), "bottom-left", "bottom-right", "top" or { x, y }, the pill's centre in the video's pixels; hold how long each stays up after its key goes down (1000 ms); css restyles it (.keystrokes the pill, .key each key). |
address |
none | (url) => string, rewriting what the browser's address pill shows. A local page (goto("./app.html")) shows a blank pill, and address gets its file:// URL: address: () => "app.acme.com" names a site. |
menubar |
{ app: "reelscript" } |
The macOS menu bar's text: app, and clockText for what its clock shows, which is the demo's clock unless you set it. false removes the bar. |
voice |
"af_heart" |
Narration voice. With your own tts engine, the default is its defaultVoice, or the first of its voices. |
tts |
Kokoro | Voice engine; see Narration. |
pronunciations |
none | Respellings for the voice, e.g. { Reelscript: "Reel script" }. |
gif |
{ width: 960, fps: 20 } |
Size and frame rate for .gif output. The default width never scales a narrower video up. |
recordingsDir |
"recordings" |
Where terminal recordings live, relative to the script. |
deterministic |
true |
Step the page's clock one frame at a time. false lets the page run in real time (the date is still pinned). |
verbose |
true |
Print progress and the output summary. |
Pre-1.0: the API can still change between minor versions, and the changelog says how. Roadmap, roughly in order:
- Captions generated from narration (SRT and burned-in)
reelscript generate: point it at a URL with a sentence and get a scriptwatchmode with live preview while editing a script- Window open and close animations, and drag-to-move
- Editor: seed the integrated terminal, and place the caret per file
- Pseudo-terminal recording for TTY-only tools
- Retina (2x) output
- Transitions between scenes, and callouts
- A Linux desktop backend for demos of native apps
MIT
