🎬 Video

How do I turn a story into a video, and keep it right when the words, the product or the source changes?

17 min readView as markdown

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

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 is the reference, and 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.

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

CommandWhat 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.mp4compare 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 doctorreport whether ffmpeg, Chrome and the fonts a render needs are here

A composition is four files

FileWhat it decides
script.tsThe narration, as cues — and with it every duration in the video
brand.tsColours, type, fonts and grounds, read by every scene through useBrand()
src/scenes/<id>.tsxWhat each scene draws. One file per scene, named after it
src/composition.tsxWhich 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 already wired in. Templates:

`starter`. Brand

presets: midnight, daylight, ember, forest, terminal.

The eight entry points are

`@getnarro/video`, `@getnarro/video/renderer`, `@getnarro/video/tts`, `@getnarro/video/studio`, `@getnarro/video/player`, `@getnarro/video/testing`, `@getnarro/video/vite`, `@getnarro/video/compat`. 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

FormatSizeShape
landscape *1920×108016:9
square1080×10801:1
portrait1080×13504:5
vertical *1080×19209:16

* Declared by narro-video new unless --format says otherwise.

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.

`clonedVoice()`, `commandEngine()`, `elevenLabsEngine()`, `kokoroEngine()`, `openAiEngine()`
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:

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

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:

RuleWhy it is a rule
frozen-holdA 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-airSilence 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.
paceA 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-legibilityCaptions 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-zoneVertical 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-arrivalThe 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-posterThe 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.
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:

{
  "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

`goto`, `click`, `hover`, `type`, `press`, `scroll`, `waitFor`, `wait`.

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.

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:

{
  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:

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

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 h264, h265, vp9, prores, each in a container that can hold it. Every mp4 gets +faststart. Delivery loudness is normalised by default — social, broadcast, none, 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

`linkedin`, `opengraph`, `x`, `youtube`, `square`, `story`,

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:

RuleWhy
No Date.now, Math.random, performance.now, requestAnimationFrame, setTimeoutFrame 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 cqwOne composition serves every format above. A px size is legible in exactly one
No className, no stylesheet importsStyling 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 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.

IntroductionWhat is Narro, and should I be writing markdown or React?
InstallationWhat do I install, and what does the project look like afterwards?
First DeckWhat does a working React deck look like, end to end?
Markdown ModeHow do I split a slide, style one word, or reveal a line — without leaving the markdown file?
Writing AI PromptsWhat do I tell an AI so the deck it writes actually builds?
Rules for AI AgentsWhat do I paste into my repo so an agent stops writing decks that build wrong?
React APIWhich component or hook do I import, and what does it take?
CLIWhich command do I run, and what are its flags?
Markdown APIHow do I read, edit, or validate a deck file from my own code instead of by hand?
Component ReferenceWhat props does this component take, and which package do I import it from?
Verifying a deckHow do I know my deck is actually correct?
AnimationHow do I reveal a list one line at a time, or move between slides with something other than a cut?
NavigationHow does the audience move through the deck, and how do I present it?
Canvas & PositioningHow do I put something at an exact position instead of in the flow?
Images & MediaHow do I use an image as a background, tint it, or embed a video?
ArchitectureWhich package owns what, and why is the seam where it is?
Zooming canvasHow do I zoom and pan across one big canvas instead of cutting between slides?
Transform ModeHow do I zoom and pan across one big canvas instead of cutting between slides?
Import & ExportHow do I get this deck out as PPTX, PDF, or one file I can email?
VideoHow do I turn a story into a video, and keep it right when the words, the product or the source changes?
Recording a web appHow do I put a demo of my product in a deck or on a landing page without shipping a video of it?
Embedding a replayHow do I embed a recorded demo of my app in my React app, my Astro site, or any other page?
TerminalHow do I put a terminal demo in a deck, a README or a video, and find out when the tool it demonstrates stops saying what the demo says?
AI IntegrationHow do I wire an AI assistant up to Narro so it can write and build decks?
ThemingHow do I change the colours, fonts, and overall look of a deck?
Deck TemplatesHow do I define one house style with named layouts my slides can reference, like a PowerPoint master?
TroubleshootingSomething is wrong with my deck. What is it, and how do I fix it?
LimitationsWhat can't Narro do, and what do I do instead?