@getnarro/video
v0.7.0
Published
Frame-deterministic video framework for Narro — a brand system, a React timeline runtime, narration-derived timing, a headless-Chrome renderer, and a scrub studio
Maintainers
Readme
@getnarro/video
Frame-deterministic video framework for Narro — a brand system, a React timeline runtime, narration-derived timing, a headless-Chrome renderer, and a scrub studio
getnarro.com · Documentation · Ecosystem
Turn a React composition into a finished video. Three things in one package: a brand — colours, type, grounds and captions, declared once and read by every scene — a runtime of frame-deterministic timeline primitives, and a renderer that drives your system Chrome frame by frame, screenshots each one, and hands them to ffmpeg to encode and mux.
Narro's decks are text. This is the same idea for motion: the composition is code, the narration is a list of sentences, and everything else is derived.
New here? getnarro.com/docs/video is the guided tour — the pipeline, the commands and what a derived timeline buys. This file is the reference.
npx narro-video new ./my-video # scaffold a project you can render immediately
npx narro-video studio ./my-video # scrub, play, switch formats
npx narro-video render ./my-video # produce the mastersrender accepts --format landscape|vertical|square, --quality draft|final, and
--workers N. With no --format it renders every format the composition declares.
Output lands in <dir>/out/ — an .mp4, a poster .png, and an .srt sidecar per format,
plus a .gif when you ask for one.
Every release is recorded in CHANGELOG.md, which ships in the published package as well as in the repository.
npx narro-video script ./my-video --from ./product.md --seconds 45 # ask an AI for the script
npx narro-video still ./my-video --frame 120 # one frame, straight from the page
npx narro-video check ./my-video # does any text leave the frame?
npx narro-video retention ./my-video --data analytics.csv # which sentence lost the audience
npx narro-video brief ./my-video --locale de-DE # ask an AI for a translation
npx narro-video cover ./my-video --channel linkedin # ask an AI for a cover image
npx narro-video apply ./my-video --feedback notes.md # turn studio notes into a patch
npx narro-video list "apps/*" # every video in the repo, with measured runtimes
npx narro-video diff before.mp4 after.mp4 # per-frame SSIM and PSNR, and the worst frames
npx narro-video migrate ./my-video # what a Remotion composition needs to move hereComing from Remotion? MIGRATING-FROM-REMOTION.md is the map,
and its first table is the one to read: spring and interpolate exist on both sides with the
same names and different behaviour, and they are the only two things that can produce a wrong
video rather than a compile error.
Requirements
- ffmpeg and ffprobe on
PATH - Chrome or Chromium installed. The renderer uses your system browser rather than
downloading its own, so a render never depends on a ~500MB browser install. Set
CHROME_PATHif it lives somewhere unusual.
npx narro-video doctor # says whether all of that is where a render needs itWhat you have to install
react and react-dom are the only peer dependencies a render needs. The @cascivo/* packages
and @preact/signals-react are also declared as peers — they are optional, and reachable
only from the @getnarro/video/studio and @getnarro/video/player subpaths. A headless render
never loads them, so a CI job that only renders installs neither a UI toolkit nor a signals
library. See the chrome is cascivo, and it is optional.
Worth running first, and worth running in CI: each of those failures otherwise surfaces later and less clearly. A missing ffmpeg fails after the audio stage and the whole capture; a missing font does not fail at all, and the render succeeds looking wrong.
The first audio stage downloads the kokoro TTS model (~90MB) into a local cache. That happens once.
Where the script comes from
Everything below derives from the narration. This is the one command that comes before it.
npx narro-video script ./my-video --from ./product.md --seconds 45 \
--audience "engineering leaders picking build tooling"
# → out/script-brief.md
npx narro-video script ./my-video --draft script.json --write
# → script.tsThe brief carries the source material and asks for sentences. What makes it worth generating
here rather than in a chat window is the word budget: 45 seconds is about 113 words, and a
model told only "45 seconds" writes ninety. That number is the one estimate in this package —
everything else is measured — and it exists because at origination there is no audio to measure
yet. The moment npm run audio runs it is replaced by the real durations and the video reflows
to them. Nothing is cut to fit.
The rest of the brief is the part that turns marketing prose into narration, which is a different craft: prose is skimmed and this is heard once, in order, with no way back. So it asks for the payoff in the first sentence rather than the setup, one idea per cue — a cue is a caption card as well as a timing unit — cue ids named for what the sentence does, and one action at the end rather than three. And it says the thing a model reaching for a video script otherwise always does: no timings, no shot directions, no camera moves. There are no frame numbers to hit here.
--draft turns the answer into script.ts, with the scene list it needs you to declare. It
refuses a scene nothing is said in — a scene's length is the sum of its cues, so a silent one
has no length — and it refuses two cues sharing an id, because a cue id is what a translation, a
channel cut, a studio note and a cover image all address. An existing script.ts is shown as a
diff and needs --write, the same as apply.
The voiceover drives the timeline
Each cue is synthesised to its own WAV and measured. Scene lengths are the sum of their cues' real durations; the composition's total length is derived from those. Editing a sentence reflows the timeline, and captions, audio and visuals move together because they all read the same measured data.
This is the one guarantee the package makes, and it is why 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: "outro", text: "Narro.", scene: "outro", hold: 20 },
],
});A scene with no cues declares its own durationInFrames instead. A scene with cues may not.
Bringing your own audio stage
synthesizeScript is not mandatory. A project that already has an audio pipeline can hand
buildTimeline a manifest it wrote itself — the shape is plain data:
const voiceover: VoiceoverManifest = {
voice: "af_heart",
speed: 1,
cues: [
{
id: "hook", // matches a cue id in script.ts
wav: "hook.wav", // relative to the composition's .assets directory
seconds: 3.42, // measured, and the only required number
tokens: [...], // optional: per-word spans from a forced alignment
bands: [[...], ...], // optional: per-frame frequency bands, each in [0, 1]
},
],
};Without tokens, word windows are estimated from word length and punctuation; with them, the
measurement wins — an estimate and a measurement should never be averaged, and the manifest is
the one place that can tell them apart. Without bands, timeline.bands is undefined rather
than an array of zeros, so a visual can tell "silent here" from "nothing was analysed".
If buildAudioTracks' one-WAV-per-cue model does not fit, <RenderRoot> takes audioTracks
directly.
If your pipeline rounds differently
buildTimeline computes a cue's length as Math.ceil(seconds * fps). If yours rounded some
other way, the difference is a frame or three per cue and it compounds: every cut, caption
chunk, SRT timestamp and chapter marker moves.
buildTimeline({
fps,
scenes,
script,
voiceover,
// Audio rounded up to a tenth of a second before being converted to frames.
roundCueFrames: (seconds, fps) => Math.ceil((Math.ceil(seconds * 10) / 10) * fps),
});Say it here rather than absorbing it into hold. hold means "a visual beat of silence", and a
field that also carries rounding compensation is one nobody can read afterwards.
Static assets
There is no staticFile: the project owns its build, so the project decides. Three places,
which are not interchangeable:
| Where | For | Referenced as |
| --- | --- | --- |
| media/ | recordings, animated GIFs | <Footage clip="demo" />, after narro-video footage . |
| src/ | anything the bundler should hash | import logo from "./logo.svg" |
| public/ | anything that needs the URL it has | /logo.svg |
Prefer the first two. media/ gives you a deterministic extracted clip; a src/ import gives
you a content-hashed URL and a build error when the file is missing, rather than a missing image
in one frame of the finished video.
narro-video new scaffolds public/, and the generated vite.config.ts copies it into
.assets/ before dev and before build. .assets/ has to be Vite's one publicDir — the page
fetches /voiceover.json and /footage.json out of it, and ffmpeg reads the WAVs off it at mux
time — so the files you own are copied in rather than served from where they live.
Entry points
| Import | What it is |
| --- | --- |
| @getnarro/video | The runtime: interpolate, interpolateColors, spring, stagger, random, Sequence, Loop, Freeze, TransitionSeries, Trail, useFrame, useBeats, useAudioBands, buildTimeline, Captions, Fill, FitText, measureText, fitText, RenderRoot, defineBrand, useBrand, useColors, Stage, Surface, Footage, coverBrief, parseCoverSpec, coverDocument, and the path, shape and noise helpers. Browser-safe, no Node dependencies. |
| @getnarro/video/renderer | Capture, encode, mux, writeGif, renderStill, renderCover, checkOverflow, reviewComposition, extractFootage, recordProject, diffVideos, migrateProject, scaffoldVideo, listProjects and the glob helpers behind list, the render cache, and the static server the CLI runs. Node only. |
| @getnarro/video/compat | remotionSpring, remotionMeasureSpring, remotionInterpolate — faithful ports of the Remotion functions whose names this package shares but whose output it does not. A migration aid; see MIGRATING-FROM-REMOTION.md. |
| @getnarro/video/tts | synthesizeScript (content-addressed cache, hand-recorded cues), the engines — kokoroEngine, elevenLabsEngine, openAiEngine, commandEngine, clonedVoice — planSynthesis, analyzeBands and toSrt. Node only. |
| @getnarro/video/studio | <Studio> — the editing console: scene outline, script, captions, timeline, against the exact tree the renderer captures. |
| @getnarro/video/player | <Player> — the same tree, embeddable, with a clock, audio, events and an imperative handle. Browser-safe. |
| @getnarro/video/testing | sceneViolations, explainViolations and SCENE_RULES, for enforcing the determinism contract in your own test suite. |
What this package is, and is not
It is a runtime, a renderer and a design-token layer: timing, scheduling, motion, capture, encode, narration, captions, and a brand whose colours, type and grounds every scene reads from. Everything above is what it ships.
It is not a component library. There is no card, no diagram vocabulary, no chart, no stat
block. <Stage>, <Surface>, <Fill>, <Captions>, <FitText>, <Footage>, <Loop>,
<Trail> and <TransitionSeries> are structural — they carry no design opinion beyond the brand — and that
is the whole of the built-in visual surface.
This is a deliberate boundary, not a gap waiting to be filled. A card that looks right for one
series looks wrong for the next, and the pieces that make a video yours — your diagram
language, your reserved colours, your callout style — are the pieces that should not be shared.
Budget for a components package of your own. Point it at useColors() and the type scale,
give every component a surface prop rather than a colour prop, and it will stay on brand for
free while remaining entirely yours.
The exception is the vocabulary that is the same in every product video — a lower third, a
stat that counts up, a benefit grid, a testimonial, browser chrome around a screen recording, a
callout ring, an end card. @getnarro/video-marketing is those, built to
the same contract: no colours of their own, container units throughout, and swept by
sceneViolations in its own test suite so that importing them cannot smuggle a wall-clock read
into a video.
What that package should not have to reinvent, and does not: container-query sizing, the determinism sweep, text that fits every format, frame-accurate scheduling against measured narration, and a palette that resolves per ground.
Starting from a template
npx narro-video new ./launch-teaser --brand ember --format landscape,verticalIt writes a video that already renders — five cues, three scenes, a brand and a determinism test — rather than a skeleton, because the first thing anyone does with a scaffold is run it, and one that renders nothing until you have written a scene teaches you nothing about whether your ffmpeg and your Chrome are where they need to be.
| File | What it decides |
| --- | --- |
| script.ts | The narration — and with it every duration in the video |
| brand.ts | Colours, type and fonts, 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 |
It also writes a NOTES.md: a per-project home for open items and decisions. That is a default
rather than a feature — with nowhere per-project to put them, they accumulate in a document
shared by every video in the repository, which is the file that conflicts in every pull request.
The generated project carries its own AGENTS.md with the contract below, and a npm test
that enforces it. --brand picks the preset the generated brand.ts extends; --template
picks the layout. scaffoldVideo from @getnarro/video/renderer is the same thing as a
function.
The template's own scenes are swept by sceneViolations and parsed by the TypeScript compiler
in this package's test suite, so a template that would not build cannot ship.
A dedicated brand
A brand is the one place a composition's look is declared, and the reason a second video is
cheap: it extends the first one's brand rather than copying its hexes.
export const brand = defineBrand({
id: "acme",
extends: "midnight",
colors: { primary: "#ff5c00", accent: "#ffd400" },
fonts: { display: { family: "Acme Grotesk", src: acmeGroteskWoff2, weights: [700, 900] } },
});
export const composition = defineComposition({ id: "acme-launch", brand, /* … */ });Scenes read it with useBrand(); RenderRoot and <Studio> both install it, so what you
scrub is what the renderer captures. <Stage> is the composition root and sets
container-type: size for you — the one line whose absence makes every cq unit in the video
resolve against the page instead of the canvas, silently and at the wrong scale.
| | |
| --- | --- |
| colors | background, surface, foreground, muted, primary, secondary, accent, positive, negative, warning, scrim, and a series array for repeated elements. The first six are the names @getnarro/marketplace colour schemes use, so a deck's scheme spreads straight in and a video matches the slides it came from. |
| fonts | display, body, mono. Point src at a woff2 imported with Vite's ?inline suffix and installBrandFonts registers it as a data URI and warms every weight before the first frame — an unwarmed weight is captured unstyled in exactly one frame, which is the kind of defect nobody finds until the video is finished. |
| type | The scale, merged over the framework's. |
| radius, gutter, captions | Corner radius, the safe inset every scene lays out against, and the caption colours — which default to foreground on scrim, so a light brand's captions invert with it. |
Presets: midnight, daylight, ember, forest, terminal. Each is a starting point, not a requirement.
defineBrand refuses a length that is not a container unit and a colour that is not a string.
That matters most for brandFromJson, which is how a brand is shared with tools that are not
TypeScript: JSON is the one path into a brand that no compiler and no source sweep can check.
Grounds a scene can stand on
A brand states one ground. Most design systems state several — a dark one, a bright one, a
saturated one for an outro — and rule which pairings are legal on each. surfaces names them,
so a scene selects a ground instead of picking colours:
export const brand = defineBrand({
id: "acme",
colors: { background: "#000000", foreground: "#ffffff", primary: "#ff5c00" },
surfaces: {
bright: { background: "#f7f7f7", foreground: "#000000", scrim: "rgba(247,247,247,0.78)" },
signature: { background: "#ff5c00", foreground: "#000000", logo: { src: primaryLogo } },
},
});<Stage surface="bright"> {/* the whole composition */}
<Surface name="signature"> {/* or a region of one */}
<Outro />
</Surface>
</Stage>| | |
| --- | --- |
| useColors() | The active ground's palette. This is the hook a scene wants. |
| useSurface() | The ground itself — name, colors, overrides, logo. For a component that branches on which ground it is on. |
| useBrand() | The brand as declared. Always the base ground, deliberately: it is the declaration, not the context. |
A surface is a diff against colors, not a replacement for it. bright above never restates
primary, so it keeps the brand's — and keeps tracking it when the brand's primary moves,
including across extends. That is the whole point: the alternative is a second palette that
agrees with the first until someone edits one of them.
Three things follow from a scene naming its ground rather than its colours:
<Captions>inverts with it.brand.captionsis a contrast decision made against the brand's ground, so offbasethe surface's ownforegroundandscrimwin. Without this, a white-on-dark caption in the one bright scene is near-white text on a near-white scrim — legible while scrubbing a dark composition, invisible in the finished video.<BrandMark>picks the legal variant. Which logo is readable is a property of the ground, not of the scene sitting on it, so a surface carries its ownlogo.- A typo throws.
resolveSurfacerefuses an unknown name rather than falling back tobase. A silent fallback renders the scene in the wrong palette and still exits zero.
Each surface and the palette inside it are frozen individually, so
brand.surfaces.bright.colors.accent = "…" fails where it happens rather than surfacing as a
wrong colour in some other scene that shares it.
Warming the fonts
A font file is only fetched when something on the page uses it, and a <Sequence> mounts on the
frame it first appears — so the first frame using Bold races Bold's arrival and can be captured
unstyled. One frame in a thousand, in the wrong typeface, found after the video is finished.
Both entry points exist to prevent exactly that:
await installBrandFonts(brand); // registers the brand's @font-face rules, warms every weight
await loadFonts([{ family: "Inter", weights: [400, 700] }]); // families you name yourselfinstallBrandFonts(brand) is the one to call. It derives the face list from the brand, so the
weights that get warmed cannot drift from the weights the composition declares — and it needs no
stylesheet, which is why a composition using it passes the strict determinism sweep.
loadFonts(faces) is the layer underneath, for a project whose fonts arrive some other way — a
fonts.css from an existing design system, @fontsource, a self-hosted <link>. It takes the
families explicitly rather than hardcoding them, so if you are migrating from a zero-argument
loadFonts() that knew its own families, the adapter is one line:
// the shape a hardcoded loader had, kept as a call site
export const loadFonts = () => baseLoadFonts(brandFontFaces(brand));brandFontFaces(brand) is that list, deduplicated by family — the same list
installBrandFonts warms.
Embedding a webm
Drop a .webm, .mp4, .mov or an image into media/ and run the footage stage:
npx narro-video footage ./my-video # --fps 12 --max-seconds 30 --max-width 1920import { Footage } from "@getnarro/video";
<Footage clip="demo" fit="cover" loop startSeconds={2} />;The clip id is the filename without its extension. Unchanged recordings are skipped on a re-run, so fixing one sentence of narration does not re-extract a minute of video.
The recording is extracted to frames rather than handed to a <video> element. A video
element plays on wall-clock time, and the renderer drives the page by frame number across
parallel workers — a worker that never evaluated frames 0..N-1 has no playhead to seek, so what
got captured would depend on how far the clip happened to have played in that worker.
__frameReady awaits <img> elements, so a still frame is awaited and a seek would not be.
Resolving the clip to one still per frame makes an embedded recording exactly as deterministic
as everything else, and costs one <img> swap.
A webm exported with transparency (yuva420p) is detected and extracted to PNG with its alpha
intact — extracting it to JPEG would composite it onto black, and the overlay it was meant to be
would arrive as an opaque rectangle that nothing reports.
Recording the product yourself, on virtual time
npx narro-video record ./my-video --base-url http://localhost:3000 # media/*.record.json → media/*.mp4
npx narro-video record ./my-video --check # has the product changed?A screen recording is the part of a product video that goes stale first and is redone last, because it is the one input that is not text. This 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 }
]
}media/dashboard.record.json is run in the same pinned Chrome the renderer captures with, and
on virtual time: the page's clock is advanced by exactly one frame per screenshot, the
browser's frames are issued under the same control, threaded animation is off so CSS
transitions follow the same clock, and Date.now() is pinned to a fixed day. Every wait is one
check per recorded frame, from outside the page, so nothing polls the page at an instant of
its own. Measured on a page with a live clock, a click, a transition and a delayed fade: takes
of an unchanged page were byte-identical in five of six runs, and the sixth differed by a
sub-frame phase in the delayed fade that scores above 0.99 SSIM — where a changed button, a
moved panel or new copy scores far lower. That is why --check compares by SSIM rather than by
hash, and why its default threshold is 0.98. A step that has to wait for the
page waits in recorded frames — which is also what a viewer would have seen. The result is
media/dashboard.mp4, which the footage stage extracts like any other recording, and a sidecar
beside it with three things a hand-made recording cannot carry:
- Marks. Every step's id is a moment in the clip, in seconds.
useClip("dashboard").marks.clickis the second the button was pressed, which is what a<Callout>needs to close onto it on that frame.urlis navigated before the first step and markedstart. - 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. - The cursor, drawn rather than baked. The runner records where the pointer went;
<Footage cursor />draws it in container units, crisp in every format and re-themable, with a press on each click. A baked cursor is 32px in a 1920 recording and gone in a vertical crop.
--check re-records every clip into a temporary file and runs narro-video diff against the
stored one. The product's UI changed exactly where the SSIM fell, and the worst frames say
where — content staleness for the input that could not have it before. recordClip({ keepFrames })
keeps a take's raw PNGs, for looking at one frame by frame. It belongs in CI beside
the product's own tests; when it fails, record --clip NAME re-records and, with the render
cache, only the scenes that draw the clip re-render.
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 a recording that navigates
twice is captured but marked not frame-exact — split it, or use the app's own client-side
routing, which never reloads the document. And a page virtual time cannot drive at all — one
that never settles, one that refuses to load without a real clock — sets
"deterministic": false and is captured in real time. Either way the manifest says which.
Authentication is a Playwright storageState file named in the spec and kept out of the
repository.
Animated GIFs
A .gif in media/ is a first-class clip. Every frame is kept, at its own delay read out
of the file, rather than resampled to the extraction rate:
<Footage clip="reaction" loop />That matters because a GIF's timing is per frame and the good ones use it — a reaction GIF is
usually 40ms a frame with one long hold on the beat, and a constant-rate resample deletes the
hold, which is the joke. clipFrameAt walks clip.delays when the manifest carries them, wraps
at the total when loop is set, and holds the last frame when it is not. Decoding happens at
build time, in ffmpeg, so a GIF is exactly as deterministic as everything else in the frame.
A single-frame .gif is still a still.
GIF export
npx narro-video render ./my-video --format landscape --gif --gif-max-mb 5Two passes, because GIF is 256 colours per frame: palettegen reads the whole clip and picks
the colours it uses, and paletteuse maps against them. Left to itself ffmpeg uses a generic
table and every gradient bands into visible steps.
Three choices do the rest, and two of them are about size:
- Ordered (bayer) dithering, not error diffusion. Floyd-Steinberg looks marginally better on one still and is a disaster for GIF: its diffused error pattern differs in every frame, so almost every pixel changes between frames and the inter-frame compression GIF depends on has nothing left to elide. A fixed threshold matrix dithers a static region identically frame after frame. On real clips this is a 2-4× size difference.
diff_mode=rectangle, so each frame stores only the bounding box of what changed.stats_mode=diff, which weights the palette toward the pixels that move — a static background can afford to be approximated and a moving subject cannot.
--gif-max-mb N is the flag worth using. Every platform that accepts a GIF has a limit, none of
them tell you before the upload, and "how many colours is 5MB" is not answerable by inspection.
The encode is retried down a ladder — frame rate first, then palette, then resolution, in the
order they are least missed — and the first result that fits is kept. If none fits, the cheapest
attempt is kept and you are told, rather than left to find out at upload time.
Rates are snapped to ones GIF can express: delays are stored in hundredths of a second, so a
30fps GIF plays at 33.3fps and a ten-second clip finishes a second early. gifsicle is used for
a further optimisation pass when it is on PATH, and skipped without complaint when it is not.
writeGif from @getnarro/video/renderer is the same thing as a function, against any mp4.
Transitions
Every cut is a cut unless you say otherwise, and a transition here spends a budget rather than taking time. Remotion's equivalent overlaps two scenes and shortens the composition, which cannot happen when the timeline was derived from measured narration: moving a cut earlier moves every cue after it out from under its audio.
So the overlap is drawn over the outgoing scene's trailing silence — the hold its last cue
already declares — and the incoming scene is premounted rather than started early. No cue moves,
the composition stays exactly as long, and the SRT still matches.
<TransitionSeries>
<TransitionSeries.Scene id="title"><Title /></TransitionSeries.Scene>
<TransitionSeries.Transition durationInFrames={12} presentation={fade()} />
<TransitionSeries.Scene id="features"><Features /></TransitionSeries.Scene>
</TransitionSeries>Ask for more frames than the scene has silence and it fails with both numbers and tells you which to change — a transition that ran long would cut away mid-word.
fade, slide, push, wipe, iris, none. All percentages and transforms, never pixels,
so one presentation reads the same in every format. none earns its place by premounting: a
clean cut instead of one frame of unstyled text. <TransitionSeries.Overlay> draws a flash or
a light leak across a cut and costs no silence at all, because it overlaps nothing.
Captions that page
<Captions cues={timeline.cues} mode="word" maxChars={28} />cue is the whole sentence — legible in landscape, a wall of text on a phone. page shows a
few words at a time, turning over mid-sentence. word is a page with the word being spoken
marked, which is the vertical-video convention.
Word windows come from the timeline, which always has them: measured when a recording carries
an alignment, and estimated otherwise from word length plus the time punctuation buys. A comma
is a beat the voice honours, so a purely length-proportional split drifts late through a
sentence and lands the last word early. toSrt(cues, fps, { granularity: "page" }) writes the
sidecar to match what the video shows.
Audio-reactive visuals
const bands = useAudioBands();The one Remotion feature this renderer cannot copy: its useAudioData reads a waveform at
runtime, and frame N here is captured by a worker that never played frames 0..N-1, so there is
no playhead to read a level from.
Doing it at build time is better anyway. The audio stage already holds every WAV, so it
analyses each cue into log-spaced frequency bands — one row per video frame — and writes them
into the manifest. A level then costs nothing at render time, is identical in every worker, and
cannot desync from the audio because it is that audio. Pass fps to synthesizeScript to
turn it on.
Drawing
| | |
| --- | --- |
| evolvePath, getLength, getPointAtLength, getTangentAtLength | A line that draws itself, a dot travelling a route, an arrow that points along its own path |
| makeRect, makeCircle, makePie, makeStar, makePolygon, makeArrow, … | Shapes as path strings, so every path utility above applies to them |
| noise2D, noise3D, fractalNoise2D | Seeded, coordinate-addressable organic motion |
| <Trail>, <CameraTrail> | Motion blur, as ghosts at fractional frames behind the live one |
| random, randomRange, randomInt, shuffle | Scatter and jitter that survive the determinism sweep |
| interpolateColors, bezier, steps | A colour ramp with alpha premultiplied, and the CSS curves in the frame domain |
| <Loop>, <Freeze> | A repeating animation, and an honest held frame |
Elliptical arcs (A) are rejected by the path parser, and every shape here is emitted as cubic
curves for that reason: measuring an arc means converting it, and a path that silently
approximated its arcs would put anything travelling it in the wrong place with nothing to say
why.
Layers
<Fill style={{ justifyContent: "center", padding: "6cqmin" }}>
<h1>...</h1>
</Fill>position: absolute; inset: 0; display: flex; flex-direction: column, which every scene writes
and gets subtly wrong once — inset: 0 without position: absolute does nothing, and
width: 100%; height: 100% is not the same thing inside a padded parent. Scenes usually stack
several: a ground, the content, a vignette.
<Fill> is not <Stage>. <Stage> is the composition root and establishes the container query
context (container-type: size) every cqmin in the tree resolves against; there is exactly
one of it. This is a layer inside that, carrying geometry and nothing else.
Text that fits
One composition serves 1920x1080, 1080x1920 and 1080x1080, so a headline sized to fill the landscape frame runs off the side of the vertical one. The source is identical in both, so no rule in the determinism sweep can see it.
<FitText text={headline} max={9} min={4} /><FitText> binary searches the largest size that fits its box, against a live measurement, and
holds the frame while it measures — the renderer must not screenshot the unfitted size, which is
what it would otherwise capture on the frame the text appears. holdFrame() is that mechanism
and is exported for anything else that has to settle before capture.
For anything that is not "fit one string into one box", measure directly:
import { fitText, measureText } from "@getnarro/video";
// Fit the longest word to 88% of the frame and the whole line to 167%, take the smaller — so a
// long headline wraps rather than shrinking to nothing, and no single word ever overflows.
const word = fitText({ text: longest, withinWidth: width * 0.88, fontFamily, fontWeight: 700 });
const line = fitText({ text: headline, withinWidth: width * 1.67, fontFamily, fontWeight: 700 });
const fontSize = Math.min(word.fontSize, line.fontSize);measureText returns { width, height } in pixels and caches by everything that changes the
answer; fitText measures once at 100px and scales, because width is linear in font size.
Both take validateFontIsLoaded, which measures the string a second time against the fallback
and throws if nothing changed — the "an unwarmed weight is captured unstyled in exactly one
frame" defect, in a form a component can act on. <FitText validateFont> runs the same check
against the family the element actually resolved to.
npx narro-video check ./my-videolays the composition out at every declared format and reports text that leaves the frame or is genuinely clipped. Exits non-zero, so it belongs in CI.
Two rules keep it usable, and both are about not reporting things that are drawn on purpose:
- Oversized type in an
overflow: visiblebox is not a finding — that is how most scenes here draw. A clip counts only when something is actually cutting the excess off, which includes<Stage>when the content runs off the side of the video and does not when it stays inside it. - Off the canvas at one sampled frame is not a finding. A scene's opening frame has every entrance at its start and its closing frame has every exit at its end, so an element sliding in from the right is off the canvas there by design. To be reported it has to be off the canvas at every sampled frame of the scene in which it appeared at all — an element that never arrives, rather than one on its way in.
npx narro-video check "apps/*"A glob instead of a directory sweeps a whole workspace: every project it matches is checked,
every project is visited even after one fails, and the exit code is the whole run's. render
takes a glob too. Nothing else does — a glob on still or cover is rejected rather than
resolved into a directory literally named apps/*.
The director's pass
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
npx narro-video review ./my-video --rules frozen-hold,pacecheck asks one question — does any text leave the frame? This asks the rest of what a
director asks of a cut, against the same headless Chrome, at a sample of frames per format:
| Rule | What it reports |
| --- | --- |
| frozen-hold | A scene that finishes its visual story and holds one identical frame for most of its length, and the cues narrated over the still |
| dead-air | A hold longer than a beat after a scene's last cue, or a cue-less scene long enough to notice |
| pace | A sentence spoken faster than a caption is read, or too long for one caption card |
| caption-legibility | Caption colours under the AA contrast ratio on any of the brand's grounds |
| safe-zone | Text under the chrome Reels, Shorts, TikTok and a feed post draw over vertical and portrait frames — and captions placed inside it |
| late-arrival | Words that arrive well after their scene's narration began |
| empty-poster | A poster frame with nothing legible on it |
Every finding names a sentence or a scene, the numbers it decided on, and the file that
decides it — because every frame here 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.
Nothing is re-cut.
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 unchanged: render, review, apply,
re-render, with a model in the loop and nobody watching the video by hand. The thresholds live
in one table, REVIEW_THRESHOLDS, and the rules are pure functions over the timeline, the
brand and the sampled frames — reviewFindings from @getnarro/video runs them on data you
supply.
Two disciplines keep it usable, borrowed from check: a layout finding has to hold at every
sampled frame the element was on screen, so an entrance passing through a safe zone is not
reported; and a frame's hash is rounded to the half pixel, so a spring settling in its last
thousandth of a pixel is a still, not motion.
More than a handful of videos
A repository with fourteen videos has a question the library could not answer: what have we
got? Every command took one <dir>, and a composition had nowhere to put a title — so the
answer lived in a hand-written table that restated, per video, a runtime that was correct until
the next re-render.
meta is where that belongs. Every field is optional, none of it reaches a frame:
export const composition = defineComposition({
id: "tab-15",
meta: {
title: "AI Became Tab 15",
status: "published", // draft | review | ready | published
tags: ["main"], // free-form grouping — a series, a campaign, a product area
order: 2, // where it sits within its group
owners: ["[email protected]"],
source: "docs/briefs/tab-15.md",
},
// …
});Deliberately not series and episode. That is one taxonomy among many, and a marketing team
groups by campaign where a docs team groups by product area — so grouping is a tag, ordering is
a number, and the library takes no position on what a group means.
$ npx narro-video list "apps/*"
ID TITLE STATUS RUNTIME FORMATS TAGS
agent-fails When the Agent Fails published 2:30 landscape,vertical main
tab-15 AI Became Tab 15 published 2:20 landscape,vertical main
birthday-party The Birthday Party draft 2:56 landscape,vertical eli5
3 projects · 1 draft · 2 published--tag eli5 and --status draft filter; --json writes the same rows as a document, for a
generated README block, a dashboard, or a CI check. Progress goes to stderr, so the JSON on
stdout is the whole of stdout.
The runtime is measured, not remembered. Each project's audio stage runs (cached, so it is a no-op when nothing was re-narrated), each is built, and the length is read off the timeline the page computed — the same timeline the renderer captures. That is the half of a catalog a consumer cannot generate for itself without a second copy of the timeline logic, which is the copy that goes wrong.
A project the sweep cannot read is reported as a row of its own and the command exits non-zero: a catalog with a video quietly missing from it is the drift this exists to remove.
While it renders
demo: 600 frames @ 30fps (1920x1080)
chrome 141.0.7390.65 (/usr/bin/google-chrome)
capturing 9 pieces across 2 workers
capture ███████████░░░░░░ 65% · 390/600 frames · 0:24 elapsed · ~0:13 leftThe status line goes to stderr, so narro-video render . > render.log keeps a log of what
was written and nothing else. On a terminal it is one line, redrawn in place, with a clock that
keeps moving through the encode and the mux — stages that report no frames, and so get no
percentage rather than a made-up one. Anywhere else — CI, a pipe, a file — it is a whole line
every ten percent, because a log full of carriage returns is a log nobody can read. --no-progress
turns it off.
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 itThe audio stage has always been content-addressed: rewrite one sentence and one cue is re-synthesised. The capture stage now is too. 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 — and a piece whose key was rendered before is concatenated from the cache instead of captured again. Rewrite one sentence and the render re-captures that sentence's scene, and its neighbours if a transition draws one over the other. Nothing else.
That is only possible because every frame here is a pure function of the frame number, which
the determinism sweep enforces. A renderer that allows a wall-clock read or a <video> element
cannot prove a piece is reusable, and does not try.
Two things decide how fine-grained the reuse is:
narroVideo()invite.config.ts. The plugin records what each module of the bundle 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 the plugin 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 the cache matches a scene to. A change inside that file re-renders that scene; a change to a helper both scenes import, tobrand.tsor to the composition root re-renders both. A scene without a file of its own is keyed on every visual module, which still excludesscript.ts.
A scene that reads useCompositionFrame() is keyed on the whole timeline rather than its own
slice, because its pixels can depend on where in the video it sits — the plugin marks the module
and the renderer widens the key. The cache lives in .cache/render/, beside the TTS cache, and
is gitignored with it; --cache-dir moves it, for a CI job that keeps one across runs.
narro-video diff is the acceptance test the cache sits under: a key carries every input the
package knows about, and a browser bump changes it, but a render before a publish is worth
comparing against the last master once.
Output formats
npx narro-video render ./my-video --codec prores --transparent
npx narro-video render ./my-video --frames 120-260 --scale 0.5 # iterate on one scene
npx narro-video still ./my-video --frame 120 # a thumbnail, in a second--format landscape|square|portrait|vertical. portrait is 4:5 (1080x1350) — the tallest
frame a feed post shows without cropping, where 9:16 is a Reel or a Story and belongs somewhere
else entirely.
--codec h264|h265|vp9|prores, each in a container that can hold it; --crf; --transparent
for a video that is an overlay (prores or vp9 only — nothing else has an alpha channel);
--srt cue|page. Every mp4 gets +faststart, so a browser streaming it plays the first frame
without fetching the whole file.
Stills at any size
A thumbnail is not one of the video shapes. still takes an explicit size, or the name of a
channel that already has one:
npx narro-video still ./my-video --frame 120 --cover-format youtube # 1280x720
npx narro-video still ./my-video --frame 120 --width 1080 --height 1080--format still chooses the layout the page is drawn at; the size is what it is captured at.
Width and height go together — a still with one of them would have to guess the other from an
aspect ratio that is no longer the format's — and a sized still is named after its size rather
than its format, so two thumbnails of the same format do not overwrite each other.
This is a still of the composition, at a channel's size. narro-video cover is the other
thing: an authored document about the video, with a headline over a frame of it.
Which browser rendered this
Every render, still and check logs the browser it resolved and what --version says:
chrome: /usr/bin/chromium (Chromium 141.0.7390.54)narro-video doctor reports the same. The browser is the largest uncontrolled input to the
output — text rasterisation and filter: blur() both differ across Chromium versions, and
backgrounds that lean on 120px blurs will not match themselves across two of them — and nothing
else in a render is unpinned like this. The fonts are embedded, the footage is extracted, the
timing is measured.
npx narro-video render ./my-video --chrome-version 141fails before the first frame on an unexpected build, rather than after twenty minutes of frames
that will not match the baseline. The version is matched as a prefix, so 141 means the major.
Comparing two renders
npx narro-video diff before.mp4 after.mp4 --threshold 0.98 --contact-sheet out/worst.pngMean and worst SSIM, mean and worst PSNR, the frames that scored worst, and a contact sheet of them. Non-zero exit below the threshold, so it belongs in CI.
The mean is the least useful number in that report. A change that broke one scene has an
excellent mean and a cliff twenty frames wide, so --threshold is checked against the worst
frame and the worst frames are what gets printed. Two encodes of identical pixels are not
byte-identical, which is why this is SSIM rather than a checksum — and PSNR alongside it,
because the two disagree usefully: PSNR notices a global shift SSIM shrugs at, and SSIM notices
structural damage in one region that PSNR averages away.
Useful for a @getnarro/video upgrade, a scene refactor and a browser bump, not only for a
migration.
The poster
The .png beside each master is the frame a platform shows before playback, so it is taken
two seconds in rather than at frame 0. Frame 0 of a narration-driven composition is always
mid-entrance — scenes mount on the frame they first appear and springs have not settled — which
makes it reliably a near-empty canvas with at most one line of caption on it. Two seconds is
past every entrance in this runtime and still inside the first scene of any composition long
enough to have one; on a video shorter than that, the seek clamps to the last frame that exists.
npx narro-video render ./my-video --poster-second 4.5 # a different moment
npx narro-video render ./my-video --poster-frame 135 # or an exact frameEither way the render log names the timestamp and frame the poster came from, so a poster that looks wrong says where it was taken from rather than looking like a broken render.
A partial render is named for its range, and its audio and SRT are rebased onto its own frame 0 so it carries its own narration rather than the narration of frames it does not contain.
Delivery loudness
npx narro-video render ./my-video --loudness social # the default: -14 LUFS, -1 dBTP
npx narro-video render ./my-video --loudness broadcast # EBU R128, -23 LUFS
npx narro-video render ./my-video --loudness none # ship the mix as it isOn by default, because this is the defect the package is least able to notice on its own.
amix is told normalize=0 deliberately — normalising a mix by input count would duck the
voice every time a sound effect started — so the mix is correct relative to itself and arbitrary
in absolute terms. One video lands hot, the next lands quiet, and neither is audible as a fault
while scrubbing with a volume knob in reach. Then the platform normalises what it was given: a
hot mix is turned down and loses its headroom, a quiet one is left to play quieter than
everything around it in the feed.
Two passes, not one. Single-pass loudnorm is a dynamic processor — it cannot know the whole
file's loudness while streaming it, so it rides the gain, and riding the gain across narration
flattens the dynamics the voice was recorded with. Measuring first buys one constant offset for
the whole mix, which changes the level and nothing else.
The mix is measured after the ducking, the sfx and the trim, because that is what gets delivered. The run says what it found:
mix measured -27.6 LUFS, 13.6 dB up to -14 LUFS (true peak ceiling -1 dBTP)A composition with no audible signal in it is skipped rather than failed. -1 dBTP rather than
0 is not conservatism: inter-sample peaks clip a lossy encode that measured clean, and
everything here is delivered as AAC or Opus.
Emitting more than a video
A composition can declare artifacts as a function of its timeline — chapters, a transcript, a manifest for a player — written from the one place that has the timeline instead of by a second script re-deriving them from the finished video afterwards and drifting.
export const composition = defineComposition({
artifacts: (timeline) => [
{ filename: "chapters.vtt", content: toVtt(timeline.scenes) },
],
});The studio
npx narro-video studio ./my-videoThree columns — what the video is made of, what it looks like, and what it says — over a timeline that carries the same windows.
| | | | --- | --- | | Scenes | Every section with its own length, its share of the running time, its cue count and its transition budget. Scene lengths are derived from measured narration, which makes them the thing most likely to surprise the person who wrote them: a scene that ran three sentences long is invisible in the source and obvious here. | | Preview | Letterboxed to the real output, with the burned-in captions drawn over it and the line being spoken underneath. | | Script | Every cue as a row, the active one marked, and the word being spoken marked inside it. Click anything to seek there. | | Captions | The cards the video will actually burn in — not the script, which is what is said rather than what is shown. Switch between whole-cue, page and word, and see the longest card's character count before a phone does. | | Scene | What the current scene is made of, including the frames of silence a transition may spend. | | Notes | Feedback on any of the above, kept across refreshes and exported as one Markdown briefing. |
The layout is Descript's rather than Premiere's, because this framework's model is Descript's:
the narration is the timeline, so the script belongs beside the picture as a first-class
surface rather than buried in a properties panel. The mapping is exact, not approximate — the
cue windows are what buildTimeline derived the video from, and the word windows come from the
manifest — so clicking a word seeks to the frame it starts on.
Dark by default, because a preview should be the brightest thing on screen. ? lists the
shortcuts.
Feedback, and getting it to something that can act on it
Every row in the studio takes a note — a scene, a cue, a caption card — and n notes whatever
is on screen. That last one opens on the narrowest thing at the playhead and lets you widen:
word “measured,” · cue how · scene how · this frame · brand · whole videowhich is the one thing the studio cannot infer. A note opened from a row seeks to what it is about first, so the preview shows it and the note records the frame it means rather than wherever the playhead was resting.
Notes are held in localStorage, keyed by composition, written on every change. A refresh does
not lose them, and neither does closing the tab — beforeunload is not dependable enough to
hold the only copy of something a person typed. Where storage is unavailable (private mode, a
blocked third-party frame, a full quota) it falls back to memory rather than failing: the
feature degrades, the studio does not.
Export produces a briefing, not a list of opinions. The reader it is written for is a model that will be asked to make the changes, so every note carries what it is about, the ids that identify it, the exact text as it stands today, where in the video it happens, and the file that decides it:
## 1. change — cue how
- **Cue id**: `how`
- **Current text**: "The narration is measured, and every duration follows from it."
- **In scene**: `how`
- **Runs**: frames 120–251 · 0:04.00–0:08.12
- **Seen at**: 0:04.00 (frame 120) in the vertical format
- **Edit in**: `script.ts`
> This sentence is doing two jobs. Split it into two cues.The document opens by saying that durations are derived, so a model reading it changes the words rather than hand-tuning a frame number — the one instruction that keeps the framework's guarantee intact. It closes with the same notes as JSON, because prose is for the reasoning and structure is for the edits.
Exporting keeps the notes and stamps them, so a later export can tell what is new. Nothing is deleted until you say so — Delete all removes the stored data itself rather than writing an empty file over it, and individual notes have their own ×.
Stale notes are announced, not left to be discovered. Each note records what its target said when it was written, so the studio can tell you when that has stopped being true:
| | |
| --- | --- |
| changed | the words it was written about have been rewritten since |
| gone | the cue or scene it pointed at is no longer in the composition |
| old | untouched for three weeks |
A note reading "this sentence is doing two jobs" is about a specific sentence. Once someone rewrites that sentence the note is either already done or no longer true, and nothing about the note itself says which — so acting on it changes something that was already changed. A banner offers to review or delete them in one go, and the export marks each one ⚠ Check first and quotes the text it was written against.
A note pinned to a frame goes stale the moment any duration moves, because frame 165 means a different moment after a re-record. That falls out of the same mechanism: what a frame note snapshots is the composition's length.
The chrome is cascivo, and it is optional
The studio is built on cascivo, declared as an optional peer dependency. Rendering a video
needs none of it: only @getnarro/video/studio imports it, npx narro-video new adds it to the
generated project's dev dependencies, and a consumer who only ever renders installs nothing
extra.
Two boundaries hold that in place, both enforced by a source sweep:
- The player never imports it. A page embedding a
<Player>should not download a design system to show a video, and cascivo is optional — a consumer who never opens the studio does not have it installed, so a player that imported it would fail to resolve at their build. - The render never loads its CSS. The scaffold imports the studio dynamically, in dev only. cascivo ships global CSS, and a build that pulled it in would apply it to the captured page too — the video would be rendered under styles that exist for the chrome around it.
The shared transport and timeline strip sit in between: they read cascivo's design tokens
through var(--cascivo-…, fallback) without importing anything, so they re-theme inside the
studio and keep working standalone inside a player.
Retention, mapped onto sentences
npx narro-video retention ./my-video --data youtube-analytics.csvEvery 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. This package did — so it can:
## Where the audience falls off
- **0:14** cue `problem` — 38.4% of its audience: "Most tools make you edit the timeline by hand."
## The 3 sentences costing the most
### 1. cue `problem` — lost 38.4%
- **Says**: "Most tools make you edit the timeline by hand."
- **In scene**: `why`
- **On screen**: 0:14.00–0:19.20 (frames 420–575)
- **Audience**: 81.0% entering, 49.9% leaving
- **Rewrite in**: `script.ts` → cues[] → id `problem`The export is read as-is: a CSV with a seconds column, YouTube's own "Video position (%)" export, or JSON. Percentages and fractions are both understood, and the position column is resolved against the composition's real duration rather than assumed.
Two numbers are reported per sentence, and the ranking uses the second one. Absolute loss is the share of the whole starting audience; relative loss is the share of the people who heard this sentence. A sentence late in the video looks harmless by absolute numbers simply because few people are left to lose. Cliffs — moments losing far faster than this video's typical sentence — are listed separately, because "which sentence is weakest" and "where did something go wrong" are different questions. That comparison is against the median rate rather than the mean: one severe cliff drags a mean up past its own threshold, so a video with exactly one thing wrong with it would report nothing wrong at all.
The document ends with the same numbers as JSON, and it opens by telling its reader to rewrite the sentences, not the timing — scene lengths are the sum of their cues' measured narration, so a shorter sentence produces a shorter scene automatically and every frame number in the report moves with it. There is nothing to re-cut. That is the whole reason this is worth handing to a model: the instruction it can act on is a rewrite, and a rewrite is the only edit this framework needs.
The same curve is written to .assets/retention.json, so the studio picks it up:
- the curve is drawn over the timeline strip, against the scenes and the waveform,
- each cliff is banded on the strip and marked on its row,
- every sentence in the script carries what it cost (
−38%), - and the inspector header says how many people reached the end.
Which puts the number on the sentence it indicts, next to the button that files a note about it.
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 translated video costs what the original did. Everywhere else a dub breaks the edit: the German runs 30% longer, and someone opens a timeline and re-cuts it by hand. Here durations are measured, so the timeline is rebuilt from the new audio — every scene grows to fit its own narration, every caption re-pages, the SRT sidecar regenerates, and nothing is re-cut. That is the single most under-priced consequence of deriving the timeline instead of authoring it.
A locale is one file, keyed by cue id:
{
"locale": "de-DE",
"voice": "df_alpha",
"cues": {
"hook": "Die meisten Videowerkzeuge zwingen dich, eine Zeitleiste zu schneiden.",
"claim": "Dieses leitet sie ab."
}
}Keyed rather than a list, because the ids are what everything else is addressed by — the audio
manifest, the timeline, the captions, a note written in the studio. A partial bundle fails the
build and names the missing cues, rather than shipping one sentence in the source language.
brief --locale writes the document a model answers with that file, and it says the one thing a
video translator is normally told the opposite of: do not match the length of the original.
Each locale synthesises into .assets/locales/<tag>/ and renders into out/<tag>/. The TTS
cache is content-addressed by text, voice and speed, so twenty languages share one cache without
colliding, and a sentence two of them happen to share is synthesised once.
npx narro-video check ./my-video --locale de-DEWorth running on every locale: a translation is the most likely thing to overflow a headline, and it is the one nobody watches before publishing.
Channel cuts
npx narro-video brief ./my-video --budget 60 --channel linkedin
npx narro-video render . --cut linkedin # out/linkedin/A cut is a list of sentence ids:
{ "id": "linkedin", "budgetSeconds": 60, "keep": ["hook", "claim", "proof", "cta"] }Choosing which sentences survive is a task a model does well. Choosing which frames survive is not — and here it never has to: the cuts, the scene lengths and the captions are all re-derived from the sentences that are left. A scene with nothing left to say disappears, and its visuals go with it.
A cut needs no audio stage. It re-uses narration that has already been measured, so it costs
a render and nothing else. brief --budget hands the model every sentence with its measured
seconds, which is the half of the decision it cannot do for itself, and fitToBudget does the
arithmetic against a ranking it returns — skipping a sentence that does not fit rather than
stopping at it, so a 60-second budget is spent on eight of the ten most important sentences
instead of the first four.
Both compose: --locale de-DE --cut linkedin renders into out/de-DE/linkedin/, from one
vite build. Locale and cut are page parameters, not build flags, so one bundle serves every
variant and the studio shows whichever the URL asks for.
The cover image
A video also has to be posted, and the still that goes out with it decides whether anyone plays it. That still is a writing problem — what does this video say, in seven words — over a picture problem, which sentence of it should be the picture. Both answers live in the script.
npx narro-video cover ./my-video --channel linkedin # → the brief a model answers
npx narro-video cover ./my-video --channel linkedin --spec covers/linkedin.json
# → out/my-video-linkedin-cover.pngThe brief carries the whole narration, in order, with the frame each sentence is on screen for and what it costs in seconds — the same material the retention report and the channel cut are built from, because it is the only artefact that knows what the video is about. It comes back as a file of words and choices:
{
"channel": "linkedin",
"headline": "The narration is the timeline.",
"subhead": "Re-record a sentence and every cut moves with it.",
"kicker": "Narro",
"backdrop": { "kind": "frame", "cue": "claim" },
"surface": "signature"
}**Nothing in
