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

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.

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

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.

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, 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, extractFootage, scaffoldVideo, listProjects and the glob helpers behind list, and the static server the CLI runs. Node only. | | @getnarro/video/tts | synthesizeScript (kokoro, content-addressed cache, hand-recorded cues), 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>, <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.tsx | What each scene draws | | 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.

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.

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.

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. Oversized type in an overflow: visible box is not reported — that is how most scenes here draw, and reporting it would make the check noise.

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/*.

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.

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.

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 it is geometry, a colour or a font size. The size comes from the channel, the palette and the type from brand.ts, and the layout from this package — so a cover written by something that has never seen the video cannot be off brand, cannot be the wrong size for where it is going, and cannot show a frame the video does not contain. What a model contributes is the two decisions it is good at.

--channel linkedin|opengraph|x|youtube|square|story. A closed set, for the reason every closed set here is one: LinkedIn's card is 1200×627 and Open Graph's is 1200×630, and nobody who typed the wrong one would find out. Each carries its own headline budget — a YouTube thumbnail is authored at 1280px and read at about 210, so it gets a third of a feed card's words at nearly twice the resolution — and the brief states it.

Four things fall out of the cover being derived rather than designed:

  • The picture is named as a sentence, not a frame. backdrop.cue resolves to the middle of that sentence — not its first frame, which is the frame its scene is still animating in. A frame number would point somewhere else the next time the narration is re-recorded; a cue id keeps pointing at the same moment. { "kind": "surface" } is the honest alternative for a video no frame of which would read behind type.
  • The frame comes from the closest master. A story cover cut from the landscape render keeps a 1080-wide sliver of a 1920-wide frame, which in a composition laid out for 16:9 is most of the scene thrown away. nearestFormat picks the declared format whose aspect is nearest, in log space, so a crop and its mirror count as the same mismatch.
  • The wash is mixed from the ground's own background, not from its scrim. A surface is a diff, so a bright ground that never restates scrim inherits the brand's — which is a contrast decision made against the brand's ground and the wrong polarity on the opposite one. That renders unreadable and exits zero, which is this framework's characteristic failure.
  • The headline is fitted, by the same binary search <FitText> uses. Not a second copy of it: fitSize is a pure function and it is serialised into the page. A headline longer than the channel carries still renders — it simply gets smaller — and you are told, in characters, because the fix is editorial.

Both halves work on a variant: --locale de-DE writes a cover from the German narration and the German render's frames, into out/de-DE/.

renderCover from @getnarro/video/renderer is the same thing as a function, and coverBrief, parseCoverSpec and coverDocument are the pieces, for a pipeline that reaches an AI its own way.

Feedback → patch

The studio exports a briefing. This reads it back:

npx narro-video apply ./my-video --feedback out/welcome-feedback.md
# → out/welcome-patch.md, the brief a model answers with rewrites.json

npx narro-video apply ./my-video --feedback out/welcome-feedback.md --rewrites rewrites.json
# → a unified diff to script.ts. --write applies it.

The loop closes because the export was designed for this: every note carries the cue id it is about, so the notes resolve to exact strings in an exact file rather than to "the second sentence". The plan is read against the composition as it is now, not as the notes remember it — a note written about a sentence that has since been rewritten is stale, and the honest outcome is to say so rather than to reapply advice about text nobody can see.

What can be applied mechanically and what cannot are kept apart on purpose:

| | | | --- | --- | | Rewrites | A note about a cue, a word or a caption card resolves to one sentence in script.ts. A model returns {"rewrites": [{"cueId": "hook", "text": "…"}]}, and that is applied as an exact string replacement — quote-escaped for the literal it lands in, so an apostrophe in a new sentence does not close the string early. | | Everything else | A note about the brand, a scene, a frame or the whole video is listed underneath with the file that decides it. Nothing is dropped, and nothing is guessed at. |

Anything a text replacement could get wrong is refused with a reason rather than attempted: a sentence that appears twice in the file, one that is computed rather than written, a cue that no longer exists. A patch that is 90% right is worse than one that says what it could not do.

The output is a diff -u you can read, pipe or reject, and --write is a separate flag — the file being changed is one a person owns. After it lands, npm run audio re-measures only the sentences whose words changed (the cache is keyed by the text), and every duration downstream follows from what it measures. Nobody re-cuts anything.

Embedding a composition

import { Player, type PlayerRef } from "@getnarro/video/player";

<Player
  composition={composition}
  timeline={timeline}
  audio={{ mode: "track", src: "/out/launch-landscape.m4a" }}
  loop
  ref={ref}
/>;

The same tree the renderer captures, driven by a clock instead of a screenshot loop — so a landing page can show the composition without an mp4, and an editor can scrub one before any render exists. Everything lays out in container units against <Stage>, so a player 480px wide renders the same composition as one at 1920.

ref gives play / pause / toggle / seekTo / getCurrentFrame / mute / setVolume / requestFullscreen / addEventListener. play() is async because audio needs a user gesture: a player that silently did nothing is the worst failure this component can have, so the promise rejects with the reason.

Two audio modes, and the difference is worth knowing:

| | | | --- | --- | | { mode: "track", src } | One pre-muxed file — the output of a render. Exact, because it is what the render produced. Ship this on a page. | | { mode: "cues", tracks } | Each cue's own audio, scheduled live. Works before any render exists, which is what an editor needs. Music plays at a static gain rather than ducking under the voice, because the compressor that does that lives in ffmpeg. |

The clock comes from whichever of those is playing, never from counting animation frames: a counted clock loses a frame whenever the browser throttles the callback, and a background tab throttles it to once a second. A frame derived from AudioContext.currentTime cannot drift from the audio scheduled against it.

Anything holding a frame — a <FitText> mid-measurement, an unresolved asset — stops the clock and emits waiting, then resume. That is the same holdFrame registry the renderer waits on.

The determinism contract

Every visual must be a pure function of the frame number. Wall-clock code scrubs fine in the studio and flickers in the render — a failure visible only in finished video, after the expensive part. sceneViolations enforces that as a source sweep:

import { sceneViolations } from "@getnarro/video/testing";

it.each(sceneFiles)("%s", (file) => {
  expect(sceneViolations(readFileSync(file, "utf8"))).toEqual([]);
});

| Rule | Why | | --- | --- | | No Date.now, Math.random, performance.now, requestAnimationFrame, new Date(, setTimeout, setInterval | Frame N is captured by a worker that never evaluated frames 0..N-1. Anything else is a flicker. For scatter, jitter or a shuffled order, seed it: random(seed). | | No CSS transition: or animation: | Same reason. Animate with interpolate, interpolateColors, spring and stagger; repeat with <Loop>. | | No px; every fontSize ends in cqmin, cqh or cqw | One composition serves 1920×1080, 1080×1920 and 1080×1080. A px size is legible in exactly one of them. | | No className, no stylesheet imports | Styling must be inline to stay scannable by the rules above. A font stylesheet is the documented exception — see below. |

Letting a font stylesheet through

@font-face is the one rule that cannot be expressed inline, so a project loading fonts through @fontsource or its own design system's fonts.css has no way to satisfy the sweep at all. Name the sheet instead of switching the rule off:

sceneViolations(source, { allowedStylesheets: ["@acme/brand/fonts.css"] });

Matched exactly, one specifier at a time. Allowing the font sheet does not allow a layout sheet beside it, and no allowlist ever permits className — the rule still protects everything else it protected before. Allow only sheets you have read and know to hold nothing but @font-face rules; the other rules will not read them for you.

installBrandFonts needs none of this. It injects the brand's @font-face rules as a <style> element built from data URIs, so a composition using it imports no stylesheet and the strict sweep passes unchanged. The allowlist is for projects with a font pipeline they are keeping.

explainViolations(source, options) returns each broken rule with the reason it is a rule, for a failure message that explains itself.

Type uses cqmin. Vertical extents use cqh. Horizontal extents use cqw. cqmin is a percentage of the canvas's smaller dimension, so type renders at the same size in every format. cqh inverts in a portrait frame — it is taller, so cqh-derived type grows while the width available to hold it shrinks.

The scene root must set container-type: size for any of these units to resolve.

Scheduling against measured scenes

Scene lengths are derived, so a hand-picked from={110} lands early in the long scenes: the scene plays its whole visual story in its first few seconds and then holds a bit-for-bit identical frame until the cut. useBeats expresses a beat as a fraction of the enclosing scene instead, so re-recording the narration keeps every beat in proportion.

const at = useBeats();
const enter = spring({ frame: frame - at(0.12), fps });
const each = at.stagger(items.length, 0.12, 0.72);

spring({ durationInFrames }) fits a spring to a beat, keeping its shape: time is scaled rather than the config, so the same call fitted to a 12-frame beat and a 40-frame one reads as the same motion. measureSpring is how long one takes to settle on its own.

Sound can be scheduled the same way. sfx on the composition places a one-shot at a fraction of a scene, so a whoosh on an entrance stays on that entrance after the narration is re-recorded:

sfx: [{ src: "whoosh.wav", scene: "features", at: 0.05 }],

A composition is four files

script.ts — the voiceover, as cues. The source of truth for timing.

brand.ts — the look. Colours, type, fonts, and the two lengths every scene lays out against.

src/composition.tsx — the visuals, one <Sequence> per scene, positioned from the derived timeline rather than hand-counted frames.

src/main.tsx — reads /voiceover.json, builds the timeline, and mounts <Studio> in dev or <RenderRoot> in a build.

The project supplies two npm scripts the CLI runs: audio (synthesise narration into .assets/, and extract media/ footage) and build (bundle into dist/). Everything else is the renderer's job.

narro-video new writes all of it. See apps/video-example for a worked one.

Recording it yourself

Drop recordings/<cue id>.wav in and that cue is never synthesised. It is measured, and the timeline rebuilds around its real length — so a line you read at your own pace reflows the video the same way editing the sentence would.

recordings/
  hook.wav              # used verbatim, measured, timeline rebuilt around it
  hook.words.json       # optional: real word timings, from any whisper

The sidecar is read in both shapes a forced alignment actually arrives in — whisper.cpp's millisecond offsets and OpenAI's verbose words — because those are what the two commands anyone runs produce. No aligner is bundled: a local whisper build is several hundred megabytes for something most videos do not use, and every transcription tool already writes one of these.

Where an alignment exists it beats the estimate everywhere captions are drawn. A malformed one is an error rather than a silent fall back, which would look like the alignment did not help.

The older path still works — WAVs dropped straight into .assets/ under the manifest's names — but it requires each file to fit the window kokoro measured, which in practice means reading to a stopwatch. recordings/ exists because that is the wrong way round.

One video per row

Narro's narration is text, and its timing is measured from that text — so a video whose script differs per row needs no per-row timing work at all. That makes personalised video a smaller step here than in a framework where duration is a number someone picks.

export const template = defineTemplate({
  id: "welcome",
  fps: 30,
  formats: ["vertical"],
  props: z.object({ name: z.string(), plan: z.enum(["free", "pro"]) }),
  resolve: ({ name, plan }) => ({
    script: defineScript({ voice: "af_heart", cues: [
      { id: "hook", text: `Welcome, ${name}.`, scene: "title" },
      { id: "plan", text: `You are on the ${plan} plan.`, scene: "detail" },
    ]}),
    scenes: [{ id: "title" }, { id: "detail" }],
  }),
  Component: Welcome,
});
npx narro-video dataset ./welcome --rows rows.jsonl --out-dir out/batch
{"id": "ada",   "name": "Ada",   "plan": "pro"}
{"id": "grace", "name": "Grace", "plan": "free"}

The cache is the feature. Synthesis is content-addressed by cue text, so a hundred rows sharing every sentence but a name synthesise the shared sentences once across the whole dataset. A hundred personalised videos cost a hundred renders and roughly one synthesis per distinct sentence.

Three other things the run does, in the order they matter:

  • Builds once. The bundle does not depend on props; rebuilding it per row would be the largest waste in the run.
  • Validates every row before synthesising any. A dataset run is minutes to hours, and row 87 failing after 86 renders is the worst possible moment to find out. The schema lives in the page and the rows live in the CLI, so the run asks the page — one page, for the whole dataset.
  • Resumes. A run that dies at row 60 does not redo 59 videos. The progress log is append-only, one id per line, so an interrupted write loses at most the row it was on. --force ignores it.

props accepts any Standard Schema validator — Zod, Valibot, ArkType. Adopting one would be the obvious move and the wrong one: this package depends on nothing heavy, and against the interface a consumer brings whichever validator they already use.

The audio stage is the project's own script, so props reach it the one way that survives npm run: NARRO_PROPS in the environment, read with propsFromEnv().

Rendering from Node, or in a container

import { makeCancelSignal, renderVideo } from "@getnarro/video/renderer";

const { signal, cancel } = makeCancelSignal();
await renderVideo({
  projectDir,
  serveUrl,
  format: "landscape",
  cancelSignal: signal,
  onProgress: ({ stage, renderedFrames, totalFrames }) => report(stage, renderedFrames / totalFrames),
});

Cancellation is cooperative — checked between frames and between stages — because a render is a browser, several ffmpeg processes and a half-written file, and stopping at an arbitrary instant leaves a truncated mp4 that looks exactly like a finished one. The partial output is deleted on the way out.

docker/ has an image that can render, and the three things about running this in a container that are not obvious: fonts, /dev/shm, and the fact that Node reports the host's CPU count rather than the container's quota.

Licence

MIT. Nothing here depends on a commercially-licensed video framework, and ffmpeg is invoked as a subprocess — never linked.