narrate-video · git:20261021.c547d3d · 2026-10-21 · sha256 9c297f10c936e418

narrate-video git:20261021.c547d3dA

Immutable. This exact content never changes and is served at /api/v1/blob/9c297f10c936e418.

---
name: narrate-video
description: Generate a narrated walkthrough video of a running web app. Use when the user asks to record, narrate, or produce a video tour / screencast / demo of a website or feature, or create a walkthrough with voiceover.
argument-hint: "[what to demo, e.g. the signup flow with test@example.com]"
---

# Narrate a walkthrough video

If you were invoked with a request (the user's words, available as `$ARGUMENTS`),
treat that as the walkthrough to produce. If it's empty, ask what flow/feature to
demo and for any test credentials or data needed.

Produce a narrated video tour using the bundled `@narrate/core` engine: it
generates speech with a TTS provider, records the browser continuously with a
headless Playwright (each beat held on screen for exactly its narration length,
so audio sync is automatic), and muxes narration onto the video with ffmpeg.

## Narrate like a presenter (use all your context)

You're not just clicking around — you're a product person **showcasing** something
to a viewer who can't see your screen. Before authoring anything, build a mental
model of *what story to tell and what to point at*, using every source you have:

- **The prompt** (`$ARGUMENTS`) — the explicit ask ("demo the signup flow", "show
  what changed in this PR", "walk through the dashboard").
- **The codebase** — components, routes, and the **real selectors** for the
  surfaces involved (read the source; don't guess selectors).
- **The running site** — what's actually on screen (use the Playwright MCP to
  snapshot the live page and confirm selectors/structure).
- **Git / GitHub context** — when the ask is about a change ("the new feature",
  "this PR", "what I just built"), inspect it: current branch vs. the base, recent
  commits, and any open PR (`gh pr view`, `gh pr diff`). The diff tells you which
  files/UI changed, so you demo *those* surfaces and **highlight exactly what's
  new** — not a generic tour.

Then translate that into beats: each beat says one thing and **points at the thing
it's describing** (cursor for interactions, a brief `focus`/`highlight` pulse for
the element/section the narration calls out). This is what makes the highlighting
feel deliberate rather than random — it's driven by what you actually decided to show.

## Evaluative walkthroughs → also emit a task list

Sometimes the ask isn't "show this off" but "critique this" / "review my home
page" / "suggest improvements". Then the narration is full of **actionable
findings** — but spoken in a video, they're easy to lose. Whenever the walkthrough
is evaluative (you're pointing out problems, gaps, or things to improve), also
write a **task list in Markdown** next to the video so the suggestions are
reviewable and a coding agent (or the user) can act on them:

- Path: `./.narrate/tmp/<slug>.tasks.md` (delivered to `./docs/<slug>.tasks.md`).
- One GitHub-style checkbox (`- [ ]`) per concrete, actionable change — not a
  restatement of the narration. Each item: a short imperative title, *why* it
  matters, and where it applies (the page/section, and the **real selector or
  source file** when you know it, so an agent can jump straight to it).
- Group by area (e.g. Hero, Navigation, Performance, Accessibility) and, where it
  helps, note rough priority (high/medium/low).
- Keep it in sync with the beats: every critical thing the narration calls out
  should have a corresponding task, so nothing stays buried in the audio.

This is your own authoring step (the engine doesn't generate it) — produce it from
the same analysis that drove the beats. For a pure showcase (no critique), skip it.

## Authenticated walkthroughs (login-gated pages)

Recording starts in a **clean, signed-out** browser context, so anything behind a
login wall needs auth set up first. **Never ask the user to paste real credentials
into the chat, and never write real credentials into the scene file** — both expose
secrets (to the LLM and on disk, including any `keepScene`/`docs` copy). Use one of
the two safe paths instead:

1. **`auth.storageState` (preferred).** Capture the user's session once, then point
   the scene at it — the recorder starts already authenticated and never visits the
   login screen. You can run this on demand: it opens a real browser, **blocks while
   the user logs in, and returns the instant they close the window**, so just invoke
   it and continue once it exits:
   ```bash
   node "${CLAUDE_PLUGIN_ROOT}/bin/narrate.mjs" auth <login-url>   # saves .narrate/auth.json
   ```
   Tell the user plainly: "a browser will open — log in, then close the window."
   (Default output is `.narrate/auth.json`; pass `--out .narrate/<name>.json` for a
   different path.) Then add `"auth": { "storageState": ".narrate/auth.json" }` to the
   scene and author beats that start *inside* the authenticated UI. The session file
   is gitignored automatically. If a later render shows the login screen, the session
   expired — just run `auth` again.
2. **`${ENV_VAR}` in `fill`/`type` (only if the login itself is the thing to demo).**
   Put placeholders like `${DEMO_PASSWORD}` in the typed text; they resolve from the
   user's environment at render time, so the secret never enters the scene or the
   chat. Ask the user to export the vars and run `render` themselves (e.g.
   `DEMO_PASSWORD=… node …/narrate.mjs render …`). Prefer a throwaway/test account.

Default to **#1**; it's the only one that keeps the credential out of the recorded
page entirely. Confirm which the user prefers rather than assuming.

## The CLI

The engine is bundled inside this plugin. Always invoke it as:

```bash
node "${CLAUDE_PLUGIN_ROOT}/bin/narrate.mjs" <command> …
```

(In the source monorepo you can instead use `pnpm narrate …`.)

Commands: `init` (scaffold the project override file `.narrate/settings.local.json`;
settings + keys normally come from the global `~/.narrate/settings.json`),
`check` (validate ffmpeg + config; exit 0/1), `render --scene <file> [--out <dir>]
[--provider <p>] [--voice <v>]`, `auth <login-url> [--out <file>]` (log in once and
save a session for authenticated scenes — see below), and `setup` (force-install
Playwright + Chromium; normally automatic). TTS provider/key onboarding lives in the
**`/narrate-setup`** skill.

## Prerequisites

- **ffmpeg + ffprobe** on PATH (`ffmpeg -version`) — the only manual dependency.
  Playwright + Chromium are auto-provisioned on the first render.
- The **target app running** and reachable at the scene's `site` URL.
- **TTS works out of the box** with the OS voice (no key). For higher-quality
  cloud narration, the user can run **`/narrate-setup`** to add a Gemini/ElevenLabs
  key (stored once, globally, in `~/.narrate/settings.json`); it's optional.

## Workflow (from a plain-English request)

1. **Scaffold + validate (deterministic).**
   ```bash
   node "${CLAUDE_PLUGIN_ROOT}/bin/narrate.mjs" init
   node "${CLAUDE_PLUGIN_ROOT}/bin/narrate.mjs" check
   ```
   `init` creates an empty, gitignored project override file
   `./.narrate/settings.local.json` the first time (settings come from the global
   `~/.narrate/settings.json`, else defaults — the OS voice); `check` validates
   ffmpeg + config and prints `RESULT: PASS|FAIL`. Act on it:
   - **ffmpeg MISSING** → give install instructions and stop (the one hard dependency).
   - **PASS** → continue. Don't block on a key: with defaults narration uses the OS
     voice. If `check` shows `provider=os` and the user wants studio-quality narration,
     mention they can run **`/narrate-setup`** first (optional), then proceed either way.
2. **Investigate & decide what to showcase.**
   - **Run/serve info:** `package.json` dev/start script, framework, URL/port,
     required env/seed.
   - **Scope from git/GitHub when the ask is about a change.** Find what's new and
     demo *that*:
     ```bash
     git branch --show-current && git log --oneline -15
     git diff --stat $(git merge-base HEAD main 2>/dev/null || echo main)...HEAD
     gh pr view --json title,body,url,headRefName 2>/dev/null   # open PR for this branch?
     gh pr diff 2>/dev/null                                     # which files/UI changed
     ```
     Map changed files → the UI surfaces they affect → the beats to record, so the
     walkthrough spotlights the new/changed behavior (use the PR title/body as the
     storyline). For a plain feature/flow ask, skip the diff and just map the flow.
   - **Selectors:** identify the pages + **real selectors** (read component source,
     or snapshot the live page with the Playwright MCP). Prefer robust selectors:
     `role=`, `text=`, `[name=...]`, stable ids.
3. **Run the app** if needed — start the dev server in the background, wait until
   the URL responds, and remember to stop it afterward.
4. **Author a scene** at `./.narrate/tmp/<slug>.scene.json` (format below). Use any
   credentials/data the user gave; treat signup/email flows as test-only. Drive it
   like a presenter (same overlay features the demo uses):
   - **Cursor carries interactions** — it auto-glides to each click/type/fill, so
     the viewer follows along. Don't hand-ring form fields.
   - **Brief highlights for what you're describing** — when a beat's narration
     calls out a specific element/section (especially a changed one from the diff),
     set that beat's **`focus`** selector (or a `highlight` step *after* any scroll),
     so a ~3s pulse points right at it, then fades. Add a `focusLabel` to name it.
5. **Render** to the temp dir:
   ```bash
   node "${CLAUDE_PLUGIN_ROOT}/bin/narrate.mjs" render \
     --scene ./.narrate/tmp/<slug>.scene.json --out ./.narrate/tmp
   ```
6. **Deliver**: copy `./.narrate/tmp/<slug>.mp4` → `./docs/<slug>.mp4`, ensure
   `.narrate/` is in the project `.gitignore`, and stop the dev server. Deliver any
   sidecars produced alongside the video too: `<slug>.vtt` (captions, if
   `output.vtt` is on), `<slug>.scene.json` (if `output.keepScene` is on), and the
   `<slug>.tasks.md` task list you authored for an evaluative walkthrough — copy
   each to `./docs/` next to the mp4.
7. **Report audio + artifacts, then clean up.** Read `./.narrate/tmp/narrate.log`
   and quote its **"Final audio"** line (stream present? mean volume in dB) so the
   user can verify the muxed audio is real. List every delivered file (video, and
   any captions / scene / task list). If the log reports the stream MISSING or
   silent, surface that and keep `./.narrate/tmp` for debugging; otherwise **delete
   `./.narrate/tmp`** automatically — the deliverables live under `./docs/`. Report
   those paths. (Note: VS Code's built-in preview can't decode the audio — verify in
   VLC or a browser.)

## Scene format

```jsonc
{
  "name": "signup",                       // output file = <name>.mp4
  "site": "http://localhost:3000",
  "viewport": { "width": 1440, "height": 900 },
  "theme": "dark",                         // optional: light | dark | system
  "auth": { "storageState": ".narrate/auth.json" },  // optional: start signed in (see Authenticated walkthroughs)
  "beats": [
    {
      "id": "intro",
      "say": "Spoken narration for this beat.",
      "voice": "Kore",                     // optional per-beat voice override
      "focus": "#hero",                    // optional: highlight this element for the whole beat
      "focusStyle": "ring",                // optional: ring | glow | spotlight (default from config)
      "focusLabel": "Welcome",             // optional: caption next to it
      "do": [ { "action": "wait", "ms": 800 } ]
    }
  ]
}
```

Use `focus` to spotlight whatever the narration is talking about — it highlights
for the beat's whole duration and clears automatically. The synthetic cursor and
highlights are injected into the page (never the OS cursor) and are on by default;
they're configurable under `overlay` in settings.

For editor autocomplete, add the hosted schema (the engine ignores unknown keys):
`"$schema": "https://raw.githubusercontent.com/tomek-i/narrate-plugin/main/narrate.scene.schema.json"`.

## Step vocabulary (the `do` array)

Selectors are real Playwright selectors. Steps run in order within a beat.

**Timing**
- `{ "action": "wait", "ms": 1000 }`
- `{ "action": "waitFor", "selector": ".x", "state": "visible" }` — state: `attached|detached|visible|hidden`
- `{ "action": "waitForUrl", "url": "**/dashboard" }`

**Navigation**
- `{ "action": "navigate", "url": "http://localhost:3000/path" }`
- `{ "action": "back" }` · `{ "action": "forward" }` · `{ "action": "reload" }`

**Mouse**
- `{ "action": "click", "selector": "..." }`
- `{ "action": "dblclick", "selector": "..." }`
- `{ "action": "hover", "selector": "..." }`
- `{ "action": "dragTo", "from": "...", "to": "..." }`

**Keyboard / forms**
- `{ "action": "fill", "selector": "#email", "text": "a@b.com" }` — set value instantly
- `{ "action": "type", "selector": "#email", "text": "a@b.com", "delay": 60 }` — key-by-key (lifelike)
- `{ "action": "clear", "selector": "#email" }`
- `{ "action": "press", "key": "Enter", "selector": "#email" }` — `selector` optional; supports combos like `Control+A`
- `{ "action": "selectOption", "selector": "select", "label": "Blue" }` — or `"value": "..."`
- `{ "action": "check", "selector": "#agree" }` · `{ "action": "uncheck", "selector": "#agree" }`
- `{ "action": "focus", "selector": "..." }` · `{ "action": "blur", "selector": "..." }`
- `{ "action": "uploadFile", "selector": "input[type=file]", "files": ["./a.png"] }`

**Scrolling** (smoothly animated over `over` ms)
- `{ "action": "scrollTo", "y": 600, "over": 2000 }`
- `{ "action": "scrollThrough", "selector": "article", "over": 7000 }` — selector optional (whole page)
- `{ "action": "scrollIntoView", "selector": "#section", "over": 800 }`

**Highlighting / pointer** (injected overlay — never the OS cursor; on by default)
- `{ "action": "highlight", "selector": ".cta", "style": "ring", "label": "Start here" }` — `style`: `ring`|`glow`|`spotlight`. Pulses ~3s then fades (config `holdMs`); add `"hold": 0` to keep it until `unhighlight`/beat end.
- `{ "action": "unhighlight", "selector": ".cta" }` — omit `selector` to clear all
- `{ "action": "point", "selector": "role=button[name=Save]" }` — glide the cursor (no click). Clicks/hover/type already auto-glide the cursor.

Two roles — keep them separate so it doesn't look random:
- **Cursor carries interactions.** Don't hand-highlight form fields; the cursor
  already glides to each element you act on (consistent by construction).
- **Highlights are brief accents** for what the narration *describes* — they pulse
  and fade, so they don't obscure the page. Use `focus` (in-view elements) or a
  `highlight` step placed *after* any scroll so the pulse lands when it's visible.

**Convenience / escape hatch**
- `{ "action": "menu", "trigger": "role=button[name=\"Toggle theme\"]", "item": "Light" }` — click trigger, then a `menuitem` by text
- `{ "action": "eval", "fn": "document.body.classList.add('x')" }` — arbitrary in-page async JS; last resort

## Notes

- **Pacing:** a beat is held for exactly its narration length. If its `do` steps
  run longer, the engine warns — split the beat or lengthen the narration.
- **Config:** global `~/.narrate/settings.json`, overridden per project by
  `.narrate/settings.local.json` (deep-merged), holds output settings plus a `tts`
  block with per-provider settings nested under their own key (`tts.gemini`,
  `tts.elevenlabs` — each with its own `voice`/`model`/`key`); default is the OS
  voice (no key). Override per-run with `--provider` / `--voice`. To switch to a
  cloud voice, run **`/narrate-setup`** (or have the user run `! narrate set-key <provider> --clipboard` after copying the key, never pasting it into chat).
  Two optional `output` flags add sidecar files next to the video: `"vtt": true`
  writes a `<name>.vtt` caption track (one cue per beat, timed to the narration),
  and `"keepScene": true` keeps a `<name>.scene.json` copy for later re-renders.
- **Theme:** the optional scene-level `theme` emulates the browser's
  `prefers-color-scheme` (the standard OS/browser color-scheme signal), so it
  works for any site that honors that media query. Apps with a *manual* toggle
  (a button/switch) won't change from this alone — drive that toggle with a
  `click`/`menu` step. Omit `theme` entirely if it doesn't apply.