kaviri.dev

kaviri

kaviri records a web app from a script and renders a finished MP4: a headless browser driven over CDP by newline-delimited JSON ops, with the camera zooming and panning onto each interaction the way Screen Studio does for a human. It is built for agents rather than people, so the demo video becomes a build artifact: it is re-recorded in CI, and the build fails when the take filmed nothing.

Apache 2.0, one binary, no account, nothing phones home.

kaviri is ChiShona for "twice, a second time" (ka-VEE-ree).

kaviri record --script demos/checkout.jsonl --out checkout.mp4

The demo video

There is no video embedded in this file yet, and the honest reason is that the one worth showing is the one CI makes. .github/workflows/demo.yml records examples/demo.jsonl on every push that touches src/, in two shapes (a wide README take and a 9:16 vertical one), and uploads each MP4 as a workflow artifact. The newest pair is on the Actions tab of this repo.

Making your own takes about thirty seconds, below.

Install

Build it from source:

git clone https://github.com/thisisisheanesu/kaviri
cd kaviri
cargo build --release

What it needs, all in user space, no sudo:

Both binaries are checked before the browser launches, so a missing encoder costs you a second rather than a whole take. kaviri doctor prints what it found and where.

Thirty seconds

From the repo root, with nothing running and no network:

./target/release/kaviri record --script examples/form.jsonl --out demo.mp4

That script opens the local page in examples/demo.html, types a name and an email into it, clicks the button and waits for the confirmation. You get demo.mp4: a zoom into each field as it fills, a pan that follows the text across, and a return to the wide shot in between.

Run it from the repo root. examples/form.jsonl navigates to a relative path, and a bare path is resolved against the working directory before it becomes a file:// URL.

The four subcommands:

kaviri record --script demo.jsonl --out demo.mp4   # a scripted take, end to end
kaviri serve                                        # NDJSON ops on stdin, results on stdout
kaviri doctor                                       # what chromium and ffmpeg resolve to
kaviri presets | kaviri backgrounds                 # the named shapes and backdrops

serve is the mode an agent holds open: it drives one browser across many ops, so a long session is one take rather than one video per step.

The op protocol

One JSON object per line, in and out.

{"op":"start_recording","path":"out.mp4"}   // path optional; --out wins in record mode
{"op":"navigate","url":"https://example.com"}  // bare paths become file://
{"op":"navigate","url":"https://slow.example","timeout_ms":60000}  // load budget, default 25s
{"op":"click","selector":"#buy"}            // or {"op":"click","x":400,"y":300}
{"op":"type","selector":"#name","text":"Ada"}  // typewriter_ms optional, default 18
{"op":"type","text":"Ada"}                  // no selector: types into the focused element
{"op":"scroll","y":600,"smooth":true}       // y is an absolute document offset
{"op":"wait","ms":800}                      // a fixed pause
{"op":"wait","selector":".loaded"}          // or wait for an element, default budget 20s
{"op":"wait","selector":".done","timeout_ms":180000}  // raise it for slow work
{"op":"wait","selector":".done","visible":false}      // presence only, skip the visibility check
{"op":"mark","label":"checkout"}
{"op":"stop_recording"}                     // renders the MP4

Every executed op answers with one line on stdout, in record and serve alike:

{"ok":true,"result":{"event":"mark","kind":"click","t":3.14,"box":[40,120,180,44]}}
{"ok":false,"error":"selector matched a non-visible element: #done","op":"click","index":7}

Each op also emits a timestamped mark carrying the target's bounding box. That telemetry is the entire input to the camera.

A failing op is reported in band and, in record mode, ends the script. kaviri still stops the recording, renders what it captured and exits non-zero, so you get a partial video and a clear error rather than nothing.

Things worth knowing before you write a script

Presets

A take has three sizes, and they are not one number: the viewport the page lays out in, the capture resolution (viewport times scale), and the video it renders to. A vertical take wants a phone-width viewport so the site lays out like a phone, a high capture so zooms crop into real pixels, and a 1080x1920 file. --preset sets all three.

preset viewport video for
desktop 1470x830 1470x830 the default: a laptop window
tiktok / reels / shorts 432x768 1080x1920 9:16 vertical, phone layout
square 540x540 1080x1080 1:1 feed posts
landscape 960x540 1920x1080 16:9 1080p, large type for a projector
readme 1100x620 1100x620 sits in a README without scaling
phone 390x844 1170x2532 a real phone's viewport and pixels

