npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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:

  1. CI-native drift detection. Your demo is a test. If a step can't complete, the build fails; on merge, the media updates itself.
  2. Byte-identical output. The same spec renders the same bytes on any machine, so committed demo media changes only when the demo does.
  3. An interactive click-through. The same spec builds a self-contained product tour with hotspots, deep links and branches the viewer chooses.
  4. 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.yaml

Don'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.yaml

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

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

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

Your 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 to nav >> 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=Delete matching 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-1x2y3z4a are 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.json

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

Re-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-ignore after writing one and tells you if git can still see it, and this repo's .gitignore covers .auth/ and auth.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.json

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

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

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

It 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 tracking
  • Hotspots 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-project for a chapter, #/step-4 for 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. Home End to step, space to play, swipe on a phone, a live region announcing each step, visible focus rings, and prefers-reduced-motion respected.

  • Light and dark, following the viewer's system preference.

  • Embeddable. ?embed=1 drops 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.reelDemo directly.

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

A 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, and heal.

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 framesframe: browser wraps the app in a macOS-style window with traffic-light dots and a URL pill; window drops the URL; none is 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=Deploy

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

Pacing — 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 length

speed 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}.gif

reel 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; waitFor blocks on selectors/URLs/network-idle, never a raw sleep().
  • Assertions. expect checks rendered text and element counts, so a passing reel check means 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 on document.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; storageState records 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.yaml

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

CI / 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 run

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

@v1 needs the tag and the matching npm release to exist. Until they do, pin KirtiJha/reel@main with version: 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 below

It 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 check
Summary
  ✓ 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 render

Ranges 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.gif3 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 cosmetic

The 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.gifA 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 record runs the spec's run.cmd in a shell — a .reel.yaml is executable code. Never run a spec you didn't write in a job holding secrets or write permissions. Set REEL_NO_EXEC=1 to refuse to spawn run.cmd at 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 · storyboard

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