@kirti_jha/reel
v0.4.0
Published
Demos-as-code for web apps and CLIs. A small YAML spec drives your real app and renders always-current GIF, MP4, storyboards and a self-contained interactive click-through — and fails your build when the flow breaks.
Maintainers
Readme
VHS films terminal demos beautifully. Reel films web apps and command-line tools from one spec grammar — and adds four things a screen recorder can't:
- CI-native drift detection. Your demo is a test. If a step can't complete, the build fails; on merge, the media updates itself.
- Byte-identical output. The same spec renders the same bytes on any machine, so committed demo media changes only when the demo does.
- An interactive click-through. The same spec builds a self-contained product tour with hotspots, deep links and branches the viewer chooses.
- AI-native authoring. Describe the demo in English; an agent drives your app and writes a spec you own.
100% MIT, no SaaS, no hosting. A CLI + a GitHub Action. Everything runs locally and in CI.
Quick start
npm i -D @kirti_jha/reel # or: npm i -g @kirti_jha/reel
npx playwright install chromium
npx reel doctor # confirm this machine can record
# Scaffold a spec, then record it
npx reel init
npx reel record demo.reel.yamlDon't want to write the first spec by hand? Drive your app and let Reel write it down:
npx reel capture --url http://localhost:3000 -o demo.reel.yamlreel doctor is worth the ten seconds. Reel leans on a Chromium build and an
ffmpeg binary, and when either is missing or mismatched the error arrives from
deep inside a library, phrased for that library's maintainers. Doctor checks
each one by using it, and tells you the command that fixes it.
Once it's a dependency, npx reel resolves to the local binary. To try it
without installing anything, use the scoped name directly —
npx @kirti_jha/reel init.
Working in a clone of this repo instead? The package's own binary isn't linked
into node_modules/.bin, so go through the script:
npm install
npm run reel -- record examples/taskflow/demo.reel.yamlThat example boots its own app and records a GIF/MP4/storyboard.
Reel Studio (web UI)
Prefer a UI? Reel ships a local Studio (Next.js + Tailwind, in this repo under
studio/) with everything the CLI does — AI authoring, a spec editor, output
options, record with a live preview, drift-check, and self-heal.
npm run studio:install # one-time: install the studio's deps
reel ui # starts the API + UI and opens your browserIt runs entirely locally: an in-process API (Playwright/ffmpeg) plus the Next.js frontend, which proxies data and media back to it.
Capture — author by doing
reel capture --url http://localhost:3000 -o demo.reel.yamlYour app opens in a real browser. Use it the way you'd demo it; a toolbar at the bottom tracks the steps and asks for the two things a recorder can't infer — captions, and where the beats fall. Press Finish and you have a spec that already replays.
The interesting part is which selector each step gets, and it's ranked by stability of meaning, not convenience: a test id is a contract, an accessible name is a promise to users, a CSS path is neither. Two rules do most of the work:
- Ambiguity is resolved by saying where, never by index. A docs site has a
Tutorial link in the nav and another in the hero, so
role=link[name=Tutorial]is qualified tonav >> role=link[name=Tutorial]— Playwright's own chaining operator, and the region has to be one the app already treats as a region (a nav, a dialog, a row) and be unique on its own terms. When nothing makes the name unique it is discarded:text=Deletematching four rows isn't "the first Delete", it's a selector that will act on whichever row the layout puts first tomorrow. - Framework-minted names are rejected.
:r3:,mui-4821,css-1x2y3z4aare stable within a page load and worthless across one, which is the worst kind of failure to debug.
What comes out is a draft, and it says so in a comment at the top. Capture sees what you clicked; it can't see which moment was the point. Anything it couldn't write down is reported rather than dropped in silence.
Demos behind a login
Don't film the sign-in. It's the slowest, most fragile part of any app, it may involve 2FA or an email link, and nobody watching your demo wants to see it. Sign in once, off camera, and replay that session:
# Log in by hand in the window that opens, then press Finish.
reel capture --url https://app.example.com --save-auth .auth/demo.jsonThe captured spec comes back already pointing at the session, so it replays
authenticated from the first reel record:
url: https://app.example.com
storageState: .auth/demo.jsonRe-capture with --auth .auth/demo.json to start signed in and keep authoring
from there. Any spec can use storageState: on its own — the file is Playwright's
format, so npx playwright-core codegen --save-storage=.auth/demo.json <url>
produces one too.
That file is a credential, not config. It holds live session cookies: anyone who gets it is signed in as you until the session expires. Reel checks
git check-ignoreafter writing one and tells you if git can still see it, and this repo's.gitignorecovers.auth/andauth.json. Never commit one, and re-capture when it expires.
There is deliberately no way to put a password in a spec — no ${ENV}
interpolation, so a typed credential would have to be committed in plaintext.
If you must script the login itself, drive it once with capture --save-auth
and let the saved session stand in for it from then on.
Starting signed out, and crossing over
storageState: authenticates the whole run, which is right when the demo
begins inside the product and wrong when it opens on a marketing page, a
logged-out home screen or a paywall — the story needs to start signed out.
signIn is that crossing:
url: https://app.example.com
steps:
- caption: "Everyone starts here — signed out"
- card: { title: "Sign in", subtitle: "off camera" }
- signIn: .auth/demo.json # or: { state: .auth/demo.json, goto: /dashboard }
- caption: "…and you're straight into the product"It applies the saved session to the browser that is already running and reloads, so one continuous demo shows the logged-out page and then the product. No jump cut between two renders, one camera path, one caption timeline. Cookies go to the context; local storage is written on the page already on screen, and any other origin the session covers (an auth server, say) is restored from a page the camera never films.
It is still a saved session rather than a scripted login, for the reason above — the demo never types a password because there is nowhere to put one.
Two things it will tell you rather than let you find out from the render: a session file whose cookies have expired (the app would quietly render logged out and every step would still pass), and one that holds no session at all — what you get when Finish is pressed before the sign-in completes.
Editor support
Every spec reel init or reel capture writes opens with a schema line, so
VS Code (with the YAML extension), JetBrains, and Neovim complete step kinds,
enumerate frame: and theme: values, and underline a misspelled key:
# yaml-language-server: $schema=https://raw.githubusercontent.com/KirtiJha/reel/main/schema/reel.schema.jsonThe schema is generated from the same zod schema the driver validates
against — a hand-maintained second copy would drift, and autocomplete that
suggests a key the driver rejects is worse than none. The hover text is the
documentation from Reel's own source. For an editor with no network access,
reel schema --out reel.schema.json vendors a copy.
The spec
A demo is a small, reviewable file that diffs cleanly in PRs. Waits are on states, not time — the reliability moat. One spec renders every format.
name: TaskFlow — add and complete a task
url: http://localhost:4321
viewport: { width: 1000, height: 720, scale: 2 }
theme: dark
run: # optional: boot the app under test
cmd: node server.mjs
readyOn: http://localhost:4321
deterministic: # make real apps reproducible
disableAnimations: true
freezeClock: "2026-01-01T09:00:00Z"
locale: en-US # pin formatting — a frozen clock isn't enough
timezone: UTC
polish:
zoom: auto # camera eases toward the active element
cursor: smooth
captions: true
frame: browser # none · browser · window (device chrome)
background: "linear-gradient(135deg, #2b3a67, #1a1f36)"
steps:
- caption: "Meet TaskFlow — capture work in a snap"
- beat: hero
- type: { selector: "#task-input", text: "Ship the Reel demo" }
- click: role=button[name=Add]
- waitFor: text=Ship the Reel demo # state-based, not sleep()
- caption: "Click a task to complete it"
- click: text=Ship the Reel demo
- beat: done
output:
preset: share # share · readme · social · hq · docs
mp4: out/taskflow.mp4
gif: out/taskflow.gif
webm: out/taskflow.webm
storyboard: out/storyboard
html: out/taskflow.html # self-contained interactive click-throughScene grammar — demos that are directed, not just recorded
A screen recording is one continuous take. Reel gives a demo structure, so it reads like something a person made on purpose:
steps:
- card: { title: "TaskFlow", subtitle: "Capture work in a snap" } # open on a title
- caption: { text: "Add a task in one click", ms: 1800 } # timed, wraps, fades
- click: role=button[name=Add]
- expect: { selector: "#list li", count: 1 } # assert, don't assume
- callout: { selector: "#count", text: "The counter tracks what's open" }
- scroll: { to: "#pricing", ms: 1100 } # cinematic pan
- zoom: { to: "#pricing h2", level: 1.7 } # explicit camera
- zoom: out
- card: { title: "One spec. Every format." } # close on a title| step | what it does |
|---|---|
| card | Full-screen title card — opens, closes, or separates chapters. Also lands in the storyboard. |
| callout | Spotlights an element: everything else dims, an accent ring draws around it, an optional label explains it. |
| scroll | Eased travel down a long page. Also asserts nothing — pair with expect. |
| zoom | Explicit camera control (to, level, ms), or out for a wide shot, when auto-zoom's guess isn't your story. |
| expect | An assertion on text, count, or visibility — what makes reel check a real smoke test. |
| caption | Now takes { text, ms, position }; it wraps at real word boundaries and fades in and out. |
Run the bundled example to see all of it at once:
npm run reel -- record examples/taskflow/showcase.reel.yamlScrolling is rendered, not recorded. Capturing a live scroll races Chromium's
rasterizer — roughly a third of frames come back with a blank band where the
compositor hasn't caught up, and no combination of launch flags or slower
scrolling avoids it. So scroll takes one settled full-page capture and pans a
viewport-sized window down it with easing: artifact-free, at the full output
frame rate, for the cost of a single screenshot. The tradeoff is that
position: fixed elements are captured once, at the top — pages with sticky
headers should use scrollTo, which jumps instead.
Dragging is the gesture a click grammar can't express, and without it a kanban board, a flow builder, a slider or a reorderable list can't be demoed at all:
- drag: { from: "#card-ship", to: "#doing" } # onto another element
- drag: { from: role=button[name=Filter], to: { x: 640, y: 320 } } # or a point
- drag: { from: "#node", to: "#canvas", ms: 1400 } # slower, for a long travelIt presses, walks the path in steps, then releases — not Playwright's
press-move-release, which plenty of apps read as a click because a board that
reorders on pointermove never sees the pointer move. Walking it is also what
makes it watchable: a card that teleports isn't a demo of dragging.
Prefer naming the destination. A point is there for dropping onto empty canvas,
where there is genuinely nothing to name — and reel capture says so in its
skipped list when it has to fall back to one, because a coordinate is a promise
about the layout rather than about the app.
Capture records drags too, which took finding: press and release on different elements fire no click at all, so before this the whole gesture produced no event, no step, and nothing in the skipped list either. The demo silently lost the thing it was about.
Interactive HTML — the format a GIF can't be
A recorded demo is passive: the viewer watches at your pace. Add one line and the same spec also builds a self-contained click-through — every element you acted on becomes a hotspot, and the viewer advances at their own pace:
output:
gif: docs/demo.gif
html: docs/demo.html # one file — no hosting, no scripts, no trackingHotspots on the exact elements the demo interacted with, pulsing in your accent colour.
Chapters from your title cards, as a clickable rail.
Deep links. Every scene is addressable —
demo.html#/create-a-projectfor a chapter,#/step-4for anything else — with working Back and Forward, and a Copy link button. Link a support reply straight at the step that matters.Autoplay that breathes. Paced by the durations actually recorded, so a title card lingers and a click doesn't. The progress track fills in real time.
Keyboard, touch and screen readers.
←→HomeEndto step,spaceto play, swipe on a phone, a live region announcing each step, visible focus rings, andprefers-reduced-motionrespected.Light and dark, following the viewer's system preference.
Embeddable.
?embed=1drops the chrome for an<iframe>, and the player reports progress to the host page and takes commands back:frame.contentWindow.postMessage({ type: 'reel:go', index: 4 }, '*'); window.addEventListener('message', (e) => { if (e.data.type === 'reel:scene') console.log(e.data.index, e.data.chapter); });Same-origin hosts can skip the messaging and use
frame.contentWindow.reelDemodirectly.One file. Frames are embedded as data URIs — the showcase demo is ~205 KB for 10 scenes. Open it locally, commit it to
docs/, or drop it on any static host. Nothing phones home.
This is the shape interactive-demo SaaS sells; here it falls out of the spec you already wrote. It uses the raw frames rather than the polished video, so hotspot coordinates map exactly to what you see.
npm run test:player drives a generated build in a real browser — 26 checks over
the router, autoplay, embed mode, the host API and the accessibility surface.
Branching — let the viewer choose
Some demos have more than one story. A branch step forks the flow, and the
click-through lets the viewer pick:
steps:
- type: { selector: "#task-input", text: "Ship the Reel demo" }
- click: role=button[name=Add]
- branch:
prompt: "What do you want to see?"
paths:
- label: "Complete a task"
default: true # the path the video follows
steps:
- click: text=Ship the Reel demo
- expect: { selector: "#count", text: "0 of 1" }
- label: "Add a second task"
steps:
- type: { selector: "#task-input", text: "Write the README" }
- click: role=button[name=Add]
- caption: "Both paths end up here" # the shared continuationA video is linear, so the GIF/MP4 follows the path marked default: true.
The interactive build carries all of them — it's the one format that can.
An app has state, so alternates can't be spliced in afterwards. Reel replays the steps leading up to the branch and then records that path, which is the only approach that holds for an app it knows nothing about. The replay is silent: no frames, no demo time. Alternates are captured as stills, so they can never leak into the video.
Every path is checked. reel check walks all of them, so a branch the video
never shows is still a tested flow — break an assertion on the path not taken and
the build fails.
Two constraints worth knowing:
- A fresh browser context resets cookies and storage, not a server's
database. For a server-backed app, pin responses with
mock:so every path starts from the same state. - Branches don't nest in v1 — a nested branch is a spec error, not a silent no-op. Recording a tree deeper than one level multiplies the trunk replays.
See examples/taskflow/branching.reel.yaml,
and npm run test:branch for the end-to-end checks.
Delivery presets — the same demo, shared many ways
A demo isn't only a README GIF. Reel captures the flow once and renders whatever
you're sharing into — pick a preset (or override any field: fps,
maxWidth, gifFps, gifMaxWidth, gifColors).
| preset | best for | tuned for |
|---|---|---|
| share (default) | anywhere — chat, docs, PRs | balanced quality/size |
| readme | README hero GIFs | smallest GIF (~640px, trimmed palette) |
| social | Twitter/LinkedIn embeds | crisp, video-first |
| hq | landing pages, decks | maximum fidelity |
| docs | product docs | clean, moderate |
Formats are chosen by which paths you set: mp4 (crisp video, best for social/
embeds/docs), gif (lightweight loop for READMEs and chat), webm (small modern
video), storyboard (a PNG per beat), and html (the interactive click-through
above). The MP4 is the highest-quality artifact; the GIF is intentionally the
lightweight one. An html-only spec skips video encoding entirely, so it's fast.
Subtitles & localization
Your captions can also become subtitles — opt in from the output block:
output:
mp4: out/demo.mp4
subtitles: true # sidecar .srt + .vtt from the captions
languages: ["es", "fr"] # localized subtitle variants- Subtitles — SRT/VTT sidecars for players, accessibility, and platforms.
- Localization — the LLM translates the captions per language and emits
localized
demo.es.srt/demo.es.vtt, etc.
Demos are silent by design right now. Synthesized voiceover sounded robotic enough to hurt the demo, so it was removed rather than shipped half-good; narration will come back once it's genuinely engaging.
Trustworthy data & privacy
Ship demos externally without leaking real data or catching a flaky backend:
redact: [".user-email", ".avatar", "#account-number"] # blurred in every frame
mock:
routes:
- url: "**/api/account"
json: { name: "Jane Rivera", balance: "$12,480.00" }
# har: fixtures/demo.har # or replay a whole HAR- Redaction — matching elements are blurred (or boxed) via an injected MutationObserver, so even content added mid-demo (new rows, avatars) is covered.
- Mock data — pin network responses (route stubs or a HAR replay) so the demo
shows clean, consistent content regardless of backend state. Applied to
record,check, andheal.
Polish
- Auto-zoom — the camera eases toward whatever you interact with and pulls back for hero/outro beats. Cropping happens in post on lossless frames; with a device frame the chrome stays fixed while the content zooms inside it.
- Device frames —
frame: browserwraps the app in a macOS-style window with traffic-light dots and a URL pill;windowdrops the URL;noneis bare. - Padding & background — a padded backdrop (solid color or
linear-gradient) with a soft drop shadow and rounded corners (radius). - Synthetic cursor — a real OS cursor doesn't exist headless, so Reel draws one and eases it between targets, with a click ripple.
- Captions — composited onto the output (so a zoomed crop never clips them), wrapped at real word boundaries using advances measured by the browser's own text engine, and faded in and out rather than hard-cut.
- Spotlight callouts — dim everything but one element, ring it in your accent colour, and label it.
- Title cards — open and close on a card, or use one to separate chapters.
Terminal demos
Reel renders the terminal itself, as a layer in the same document it uses for web demos — so a CLI demo gets captions, title cards, device frames, the deterministic timeline and every output format, and one spec can show a command and the browser it affects:
terminal:
cols: 84
rows: 20
prompt: "~/app $ "
theme: dracula # colour scheme; `reel themes` lists them
require: [node, git] # fail up front if these aren't installed
steps:
- run: { cmd: "mkdir -p tmp", hidden: true } # setup, off camera
- run: npm run build # types it, runs it for real, replays the output
- expectOutput: "built in" # a real assertion, checked by `reel check`
- run: { cmd: npm test, expectCode: 0 }
- show: app # cut to the browser
- click: text=DeployCommands genuinely execute, so reel check is a smoke test of your CLI. Output
is captured to completion and then replayed on camera at a bounded pace —
which is why a 60-second install becomes two seconds of film, and why the result
is byte-identical every run.
theme supplies the 16 ANSI colours plus a matching background and
foreground — reel themes prints each one as a swatch. Set background,
foreground or palette to override any part of it.
require is checked once before anything is filmed, in check as well as
record, so a missing dependency fails by name instead of being recorded as
command not found halfway through the video.
hidden runs a command off camera. It still runs and expectCode still
applies, so it remains part of what reel check verifies — it simply never
reaches the screen. Each command gets its own shell, so cd in a hidden step
doesn't carry into later ones; set terminal.cwd for that.
Colour comes from FORCE_COLOR, which covers build tools, package managers, git
and bespoke CLIs. Tools that check isatty directly — common in Go, Rust and
Python — will still print unstyled, because output goes through a pipe rather
than a pty. That is the same choice that makes the recording deterministic and
expectOutput meaningful. Full-screen TUIs need a real pty and aren't
supported.
See examples/cli/demo.reel.yaml.
Reproducible output
The same spec against the same app renders byte-identical media on any machine. Frame timestamps come from a virtual clock, not the wall clock: a step advances the timeline by the duration it declares, and real waiting — selectors, network, the app settling — costs no demo time at all. A loaded CI runner produces exactly what your laptop did.
This is what makes committing demo media from CI worth doing. Without it every push rewrites a multi-megabyte GIF whether or not the demo changed, and the diff tells a reviewer nothing. With it, a media change means the demo changed.
deterministic:
timeline: true # default; false films the app's own animation in real timePacing — control how long a demo runs
polish:
speed: 1.5 # multiplier over every authored duration
trimIdle: 800 # cap still stretches (blunt: it also shortens holds)
output:
targetDuration: 30s # fit the finished demo to a lengthspeed scales as it records; targetDuration and trimIdle reshape the
recorded timeline afterwards, so neither re-runs the demo. Prefer speed or
targetDuration for a demo that's already paced — trimIdle can't tell dead
air from a deliberate pause.
One spec, many variants
A docs page usually needs the same flow at desktop and mobile, in both themes. That's one spec, not four:
matrix:
viewports:
- { name: desktop, width: 1280, height: 800 }
- { name: mobile, width: 390, height: 844, scale: 3 }
themes: [light, dark]
output:
gif: docs/demo-{viewport}-{theme}.gifreel check walks every variant too — a responsive layout can hide an element
at one width and not another, and that's exactly the drift worth catching.
Commands
| Command | What it does |
|---|---|
| reel doctor | Check this machine can record: browser, ffmpeg, image pipeline, temp space. |
| reel init [dir] | Scaffold a starter demo.reel.yaml. |
| reel capture --url <url> | Author by doing — drive the app in a browser and get a spec back. |
| reel capture --save-auth <file> | Save the signed-in session, so demos behind a login replay without one. |
| reel record <spec> | Drive the app and render GIF / MP4 / WebM / storyboard. |
| reel check <spec> | Re-run headlessly; exit 1 if any step can't complete (CI drift). |
| reel diff <before> <after> | Compare two renders and report which parts of the demo changed. |
| reel review <before> <after> | Say what changed and whether the demo is still true — including captions the UI no longer matches. |
| reel ci [specs...] | Run every demo in the repository, one exit code — what the GitHub Action calls. |
| reel heal <spec> [--write] | Re-run; when a step breaks (UI drift), an agent re-resolves it, verifies the fix, and repairs the spec. |
| reel schema [--out <file>] | Print the JSON Schema for a spec (editor autocomplete). |
| reel ui | Launch Reel Studio, the local web UI. |
| reel themes | List the colour schemes available to terminal demos. |
| reel author <story> --url <url> | AI authoring — an agent drives your app and emits a spec you own. |
Useful flags:
| Flag | What it does |
|---|---|
| --json | Print one machine-readable result object on stdout; logs stay on stderr. |
| record --if-changed | Skip the render when the spec, its inputs and its outputs are all unchanged. |
| record --app-revision <sha> | Identify the app being demoed, so a changed app forces a re-render. |
| diff --exit-code | Exit 1 when the two renders differ, like git diff --exit-code. |
| review --fail-on <verdict> | Exit 1 at stale-caption (default), content, cosmetic, or never. |
| -v, --verbose | Per-step detail, including the ffmpeg invocations. |
Why it's reliable (and the plan's three hard problems)
Real web apps aren't terminals — they animate, read the clock, and fetch live data. Reel closes that gap so demos don't drift or flake:
- State-based waits. Every action auto-waits on app state;
waitForblocks on selectors/URLs/network-idle, never a rawsleep(). - Assertions.
expectchecks rendered text and element counts, so a passingreel checkmeans the app still works, not just that selectors resolve. - Determinism controls. Freeze the clock, pin locale and timezone, seed
Math.random, disable animations/caret — installed before app code runs, on every document. Capture also waits ondocument.fonts.ready, so the opening frames never catch a flash of fallback text. - App lifecycle + auth. A
run:block boots your dev server and waits for it;storageStaterecords logged-in flows.
AI authoring — describe it, don't script it
reel author "sign up, create a project, invite a teammate" --url http://localhost:3000 -o signup.reel.yamlAn agent drives your running app in a headless browser: it snapshots the page,
works out the selectors, performs the story, and verifies each step reached the
expected state before it lands in the spec (so the emitted demo replays
reliably). The result is a normal .reel.yaml you own and edit — not a black
box. Re-run it against a changed UI to update a demo.
It directs, rather than just clicking through: alongside captions and hero/outro beats it opens and closes on title cards, and spotlights the payoff — the result you came for — with a one-line callout. A callout is verified like any other step, so a spotlight never lands on an element that isn't there.
Model access is provider-agnostic and BYO-key, via a LiteLLM
proxy's OpenAI-compatible endpoint — point it at Claude, Gemini, GPT, or anything
your proxy routes to. Configure it with a few env vars (see
.env.example):
export LITELLM_API_BASE=https://your-proxy.example.com
export LITELLM_API_KEY=sk-...
export LITELLM_MODEL=your-model-name
export SSL_VERIFY=false # for corporate proxies with non-verifying certsCI / drift detection
The GitHub Action
- uses: KirtiJha/reel@v1
with:
specs: "**/*.reel.yaml" # the default
mode: check # fail the build if any demo can't runThat's drift detection for every demo in the repository, in one step, on a
fork's pull request safely — mode: check renders nothing and runs no
run.cmd write-back.
@v1needs the tag and the matching npm release to exist. Until they do, pinKirtiJha/reel@mainwithversion: local, which builds Reel from the checkout instead of installing it — the same thing Reel's own CI does.
The version that keeps your README honest does more:
- uses: KirtiJha/reel@v1
with:
specs: docs/demo.reel.yaml
mode: record # regenerate the media
review: true # …and say what changed in it
comment: true # one PR comment, updated in place
commit: true # push the regenerated media back onto the branch
commit-paths: docs
fail-on: stale-caption
llm-api-key: ${{ secrets.REEL_LLM_API_KEY }} # optional; see belowIt needs permissions: { contents: write, pull-requests: write } for the last
two, and a actions/checkout before it.
| Input | Default | What it does |
|---|---|---|
| specs | **/*.reel.yaml | Paths or globs, whitespace separated. |
| mode | check | check renders nothing; record regenerates media. |
| review | false | Compare each re-render against what it replaced. |
| fail-on | stale-caption | Verdict that fails the build: cosmetic, content, stale-caption, never. |
| if-changed | false | Skip a render whose spec, inputs and outputs are unchanged. |
| app-revision | — | A commit SHA, so a changed app forces a re-render. |
| comment / commit | false | Post one comment; push the media back. |
| version | this tag's | Which @kirti_jha/reel to install, or local to build from the checkout. |
| browser / node-version | chromium / 20 | Set either to empty to use what the job already has. |
Outputs: specs, failed, changed, verdict, outputs (JSON array of every
file rendered) — so later steps can branch without grepping a log.
The action is deliberately thin. Every decision it makes — which specs to
run, what "changed" means, whether a verdict should fail the build — is
reel ci, a normal command with unit tests that you can run on your laptop and
get the same answer:
reel ci "examples/**/*.reel.yaml" --mode checkSummary
✓ examples/cli/demo.reel.yaml — unchanged
✓ examples/taskflow/branching.reel.yaml — unchanged
✓ examples/taskflow/demo.reel.yaml — unchanged
✓ examples/taskflow/showcase.reel.yaml — unchanged
✓ examples/taskflow/subtitled.reel.yaml — unchanged
✓ 5 demos — all current, all honest.Logic embedded in workflow YAML is typechecked by nothing, tested by nothing,
and only ever runs on a machine you can't attach a debugger to. What's left in
action.yml is the part that genuinely belongs to a runner: Node, a browser, a
cache, and moving files afterwards. Reel's own CI runs this action with
version: local, so the action is exercised by every pull request rather than
only by its users.
Without a model configured, review: true still locates every change and says
plainly that nothing judged it. It never reports a green tick it didn't earn.
Rolling your own
Prefer to write the workflow yourself? Copy
.github/workflows/reel.yml. Either way the
preview only works because output is deterministic — otherwise every PR would
carry a media change and the signal would be worthless.
Don't re-render what didn't change. Recording is the slowest thing Reel does, and CI regenerates on every push. Because identical inputs produce identical bytes, the render is pure cost when nothing moved:
reel record demo.reel.yaml --if-changed --app-revision "$GITHUB_SHA"It hashes the spec's bytes plus every local file it names (a storageState, a
mock HAR), the Reel version, and the app revision you pass in — then checks the
declared outputs are still on disk, because a matching hash means nothing if
somebody deleted the GIF. The app itself is deliberately not fingerprinted:
Reel can't know what a URL will serve, and pretending otherwise would make
skipping unsafe. That's what --app-revision is for.
When a step breaks, look at what it saw. A failure writes diagnostics to
.reel-failures/ beside the spec: a full-page failure.png, a failure.gif of
the last four seconds leading up to it, the page's HTML, and a failure.json
with the step, the URL and the console output. A CI log saying Timeout 30000ms
exceeded is not a debuggable artifact; the four seconds before it is.
Machine-readable results. --json prints one result object on stdout —
logs stay on stderr, so the two never interleave. Failures carry the step that
broke and the paths to its artifacts, so a job can surface them without knowing
where Reel puts things:
reel check demo.reel.yaml --json | jq -r '.error.artifacts.screenshot // empty'Reviewing a changed demo. A PR that touches the app produces a changed
binary marked "modified", which isn't review. reel diff says what actually
moved:
$ reel diff before.gif out/demo.gif
8.0s–9.8s 2.4% of pixels · hero
10.4s–11.4s 5.7% of pixels · hero
15.0s–15.4s 50% of pixels · outro · only in one renderRanges are labelled with the beats they fall in, and each one gets a
before/after/difference strip with the changed pixels burned magenta. Two
renders differing is a result, not a failure, so the exit code stays 0 unless
you pass --exit-code.
The workflow does this for you. When a PR changes the demo, the comment it leaves is the diff rather than a notification — a table of when each change happens, how much moved, and which beat it belongs to. So the reviewer knows where to look instead of scrubbing a twenty-second video:
🎬 Demo updated
docs/demo.gif— 3 changes, 27% of the running time. Length 15.4s → 15.2s (-0.2s).| When | How much | Where | |---|---|---| |
8.0s–9.8s| 2.4% of pixels | hero | |10.4s–11.4s| 5.7% of pixels | hero | |15.0s–15.4s| 50% of pixels | outro, only in one render |
Is the demo still true? That's a different question, and it's the one that
scales badly. reel check proves every step ran. reel diff proves pixels
moved. A demo can pass both and be wrong: rename a button from Start free
trial to Get started and the flow still completes, the diff is a fraction of
a percent, and the caption over it now names a control that no longer exists.
At one demo somebody watches the GIF and catches that. At forty, regenerated
weekly, nobody does.
$ reel review before.gif out/demo.gif
✗ 1.6s–5.2s
The primary button now reads "Create"; it read "Add". The caption
"Press Add to save it" names a button that no longer exists.
caption: “Press Add to save it”
0 needing a look · 1 stale captions · 0 cosmeticThe pixel pass still decides where; the model only judges what, looking
at the before/after/difference strip for one moment at a time, told which
captions were on screen for it. Which captions those are is computed from the
recorded timeline, not asked — the model is never given a job that arithmetic
can do. Verdicts are cosmetic, content and stale-caption, and
--fail-on decides which of them stops a pipeline (stale-caption by
default). A reply it can't parse becomes unreviewed, which ranks above
cosmetic: "we couldn't tell" must never be quieter than "we looked and it was
fine".
It needs a model (any of the providers below — the same REEL_LLM_*
configuration heal and author use). Without one you get the pixel report
and a line saying nothing judged it, never a green tick. Set REEL_LLM_API_KEY
as a repository secret and the PR comment upgrades itself from the table above
to a review:
🎬 Demo review
docs/demo.gif— A caption no longer matches the screen.| | When | What changed | |---|---|---| | 🔴 |
1.6s–5.2s| The primary button now reads "Create"; it read "Add". The caption "Press Add to save it" names a button that no longer exists. |
When a step breaks in CI, the screenshot, clip and DOM are uploaded as a build artifact, so a red build can be diagnosed from the evidence rather than reproduced locally — which for a timing-sensitive recording is sometimes not possible at all.
Flaky steps. retries: 2 re-runs a step that fails transiently. Retries are
deliberately narrow: steps that mutate nothing are always safe, while a click or
a type is retried only when the failure proves the action never landed — so a
retry can't double-submit a form.
Self-healing. When the UI drifts and a step breaks, reel heal --write
repairs the spec. It works through a ladder: candidates derived from what the
broken selector was reaching for (role, name, id, placeholder) are scored and
tried first, and every candidate is verified by actually running the step
before it's accepted. An LLM handles what the ladder can't — and is entirely
optional, so a renamed id or a relabelled button repairs offline with no API key.
Security.
reel recordruns the spec'srun.cmdin a shell — a.reel.yamlis executable code. Never run a spec you didn't write in a job holding secrets or write permissions. SetREEL_NO_EXEC=1to refuse to spawnrun.cmdat all, and start the app yourself instead.
Architecture
spec.yaml ─▶ loader/validator ─▶ driver (Playwright) ─▶ capture (retina screenshots)
│ │
determinism + state waits timestamped 2× frames (deduped)
│
overlay (cursor · captions · ripples)
│
auto-zoom: cfr expand · eased crop · caption composite (sharp)
│
encoders: GIF · MP4 · WebM · storyboardFrames are the substrate the whole polish pipeline builds on. Capture is a
timestamped page.screenshot() loop at true retina (2×) — the only path that
honors device-pixel-ratio (CDP screencast only ever yields CSS-resolution). Byte-
identical frames during static holds are deduped, so a long hold costs one frame,
and per-frame timestamps keep playback at the intended speed.
Status
Alpha (v0.2). Working and dogfooded (the GIF above): the core runner, determinism, retina capture, auto-zoom, device frames + padding/background, polish (synthetic cursor + composited captions), the scene grammar (title cards, spotlight callouts, rendered scrolling, explicit camera, assertions), delivery presets, encoders (GIF/MP4/WebM/storyboard), interactive HTML, branching, terminal demos, capture and AI authoring, demos behind a login (including signing in mid-demo), self-healing drift repair, subtitles + localization, and PII redaction + mock data — the model-backed parts provider-agnostic via a LiteLLM proxy, and optional.
What changed between releases, and which of it will move your committed media, is in CHANGELOG.md.
Around them, the things that make it usable day to day: reel doctor, failure
artifacts, --json, --if-changed, reel diff, reel review, reel ci and
its GitHub Action, and a JSON Schema for editor autocomplete.
License
MIT.
