npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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

npm version npm downloads license CI

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 masters

render 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 here

Coming 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_PATH if it lives somewhere unusual.
npx narro-video doctor    # says whether all of that is where a render needs it

What 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.ts

The 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,vertical

It 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.captions is a contrast decision made against the brand's ground, so off base the surface's own foreground and scrim win. 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 own logo.
  • A typo throws. resolveSurface refuses an unknown name rather than falling back to base. 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 yourself

installBrandFonts(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 1920
import { 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.click is the second the button was pressed, which is what a <Callout> needs to close onto it on that frame. url is navigated before the first step and marked 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.
  • 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 5

Two 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-video

lays 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: visible box 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,pace

check 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 left

The 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 it

The 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() in vite.config.ts. The plugin records what each module of the bundle 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 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>.tsx is what the cache matches a scene to. A change inside that file re-renders that scene; a change to a helper both scenes import, to brand.ts or to the composition root re-renders both. A scene without a file of its own is keyed on every visual module, which still excludes script.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 141

fails 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.png

Mean 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 frame

Either 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 is

On 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-video

Three 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 video

which 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.csv

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

Worth 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.png

The 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