A preset is a starting point: --width, --height, --scale, --out-width and --out-height after it still win. A vertical frame also wants its own shot list, so scroll rather than pan and keep every beat short. examples/ngano-vertical.jsonl is one written that way.

Every take is composited onto a backdrop, the way Screen Studio and Cleanshot frame a recording: the content inset with rounded corners and a soft drop shadow. kaviri backgrounds lists the nine built-in plates. The default is auto, which probes the rendered take at two frames a second, folds it into a chroma-weighted mean hue and a mean lightness, and picks the backdrop nearest a split-complementary target while penalising one that sits within 0.35 of the content's own lightness. Nothing is random: the same recording always picks the same backdrop. --background none renders full frame.

Headless capture has no OS cursor, so kaviri draws one into the page as vector SVG, with a click ripple. It is part of the page, so the zoom transform carries it along and it cannot drift away from the click it belongs to. --cursor none turns it off.

The GitHub Action

A demo video goes stale the moment the UI moves, and nobody re-records it, because that means blocking out an afternoon. Put the script in the repo next to the code it films and the demo changes when the product does.

- uses: actions/checkout@v4

- name: Serve the app
  run: |
    npm run build && npx serve -l 8099 dist &
    until curl -sf http://127.0.0.1:8099/ >/dev/null; do sleep 0.25; done

- uses: thisisisheanesu/kaviri@v1
  with:
    script: demos/checkout.jsonl
    out: checkout.mp4
    preset: readme          # or tiktok, square, landscape, desktop, phone

The MP4 is uploaded as a workflow artifact. Pass upload: false if you would rather push it somewhere yourself.

The action installs ffmpeg and Chromium, installs a Rust toolchain rather than assuming one, builds kaviri from its own checked-out source and runs the take. Supported runners are ubuntu-* and macos-*, GitHub-hosted or self-hosted: apt on Linux, brew on macOS, and any other RUNNER_OS exits with a message rather than guessing.

Two things it sets that a local run does not, and should not:

Pinning the action to a tag fixes kaviri's behaviour, not its output. Two runs of the same script on the same commit are not frame-identical: marks and frame times come from a wall clock and the capture cadence follows machine load, so durations, waypoint spacing and the exact pixels all move a little. What pinning buys you is that the framing rules, the zoom ladder and the backdrops stay put. If you need a byte-stable asset, render once and commit the MP4.

Failing the build when the take filmed nothing

A recorder that produces a file is not a recorder that produced a video. A take that filmed a Chrome error page, or a dev server that never came up, is a freeze frame held for thirty seconds, and a duration check passes it.

.github/workflows/demo.yml in this repo is the real thing rather than an illustration. After each take it runs two gates:

# 1. it is longer than two seconds
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 -i "$OUT")
awk -v d="$dur" 'BEGIN { exit (d > 2.0) ? 0 : 1 }'

# 2. the page actually moved: compare the first frame to the last
ffmpeg -nostdin -v error -y -ss 0.5     -i "$OUT" -frames:v 1 first.png
ffmpeg -nostdin -v error -y -sseof -0.5 -i "$OUT" -frames:v 1 last.png
ffmpeg -nostdin -v error -i first.png -i last.png -lavfi "psnr=stats_file=psnr.log" -f null -

psnr_avg comes back as the literal inf for two identical frames, which is handled as its own case rather than coerced to a number, and anything above 50dB is indistinguishable by eye. The script navigates, clicks and types, so a demo whose first and last frames are the same picture did not film the product. The job goes red.

That is the part that makes the video a build artifact. The zoom is what makes it watchable.

How the camera works

Frames arrive at irregular intervals and are normalised to constant-rate 30fps before any time-based arithmetic. Each interaction mark then becomes a zoom event, and the events are rendered as a generated ffmpeg zoompan expression over that intermediate.

Why it follows the caret

kaviri records agents. There is no hand on a mouse to follow, and the pointer it draws is a prop: it is parked wherever the field was clicked and stays there while a whole sentence is typed. The thing that moves, and the thing a viewer is reading, is the caret. So that is what the camera follows.

Finding it means measuring it. For contenteditable that is the selection's rectangle; for input and textarea there is no caret rectangle in the DOM, so the text up to the caret is mirrored into a hidden element with the same typography and the offset of a zero-width span at its end is read back.

