> ## Documentation Index
> Fetch the complete documentation index at: https://docs-stg.outerlayer.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Record a demo

> Record the running app with Playwright, then attach a narrated demo with one chapter for each acceptance criterion.

A demo shows the screen behavior for each acceptance criterion as its own chapter. A spoken line says what happens. The chapter lists the checks the recording ran.

Each chapter is an ordinary video artifact. It is the video proof for its criterion, so no proof rule changes. A chapter with a failed check is never bound to its criterion.

You record the demo in three steps:

1. Write a Playwright test with `@outerlayer/playwright`.
2. Run the test. It writes a video and a manifest.
3. Run `outerlayer emit demo` on the manifest.

Demos cover browser apps. A runner build image has everything the command needs. On your own machine, install `ffmpeg` and `espeak-ng` first.

## Write the test

Install the package next to your Playwright setup. It needs `@playwright/test` 1.50 or later.

```bash theme={"system"}
npm install --save-dev @outerlayer/playwright
```

Import `test` and `expect` from the package, not from `@playwright/test`. Add one `demo.chapter` for each criterion.

```ts theme={"system"}
import { expect, test } from "@outerlayer/playwright";

test("a member signs in", async ({ page, demo }) => {
  await page.goto("/login");

  await demo.chapter("LOGIN-01", "Sign in", "The member signs in and lands on the dashboard.", async () => {
    await page.getByLabel("Email").fill("member@example.com");
    await page.getByRole("button", { name: "Sign in" }).click();
    await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
  });
});
```

`demo.chapter(criterionId, title, narration, body)` takes these arguments:

