🎬 Video
How do I turn a story into a video, and keep it right when the words, the product or the source changes?
@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
| 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 |
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 already wired in. Templates:
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`. Onlythe 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
| 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.
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.
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()invite.config.ts. The plugin records what each module was built from, withscript.tsmarked 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>.tsxis 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:
| 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. |
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.clickis the second the button was pressed.urlis navigated before the first step, under the markstart. - 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
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 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.