# Video

> Render a composition to MP4 — narration-derived timing, a scene cache, the director's pass, any voice, scripted footage, and citations that ship with the video

`@getnarro/video` is the other half of the same idea. A deck is text; so is a video. The
narration is a list of sentences, the visuals are React, and **everything else is derived** —
including the timing.

```bash
npx narro-video new ./launch-teaser     # a project that renders immediately
npx narro-video studio ./launch-teaser  # scrub, play, switch formats
npx narro-video render ./launch-teaser  # the masters
```

It needs **ffmpeg** and **ffprobe** on `PATH`, and **Chrome or Chromium** installed — the
renderer drives your system browser rather than downloading its own. `npx narro-video doctor`
says whether all of that is where a render needs it.

> This page is the map. The package's
> [README](https://github.com/getnarro/narro/tree/main/packages/video) is the reference, and
> [MIGRATING-FROM-REMOTION.md](https://github.com/getnarro/narro/blob/main/packages/video/MIGRATING-FROM-REMOTION.md)
> is the page to read first if you are coming from Remotion.

## The one guarantee

Every duration in the video is **measured, not chosen**. Each cue is synthesised to its own WAV
and measured; a scene's length is the sum of its cues; the composition's length is the sum of
its scenes. `buildTimeline` refuses to run without a voiceover manifest rather than estimating
one.

```ts
export const script = defineScript({
  voice: "af_heart",
  cues: [
    { id: "hook", text: "A deck is just text.", scene: "title" },
    { id: "claim", text: "So write it, and let the tools do the rest.", scene: "title", hold: 8 },
  ],
});
```

Edit a sentence and the video re-times itself: the cut moves, the captions re-page, the `.srt`
regenerates, and any artifact the composition derives from its timeline — chapters, a transcript,
a manifest for a player — is rewritten with it. Nothing is re-cut, because nothing was cut by
hand.

Everything below follows from that. A translation costs what the original did. A channel cut is
a list of sentence ids. A review can name the sentence at fault. A render can tell which scenes
changed. None of it is available to a framework where `durationInFrames` is a number someone
typed.

## Commands

<!-- generated:video-commands -->
| Command | What it does |
| --- | --- |
| `narro-video new <dir>` | scaffold a reusable video project |
| `narro-video script <dir>` | write the brief a model answers with the first draft of the narration, then turn the answer into script.ts |
| `narro-video studio <dir>` | scrub and play the composition in a browser |
| `narro-video render <dir>` | write the masters into \<dir>/out/, re-using every scene whose source and narration have not changed since the last render |
| `narro-video cache <dir>` | report what the render cache holds, or empty it |
| `narro-video still <dir>` | write one frame as a PNG, straight from the page |
| `narro-video check <dir>` | lay the composition out in every declared format and report text that leaves the frame or is clipped, then check every citation against its source and the script against the brand policy |
| `narro-video review <dir>` | watch the composition the way a director would and write a briefing: frozen holds, dead air, pace, caption legibility, platform safe zones, late entrances, an empty poster |
| `narro-video apply <dir>` | turn a studio feedback export, or a review, into a patch to script.ts |
| `narro-video stale <dir>` | which cited sources have changed since the last render |
| `narro-video approve <dir>` | write the one document a person approves: what the video says, what each claim rests on, the policy, the layout check and the director's pass |
| `narro-video record <dir>` | run every media/\<clip>.record.json against the product on virtual time and write the clip with its marks and cursor path; `--check` re-records and diffs against the stored one |
| `narro-video footage <dir>` | extract media/ recordings into frames the renderer can capture |
| `narro-video brief <dir>` | write the brief a model answers with a variant of this video |
| `narro-video cover <dir>` | write the brief a model answers with a cover image, then draw it |
| `narro-video retention <dir>` | map a retention export onto the script and write the sentences that cost the most audience |
| `narro-video dataset <dir>` | render one video per row of a dataset |
| `narro-video list <glob>` | the catalog: every composition a glob matches, with the runtime read from its built timeline rather than remembered |
| `narro-video diff A.mp4 B.mp4` | compare two renders frame by frame — mean and worst SSIM and PSNR, and the frames that scored worst |
| `narro-video migrate <dir>` | report what a Remotion composition needs in order to move here, and rewrite only the renames |
| `narro-video doctor` | report whether ffmpeg, Chrome and the fonts a render needs are here |
<!-- /generated:video-commands -->

## A composition is four files

| File | What it decides |
| --- | --- |
| `script.ts` | The narration, as cues — and with it every duration in the video |
| `brand.ts` | Colours, type, fonts and grounds, read by every scene through `useBrand()` |
| `src/scenes/<id>.tsx` | What each scene draws. One file per scene, named after it |
| `src/composition.tsx` | Which scenes exist, in what order, in which formats |

`narro-video new` writes all of them, plus a determinism test and a `vite.config.ts` with the
[render cache plugin](#rendering-only-what-changed) already wired in. Templates:
<!-- generated:video-templates-inline -->`starter`<!-- /generated:video-templates-inline -->. Brand
presets: <!-- generated:video-brands-inline -->`midnight`, `daylight`, `ember`, `forest`, `terminal`<!-- /generated:video-brands-inline -->.

The eight entry points are
<!-- generated:video-entry-points-inline -->`@getnarro/video`, `@getnarro/video/renderer`, `@getnarro/video/tts`, `@getnarro/video/studio`, `@getnarro/video/player`, `@getnarro/video/testing`, `@getnarro/video/vite`, `@getnarro/video/compat`<!-- /generated:video-entry-points-inline -->. Only
the first is needed to draw a frame, and it is browser-safe; the renderer and the engines are
Node, and a headless render loads neither the studio nor its design system.

## One composition, every format

<!-- generated:video-formats -->
| Format | Size | Shape |
| --- | --- | --- |
| `landscape` * | 1920×1080 | 16:9 |
| `square` | 1080×1080 | 1:1 |
| `portrait` | 1080×1350 | 4:5 |
| `vertical` * | 1080×1920 | 9:16 |

\* Declared by `narro-video new` unless `--format` says otherwise.
<!-- /generated:video-formats -->

The same source serves all of them, which is why **`px` is a lint error** in a scene and every
length is a container unit. `cqmin` is a percentage of the canvas's smaller dimension, so type
renders at the same size in every format.

`narro-video check` lays the composition out at each declared format and reports text that
leaves the frame or is genuinely clipped. It is the one visual defect no source sweep can see:
the source that overflows in 1080×1920 is character-for-character the source that fits in
1920×1080.

## Any voice, and more than one

Kokoro is bundled, runs offline and needs no key. It is not the only option:
`synthesizeScript` takes any `TtsEngine` — a voice list, a fingerprint, and one call per
sentence — and does the rest.

<!-- generated:video-engines-inline -->`clonedVoice()`, `commandEngine()`, `elevenLabsEngine()`, `kokoroEngine()`, `openAiEngine()`<!-- /generated:video-engines-inline -->

```ts
import { clonedVoice, elevenLabsEngine, openAiEngine } from "@getnarro/video/tts";

await synthesizeScript({
  script,
  engine: clonedVoice({ engine: elevenLabsEngine(), reference: "recordings/founder.wav", cacheDir }),
  engines: { openai: openAiEngine() },
});
```

Whatever an engine reports about duration is ignored: the WAV is measured here, exactly as a
hand recording is, so swapping the engine changes nothing downstream. A cloned brand voice is a
recording checked into the repository and registered once, under that recording's hash — so
re-recording the reference re-synthesises every cue in it.

A cue can name a **speaker**, and a speaker names a voice and an engine:

```ts
defineScript({
  voice: "af_heart",
  speakers: { guest: { voice: "nova", engine: "openai" } },
  cues: [
    { id: "q", text: "Why does the timeline reflow?", scene: "qa" },
    { id: "a", text: "Because it is derived from the audio.", scene: "qa", speaker: "guest" },
  ],
});
```

The name flows into the manifest, `<Captions showSpeaker />` and
`toSrt(cues, fps, { speakers: true })`.
The cache is keyed by engine and fingerprint as well as text, voice and speed, so two engines
with a voice of the same name never share a file. `NARRO_TTS_DRY_RUN=1 npm run audio` says what
a run would synthesise, in characters per engine, before it spends a hosted engine's budget.

Prefer your own voice? Drop `recordings/<cue id>.wav` in and that cue is never synthesised — it
is measured, and the timeline rebuilds around your pace.

## Rendering only what changed

```bash
npx narro-video render ./my-video            # reuses every piece whose inputs match
npx narro-video render ./my-video --no-cache # captures everything
npx narro-video cache ./my-video             # what the cache holds; --clear empties it
```

A render is split into **scene-aligned pieces**, each keyed on everything that can change a
pixel in it: the scene's source, its cues' text and measured timing, the brand, the footage
manifest, the format, the encode settings, the props, the variant, and the browser build. A
piece whose key was rendered before is concatenated from `.cache/render/` instead of captured
again.

Rewrite one sentence and the render re-captures that sentence's scene — and its neighbours,
where a transition draws one over the other. Nothing else.

That is only possible because every frame is a pure function of the frame number, which the
determinism sweep enforces. Two things decide how fine-grained the reuse is:

- **`narroVideo()` in `vite.config.ts`.** The plugin records what each module was built from,
  with `script.ts` marked timing-only — its effect on a frame is the timeline the key already
  carries, so a rewrite invalidates no scene's *source*. Without it a render is keyed on the
  whole bundle: correct, and any edit re-renders everything. The scaffold includes it.
- **One file per scene, named after it.** `src/scenes/<id>.tsx` is what a scene is matched to.

## The director's pass

`check` asks whether any text leaves the frame. `review` asks the rest of what a director asks
of a cut, at a sample of frames per format:

<!-- generated:video-review-rules -->
| Rule | Why it is a rule |
| --- | --- |
| `frozen-hold` | A scene that finishes its visual story and then holds one identical frame until the cut reads as a slide, not a video. Scene lengths are derived from narration, so the fix is to say less in that scene or split it — never to stretch the motion. |
| `dead-air` | Silence longer than a beat reads as a fault in a feed. A hold is a decision worth making on purpose, and a scene nothing is said in has no length of its own to justify. |
| `pace` | A sentence too long for one caption card pages mid-thought on a phone, and one spoken faster than the eye reads a card is lost. Both are fixed by rewriting the sentence. |
| `caption-legibility` | Captions are read in every format on the brand's own grounds. A caption colour that falls under the AA contrast ratio on one surface is legible while scrubbing and invisible in a feed. |
| `safe-zone` | Vertical and portrait platforms draw their own chrome over the frame — the caption, the action rail, the progress bar — and text under it is text nobody sees. |
| `late-arrival` | The narration starts on the scene's first frame. When the words on screen arrive well after the sentence began, the viewer hears a claim over an empty canvas. |
| `empty-poster` | The poster is the one frame a platform shows before playback. A poster with nothing on it is the video's first impression, made by nothing. |
<!-- /generated:video-review-rules -->

```bash
npx narro-video review ./my-video            # → out/<id>-review.md
npx narro-video review ./my-video --strict   # non-zero exit on any finding, for CI
```

Every finding names a **sentence or a scene**, the numbers it decided on, and the file that
decides it — because every frame is under a known cue. That is what makes the output an
instruction rather than a report: *"scene `ladder` holds one frame for 6s of its 9s while
`climb` is narrated"* is fixed by shortening or splitting `climb` in `script.ts`, and the scene
reflows.

The briefing is the same document the studio exports, with the same JSON appendix, so
`narro-video apply . --feedback out/<id>-review.md` reads it back and turns it into a patch to
`script.ts`. Render, review, apply, re-render — with a model in the loop and nobody watching
the video by hand.

## Product footage that re-records itself

A screen recording is the one input to a product video that is not text, and the one that goes
stale first. `media/<clip>.record.json` makes it text:

```json
{
  "url": "/dashboard",
  "viewport": { "width": 1440, "height": 900 },
  "storageState": ".auth/state.json",
  "steps": [
    { "id": "settle", "wait": 1 },
    { "id": "hover", "hover": "[data-test=export]", "settle": 0.4 },
    { "id": "click", "click": "[data-test=export]" },
    { "id": "done",  "waitFor": "text=Exported", "settle": 1.5 }
  ]
}
```

Steps are
<!-- generated:video-recording-actions-inline -->`goto`, `click`, `hover`, `type`, `press`, `scroll`, `waitFor`, `wait`<!-- /generated:video-recording-actions-inline -->.
The spec runs in the same pinned Chrome the renderer captures with, and **on virtual time**: the
page's clock is advanced one frame per screenshot, the browser's frames are issued under the
same control, threaded animation is off so CSS transitions follow that clock, and `Date.now()`
is pinned to a fixed day. Every wait is one check per recorded frame, from outside the page.

```bash
npx narro-video record ./my-video --base-url http://localhost:3000
npx narro-video record ./my-video --check     # has the product changed?
```

Three things fall out of the recording being scripted:

- **Marks.** Every step's id is a moment in the clip, in seconds — `useClip("dashboard").marks.click`
  is the second the button was pressed. `url` is navigated before the first step, under the mark
  `start`.
- **Alignment.** `<Footage clip="dashboard" align={{ mark: "click", cue: "one-click" }} />`
  offsets the clip so the click lands on the first frame of the sentence that says it. Both
  sides are measured, so the click stays on the word "click" after either is re-recorded.
- **A cursor drawn rather than baked.** `<Footage cursor />` draws the recorded pointer path in
  container units, with a press on each click — crisp in every format, where a baked cursor is
  32px in a 1920 recording and gone in a vertical crop.

`--check` re-records into a temporary file and diffs against the stored clip: the product's UI
changed exactly where the SSIM fell, and the worst frames say where. Two limits, both measured
rather than assumed: **one page per recording** — a second `goto` resets the document's clock in
a way virtual time cannot pin, so such a clip is captured and marked not frame-exact — and a
page virtual time cannot drive at all sets `"deterministic": false` and is captured in real time.

## Accountable video

Nothing in a timeline editor knows what a video *says*. Here the text is the source of truth, so
every sentence can carry its evidence:

```ts
{
  id: "stat",
  text: "Ninety-two percent faster to first frame.",
  scene: "proof",
  cite: { source: "docs/benchmarks.md", quote: "92% faster to first frame" },
}
```

`check` reads the source and looks for the passage, whitespace and case aside, and reports a
source it cannot read or a quote that is not in it. The script brief asks a model for a citation
on every sentence carrying a number, a percentage or a superlative — and says that a claim with
nothing to quote is a claim to cut.

**Brand as policy.** Beyond colours and type, a brand is what its videos never say and always
do:

```ts
export const brand = defineBrand({
  id: "acme",
  policy: {
    bannedWords: ["revolutionary", "best-in-class"],
    requireCitations: "claims",
    maxWordsPerCue: 22,
    closingScene: "end-card",
    required: [{ contains: "results may vary", because: "legal asks for it on every benchmark" }],
  },
});
```

`check` enforces it against the script that actually built, and the brief hands it to the model
so it writes inside the policy the first time.

Every render writes `<name>-<format>.claims.json` beside the SRT: each cited sentence, its
source, the passage, a hash of the source as it stood, and the frames it is on screen for.
`narro-video stale` reads every cited source again and compares — a changed hash is a source
that moved, a passage no longer found is evidence that is gone — and exits non-zero, so a CI job
can name every published video whose evidence moved.

`narro-video approve` runs the layout check, the policy check, the director's pass and the
staleness check, and writes the one document a person approves: what the video says, what each
claim rests on and whether that still stands, what every check found, and where the master and
the poster are. The check says *cited and present*, never *true* — a passage existing in a
source is evidence, not proof.

## One video, every locale and channel

```bash
npx narro-video brief ./my-video --locale de-DE   # what to answer with
NARRO_LOCALE=de-DE npm run audio                  # narrate the answer
npx narro-video render . --locale de-DE           # → out/de-DE/
```

A dub breaks the edit everywhere else: the German runs 30% longer and someone re-cuts by hand.
Here the timeline is rebuilt from the new audio — every scene grows to fit its own narration,
every caption re-pages, the SRT regenerates. A locale is one JSON file keyed by cue id, and a
partial one fails the build rather than shipping a sentence in the source language.

A **channel cut** is a list of sentence ids: `{ "keep": ["hook", "claim", "cta"] }`. Choosing
which *sentences* survive is a task a model does well; choosing which *frames* survive is not,
and here it never has to. A cut needs no audio stage at all, because the narration it keeps has
already been measured.

Both compose — `--locale de-DE --cut linkedin` renders into `out/de-DE/linkedin/` from one
build — and `narro-video dataset` renders one video per row of a JSONL file, synthesising each
distinct sentence once across the whole run.

## Publishing it

`--codec` takes <!-- generated:video-codecs-inline -->`h264`, `h265`, `vp9`, `prores`<!-- /generated:video-codecs-inline -->,
each in a container that can hold it. Every mp4 gets `+faststart`. Delivery loudness is
normalised by default — <!-- generated:video-loudness-inline -->`social`, `broadcast`, `none`<!-- /generated:video-loudness-inline -->,
where `social` is -14 LUFS — because a mix delivered off target is inaudible in the studio and
unmissable in a feed.

`narro-video cover --channel NAME` writes the brief a model answers with a cover image, then
draws it. The channel is one of
<!-- generated:video-cover-channels-inline -->`linkedin`, `opengraph`, `x`, `youtube`, `square`, `story`<!-- /generated:video-cover-channels-inline -->,
and it decides the size. The words come from the model, the palette and type from `brand.ts`,
and the picture is named as a *sentence* rather than a frame number — so it still points at the
same moment after a re-record.

`narro-video retention --data analytics.csv` maps a platform's retention export onto the cues
and ranks the sentences that cost the most audience. Every platform can tell you the audience
left at 0:14; none of them can tell you which sentence that was, because none of them derived
the timing from the sentences.

## The determinism contract

Every visual must be a pure function of the frame number. Wall-clock code scrubs fine in the
studio and *flickers* in the render — a failure visible only in finished video, after the
expensive part. `sceneViolations` from `@getnarro/video/testing` enforces it as a source sweep,
and the scaffold ships the test:

| Rule | Why |
| --- | --- |
| No `Date.now`, `Math.random`, `performance.now`, `requestAnimationFrame`, `setTimeout` | Frame N is captured by a worker that never evaluated frames 0..N-1. Seed it instead: `random(seed)` |
| No CSS `transition:` or `animation:` | Same reason. Animate with `interpolate`, `spring` and `stagger` |
| No `px`; every `fontSize` in `cqmin`, `cqh` or `cqw` | One composition serves every format above. A `px` size is legible in exactly one |
| No `className`, no stylesheet imports | Styling must be inline to stay scannable by the rules above — a font sheet is the documented exception |

That contract is also what makes the render cache safe, the recorder diffable, and a review
reproducible. It is the load-bearing rule of the package.

## Driving it from an assistant

Every command above either writes a briefing a model answers or reads such an answer back, and
[`@getnarro/mcp-server`](/docs/ai-integration) exposes eleven of them as MCP tools —
`video_script`, `video_render`, `video_check`, `video_review`, `video_apply`, `video_brief`,
`video_cover`, `video_retention`, `video_record`, `video_stale`, `video_approve`. An assistant
with those can run the whole loop: draft the narration from source material, render, watch its
own output, patch the script, re-render, and hand a person one document to approve.