| Argument | Meaning |
| - | - |
| `criterionId` | The id of a [recorded criterion](/reference/cli-emit#outerlayer-emit-criteria). Use `null` for an opening scene. |
| `title` | A short name for the chapter. |
| `narration` | The line spoken over the chapter. Use an empty string for no speech. |
| `body` | The steps of the chapter. |

The chapter starts when `body` starts and ends when `body` ends. Each `expect` that runs inside `body` is recorded as a check of the chapter.

A failed `expect` fails the test, as it does without this package. The failed check is still in the manifest.

Follow these rules for chapters:

* Give each criterion one chapter.
* Put a chapter with no criterion first. Only the first chapter may have none.
* Keep the narration short. The speech must end before its chapter does. See [Fit the narration](#fit-the-narration).
* Do not use a viewport larger than you need. A clip over 8 MiB is refused.

## Fixed settings

Two recordings of an unchanged page can still differ. The clock moves, the timezone changes the date format, and an animation is caught at another point. The `demo` fixture fixes each setting that the browser controls:

| Varies between runs | The fixture sets |
| - | - |
| The clock | Each test starts at 2026-01-15 10:00:00 UTC. The clock then runs normally. |
| Timezone | `UTC` |
| Language and formats | `en-US` |
| Window size | A 1280 × 800 viewport and a device pixel ratio of 1. |
| Animations | A reduced-motion preference, and animation and transition times of 0. |
| Fonts | Each still waits for web fonts to load. |
| The text cursor | The cursor is transparent. |
| Random values | `Math.random` returns the same sequence on every page load. |

The fixture cannot fix what your app's server controls, such as its clock, its random ids and its data. [Compare two runs](#compare-two-runs) finds that variation.

### Turn a setting off

Every fixed setting is on by default. Turn one off with the `demoOptions` option. Each key is `true` unless you set it to `false`.

```ts theme={"system"}
test.use({ demoOptions: { clock: false } });
```

| Key | Setting it controls |
| - | - |
| `clock` | The start time of the page clock. |
| `random` | The fixed `Math.random` sequence. |
| `animations` | Reduced motion, zero animation times, and finished animations in each still. |
| `caret` | The transparent text cursor. |
| `fonts` | The wait for web fonts before each still. |

To change the viewport, locale, timezone or pixel ratio, set that option yourself, for example `test.use({ viewport: { width: 640, height: 360 } })`. Your value replaces the fixed one.

### Hide a region that varies

Use `demo.mask` to hide an element that varies for reasons of its own, such as a timestamp from your server.

```ts theme={"system"}
await demo.chapter("ACTIVITY-01", "Activity feed", "The feed lists the latest event.", async () => {
  await demo.mask(page.getByTestId("last-updated"));
  await expect(page.getByRole("list")).toBeVisible();
});
```

The mask covers the element with a solid grey box until the chapter ends. The box shows in the video and in every still. It is the same in every run, so it never causes a difference. The box follows the element when it moves or changes size.

### Stills

When a check in a chapter passes, the fixture takes a still of the page. A still is a PNG file. The manifest lists each still with the check it follows. A failed check leaves no still. A chapter with no passed checks has no stills.

After an `await expect(...)` check, the still shows the page at that check. A synchronous matcher, such as `expect(1).toBe(1)`, cannot make the chapter wait. The still then shows the page a moment later, after the next lines of the chapter have run. Use an async matcher, such as `await expect(locator).toBeVisible()`, for a check that needs a fixed still. A page that changes during that moment makes the chapter read `no`, never a false `yes`.

If a passed check has no still, for example because the page closed, the chapter reads `not checked` unless another still already differs.

## Run the test

Run the test as usual.

```bash theme={"system"}
npx playwright test
```

The package turns video recording on. When the test ends, its output folder holds a `demo` folder with two files:

| File | Content |
| - | - |
| `video.webm` | The recording of the whole test. |
| `manifest.json` | The chapters, in order. Each has its criterion, start and end in seconds, title, narration, checks and stills. |
| `stills/` | One PNG for each passed check. |

## Emit the demo

Run the command on the manifest.

```bash theme={"system"}
outerlayer emit demo test-results/<test folder>/demo/manifest.json --item 412
```

The command does these steps:

1. It checks the manifest, the video and the programs it needs. It refuses here, before it uploads anything.
2. It turns each narration line into speech on your machine.
3. It cuts the video into one clip for each chapter. Each clip is WebM with VP9 video and Opus audio.
4. It emits each clip as a video artifact.
5. It emits the manifest as the demo record, with the artifact id of each clip added.

A chapter whose checks all passed is bound to its criterion. A chapter with a failed check, and a chapter with no criterion, is stored with no criterion bound.

When a chapter has a failed check, the command stores everything and then exits 1. It names the chapter and the failed check. Fix the app, record again and run the command again.

You can run the command again with the same manifest, video, item and pull request. This also applies after an upload failed part way. A clip that is already stored keeps its artifact id, and the command does not store it a second time.

Inside a recorded session, the clips and the record are queued. They upload on the next `outerlayer sync`. Outside a session they upload at once. The full flags are in [`outerlayer emit demo`](/reference/cli-emit#outerlayer-emit-demo).

## Compare two runs

A reviewer wants to know which chapters changed. Run the test twice and give both manifests to the command.

```bash theme={"system"}
outerlayer emit demo run-1/demo/manifest.json --compare run-2/demo/manifest.json --item 412
```

The command uses the first run's video and checks. It does not read the second run's video. It does these steps:

1. It refuses before it uploads anything when the runs differ in chapters, titles or checks. It names the first difference.
2. It compares each still with the still at the same step of the second run. It compares pixels, not file bytes.
3. It stores `comparable` for each chapter in the demo record.

| `comparable` | Meaning |
| - | - |
| `yes` | Every still of the chapter matches the second run. |
| `no` | A still differs. The record holds the check and the smallest rectangle that holds every differing pixel. |
| `not checked` | You gave one manifest, the chapter has no stills, or a passed check has no still. |

For a `no` chapter, the command prints the chapter, the step and the rectangle. Fix what varies in your app, or hide it with [`demo.mask`](#hide-a-region-that-varies).

Comparing never changes what a clip proves. A chapter binds to its criterion when its checks pass, whether it reads `yes`, `no` or `not checked`. The command never calls a chapter `yes` from one run. It refuses when both manifests are the same file.

## Manifest format

The fixture writes `manifest.json`. Any recorder that writes the same format gets narration and comparison from `outerlayer emit demo`. The current format is version 2. The command still reads version 1, which has no stills.

| Field | Meaning |
| - | - |
| `schemaVersion` | `2`. |
| `video` | The video file, relative to the manifest. |
| `chapters` | 1 to 200 chapters, in order. |
| `chapters[].criterionId` | The id of a recorded criterion, or `null` for an opening scene. |
| `chapters[].start`, `chapters[].end` | Seconds from the start of the video. |
| `chapters[].title` | A name for the chapter. |
| `chapters[].narration` | The line spoken over the chapter. Empty for no speech. |
| `chapters[].checks` | The checks of the chapter, each with `text` and `passed`. |
| `chapters[].stills` | The stills of the chapter, each with `check`, `file` and `sha256`. |

A still has these fields:

| Field | Meaning |
| - | - |
| `check` | The index, starting at 0, of the check in `checks` that the still follows. |
| `file` | The PNG file, relative to the manifest's folder. |
| `sha256` | The SHA-256 of the PNG file's bytes, in lower-case hex. |

A still follows a check that passed. The `check` values rise from one still to the next. The still files must stay unchanged after the run, because `outerlayer emit demo --compare` checks each file against its `sha256`.

## Fit the narration

Speech starts 0.2 seconds into its chapter and ends at least 0.2 seconds before the chapter ends. Speech that is too long for its chapter is sped up, to at most 1.3 times. Speech that is still too long makes the command refuse. It names the chapter and says by how many seconds the line is too long.

To fix a refusal, shorten the line or lengthen the chapter. The command never stretches the video.

## Install the programs

The command runs `ffmpeg` and `espeak-ng` as separate programs. `ffprobe` comes with `ffmpeg`. A narrated run needs both. A run with `--no-narration` needs only `ffmpeg`.

### Linux

Use your package manager.

```bash theme={"system"}
# Debian and Ubuntu
sudo apt-get install ffmpeg espeak-ng

# Fedora
sudo dnf install ffmpeg espeak-ng

# Alpine
sudo apk add ffmpeg espeak-ng
```

On Fedora, `ffmpeg` comes from the RPM Fusion repository. See the Fedora documentation to enable it.

### macOS

Use Homebrew.

```bash theme={"system"}
brew install ffmpeg espeak-ng
```

### Windows

Install `ffmpeg` with `winget`, then install eSpeak NG from its Windows installer.

```powershell theme={"system"}
winget install Gyan.FFmpeg
```

Download the eSpeak NG installer from the [eSpeak NG releases page](https://github.com/espeak-ng/espeak-ng/releases). Open a new terminal after both installs. Then check that each program is on `PATH`.

```powershell theme={"system"}
ffmpeg -version
espeak-ng --version
```

If a command is not found, add its folder to `PATH`. The `ffmpeg` folder is the `bin` folder of the winget package. The eSpeak NG folder is `C:\Program Files\eSpeak NG`.

## Download the voice pack

The first narrated run downloads the voice pack to `~/.outerlayer/voice/`. It is about 400 MB. It holds the Kokoro-82M speech model, one voice and the ONNX Runtime that runs the model. The command shows progress and resumes a broken download.

The command checks every file of the pack against a pinned sha256 on each narrated run. When a file does not match, the command refuses and names the file. Delete that file to download it again.

After the first run, narration needs no network, no account and no key.

To work offline from the start, set `OUTERLAYER_VOICE_MIRROR` to the address of a server that holds the pack files. The pinned hashes still apply to every file the mirror serves.

## Skip the narration

Pass `--no-narration` to store silent clips.

```bash theme={"system"}
outerlayer emit demo test-results/<test folder>/demo/manifest.json --no-narration
```

This needs no `espeak-ng` and no voice pack. It downloads nothing.

## Run in a build

The runner build image holds `ffmpeg`, `espeak-ng` and the voice pack. A build records and narrates a demo with no network and nothing else to install. The [evidence skill](/starter-skills) tells an agent to record a demo for the criteria whose behavior shows on screen.

See also: [Prove the work](/attach-evidence), [`outerlayer emit demo`](/reference/cli-emit#outerlayer-emit-demo).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.