kaviri.dev

Your demo video is a build artifact.

It changes when your product does, or the build fails.

kaviri recording a web app. No hand on a mouse. The camera follows the caret.

Demos go stale.

The UI moves and the video does not. Nobody re-records it, because re-recording means blocking out an afternoon, finding a quiet room and getting the mouse path right on the fourth take. So the demo quietly lies about the product, and it keeps lying until someone is embarrassed enough to book that afternoon.


How it works

Write the take down. Run it in CI.

A take is a newline-delimited JSON script of ops. It lives in the repo, next to the code it films, so it is reviewed and versioned like the code it films.

examples/demo.jsonl

{"op":"navigate","url":"http://127.0.0.1:8099/"}
{"op":"wait","selector":"#q","timeout_ms":30000}
{"op":"start_recording"}
{"op":"wait","ms":2200}
{"op":"mark","label":"start"}
{"op":"type","selector":"#q","text":"14 Rue Lafayette, Paris"}
{"op":"wait","ms":500}
{"op":"click","selector":"#go"}
{"op":"wait","selector":"li","timeout_ms":15000}
{"op":"wait","ms":1400}
{"op":"mark","label":"tracked"}
{"op":"type","selector":"#q","text":"Kigali Heights, KG 7 Ave"}
{"op":"wait","ms":500}
{"op":"click","selector":"#go"}
{"op":"wait","ms":1800}
{"op":"mark","label":"second"}
{"op":"wait","ms":1600}
{"op":"stop_recording"}

Every op carries a timestamp and a bounding box, and the zoom is derived from them. You do not describe camera moves. You describe what happens.

.github/workflows/demo.yml

name: demo

on:
  push:
    branches: [main]
    paths:
      - src/**
      - examples/**
      - .github/workflows/demo.yml

jobs:
  record:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Serve the app
        run: |
          python3 -m http.server 8099 --directory examples &
          for _ in $(seq 1 40); do
            curl -sf http://127.0.0.1:8099/ >/dev/null && break
            sleep 0.25
          done

      - name: Record
        uses: thisisisheanesu/kaviri@v1
        with:
          script: examples/demo.jsonl
          out: demo.mp4
          preset: readme
          artifact-name: demo

The action installs ffmpeg and a Chromium if the runner has none, builds the recorder and uploads the MP4 as a workflow artifact. preset sets the viewport, the capture scale and the output size together: readme, tiktok, square, landscape, phone.

or locally, with no CI at all

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

The part that matters

The build fails if the take filmed nothing.

A recorder that writes a valid MP4 of a Chrome error page has not failed in any way your CI can see. Thirty seconds of a frozen dev-server timeout is still thirty seconds of video, and it will sail past a duration check. So kaviri's own workflow compares the first frame against the last one. A script that navigates, clicks and types cannot produce the same picture twice.

.github/workflows/demo.yml, the check that turns the job red

ffmpeg -nostdin -v error -y -ss 0.5     -i "$OUT" -frames:v 1 "$RUNNER_TEMP/first.png"
ffmpeg -nostdin -v error -y -sseof -0.5 -i "$OUT" -frames:v 1 "$RUNNER_TEMP/last.png"
ffmpeg -nostdin -v error \
  -i "$RUNNER_TEMP/first.png" -i "$RUNNER_TEMP/last.png" \
  -lavfi "psnr=stats_file=$RUNNER_TEMP/psnr.log" -f null -

psnr=$(sed -n 's/.*psnr_avg:\([^ ]*\).*/\1/p' "$RUNNER_TEMP/psnr.log" | head -1)

# psnr_avg is the literal "inf" for two identical frames, so that case is decided here
# rather than left to awk's coercion.
if [ -z "$psnr" ] || [ "$psnr" = "inf" ]; then
  echo "the first and last frames are identical; the take filmed nothing"
  exit 1
fi

# Anything above ~50dB is indistinguishable by eye, which for a demo that navigates,
# clicks and types means the page never changed.
awk -v p="$psnr" 'BEGIN { exit (p + 0 < 50.0) ? 0 : 1 }' \
  || { echo "the first and last frames are the same picture; the take filmed nothing"; exit 1; }

the first and last frames are the same picture; the take filmed nothing

That line in a pull request is the whole idea. The demo is not a file someone remembers to refresh. It is a check that goes red.


Local and hosted

The recorder is free. The queue is the product.

Local

Free forever. Apache 2.0. No account.

  • One Rust binary, Linux and macOS
  • Brings its own browser over CDP, no screen recorder, no Wayland portals
  • Runs on your laptop or in your own CI
  • Your pages never leave your machine

Hosted

The same recorder, with the parts you would otherwise build.

  • A queue, so a take does not occupy a CI minute
  • Storage and a stable URL you can link from a README or a landing page
  • Every take kept, so you can see what the demo looked like three releases ago

Pricing is coming. Join the waitlist and you will hear the number before anyone is asked to pay it.


Open source

Apache 2.0, unconditionally.

The recorder is the whole product. Everything that makes the video is in the repo under Apache 2.0, with no revenue threshold, no delayed-open clause and no field-of-use limit. Fork it, ship it inside your own tool, sell the result. The hosted service is a convenience, and the only thing the licence holds back is the name.