That measurement costs a round trip to the page, so it happens exactly twice per typing op, once before the first character and once after the last. The pan is laid down afterwards, interpolated between the two at 0.12s spacing. Measuring between keystrokes put the round trip inside the typing rhythm: the words came out slower than the requested speed, and the camera moved in steps because the samples were as uneven as the latency.

What it costs the app you are filming

There are two capture paths, and --scale chooses.

Above 1, which is the default and every preset, kaviri runs a Page.captureScreenshot pump: a fresh viewport screenshot roughly every 25ms, one request outstanding at a time, spooled as JPEG. Each one is a full compositor pass plus a JPEG encode inside the same browser that is running the app you are filming. On a 1470x830 viewport at 2x that is roughly 15 to 25 MB/s and a busy core. The filmed app runs measurably slower than it does unrecorded: animations stutter, and a wait that is comfortable by hand can time out on a loaded machine.

At --scale 1 kaviri switches to the DevTools screencast, where the browser pushes frames as it paints them. That is far cheaper and takes kaviri almost entirely out of the app's way, but it caps frames at the CSS viewport whatever maxWidth asks for, so zooms crop into upscaled pixels. If the take is for a README at native size, or the app is timing-sensitive, or you are on a shared CI runner, that is the better trade.

Resource envelope

Plan for this before a long take, because the failure mode is a full disk.

Environment

Every one of these has a flag. The variables exist so a CI job can set them once for a whole matrix.

variable what it does
KAVIRI_CHROMIUM browser binary, same as --chromium
KAVIRI_CHROMIUM_ARGS extra Chromium flags, whitespace-separated
KAVIRI_FFMPEG ffmpeg binary, or a name to resolve on PATH
TMPDIR where the frame spool and the CFR intermediate live
KAVIRI_SPOOL_DIR the spool alone, same as --spool-dir
KAVIRI_MAX_SPOOL_BYTES the spool cap, same as --max-spool-bytes
KAVIRI_KEEP_TEMP keep the intermediates, same as --keep-temp
KAVIRI_TELEMETRY where to write the sidecar; 0 or off suppresses it
KAVIRI_TOKEN the serve --port token, instead of a generated one
KAVIRI_DEBUG log the CDP traffic on stderr

The tool was called lensa before the rename, so each variable is also read under its old LENSA_ name when the KAVIRI_ one is unset. KAVIRI_ wins if both are set. Nothing else carries the old name.

The telemetry sidecar

kaviri can write a JSON sidecar with the raw marks and the computed zoom events. It is off by default. --keep-temp puts it next to the video as <out>.telemetry.json, and KAVIRI_TELEMETRY=<path> writes it wherever you name. KAVIRI_TELEMETRY=0 suppresses it even under --keep-temp.

It stays off because its marks array carries every navigate label verbatim: full URLs, query strings, any token in them, and local file:// paths. Read it before you upload it as a build artifact.

Serving over TCP

kaviri serve on stdin needs no authentication: the ops come from the process that started it. kaviri serve --port <n> does not have that property. Binding to loopback is not a trust boundary against a browser, because any page the user visits can fetch() a loopback port, and the op set can navigate to file:// URLs and write an MP4 to a path of the caller's choosing.

So --port prints a token on stderr at startup and the first line of every connection must be {"op":"hello","token":"…"}. Any line that is not JSON drops the connection rather than being partly executed, which is what makes a stray HTTP request a disconnect instead of a script. Pick a port outside Chrome's debugging range; kaviri does not default to one. Prefer stdin when one client is enough.

Limitations

Where the code is

cargo test runs the suite, including two tests that shell out to a real ffmpeg to validate the generated filter graph and the hand-written PNG. They fail loudly rather than skipping when no ffmpeg is present; set KAVIRI_SKIP_FFMPEG_TESTS=1 if you genuinely want them skipped.

Licence

Apache 2.0, unconditional, on everything in this repository. No revenue threshold, no field-of-use restriction, no contributor licence agreement to sign. Run it locally, run it in your CI, put it in your product. See LICENSE.

A hosted version is being built at kaviri.dev for people who would rather not run the browser themselves. It is not live yet, and nothing here depends on it.


This page is generated from README.md in the repository, so it cannot drift from the code it documents without somebody noticing.