demovid
v0.5.0
Published
Rode dentro do seu projeto frontend, descreva a demo em português, e receba um MP4 narrado + uma timeline com os tempos de cada fala e os melhores pontos de corte. O roteiro é escrito por IA a partir dos elementos reais do app.
Maintainers
Readme
demovid
Turns any frontend project into a narrated demo video. One pass, no post-production.
A Playwright-driven Chromium loads your app and an injected overlay draws speech balloons, a
synthetic cursor and a spotlight. Narration is pre-rendered with OpenAI TTS and played through the
speakers; a screen recorder captures the browser window with system audio. Because the audio plays
live and audio.onended drives step advance, there is no timeline to synchronise — no forced
alignment, no word timestamps, no drift.
cd ~/my-app
npx demovid # scan → ask what to demo → gpt-5.4 writes it → rehearse → recordIt works out how to run your project, starts (or adopts) your dev server, crawls
the app for elements it can actually address, asks in Portuguese what you want to
show, has gpt-5.4 write the storyboard, rehearses it so broken selectors surface
before anything is recorded, and only then records. You get demo.mp4 and
demo.timeline.json.
Working out how to run your project
Most projects are answered for free, by reading files: the manifests of every
workspace member (your frontend is often not in the root package.json), the
server.port in vite.config.ts and friends, and the URL your dev server prints
on startup — whichever of those is actual evidence rather than a guess.
When that is not enough, demovid hands the repository to the
pi coding agent
(deepseek-v4-pro, thinking xhigh), which reads the README, follows the dev
script, and answers four things nothing else can: how to start the app and at
which URL, whether it needs a login and which dev credentials the repo
documents, how the app shows that it is busy — the spinners, skeletons and
aria-busy flags a Playwright script has to wait out, plus which operations are
slow enough to need a bigger ceiling — and what is actually worth
demonstrating, offered as a ready-made answer you accept with Enter or type
over. The answer is cached in .demovid.json, so it is asked once.
The answer is written to .demovid.json in your project, so it is paid for once,
and it is plain JSON you can correct by hand. It is re-derived when your manifests
change, not on a timer.
npx demovid --no-discover # never call the agent; fail if the files do not suffice
npx demovid --url http://localhost:5273 # skip configuration entirelyThe agent runs with all of its tools, including write, so it can edit the repository it is inspecting. demovid diffs the git working tree around the call and names anything it touched — it cannot undo those edits for you. Use
--no-discoveror--urlif that is not a trade you want.
The hand-written path still works, and the guided flow just writes the same file:
demovid doctor # does this machine have what it needs?
demovid rehearse demo.yaml # validate selectors and camera, record nothing
demovid record demo.yaml --res reels # → demo.mp4 + demo.timeline.json# examples/demo.yaml
title: Painel de Exames
url: ./app.html # relative to the YAML
locale: pt-BR
preset: boardroom
steps:
- action: focus
target: "#kpi-pendentes"
say: Aqui em cima ficam os indicadores do dia.
- action: type
target: "#busca"
value: "REQ-88413"
say: A busca aceita o protocolo ou o nome do paciente.A GIF for a README, with no voice at all
demovid record demo.yaml --format gif # → demo.gif, ≤ 5 MB, no API calls for audio
demovid record demo.yaml --format webp # same pass, usually 5–10× smaller--format gif (or webp) is a different product, not the MP4 with the sound off:
- No narration is synthesised. Not muted — never requested. A silent take costs nothing in TTS.
- The balloon becomes the only channel, so the storyboard's
captionfield is used instead ofsay(falling back tosaywhen a step has none), and thereadmepreset makes it big enough to read: 21px, slight transparency over a 3px backdrop blur, and placement that keeps clear of the cursor as well as of the element it describes. - Step timing comes from the reading budget —
pacing.cpsand the dwell floors — because there is noonendedto wait for. - 5 MB is a hard ceiling. Over it, frames are dropped (15 → 12 → 10 → 8 → 6 → 5 fps) until the file fits, and the log says which rung shipped. If even 5 fps is too big it hands you the file with a loud warning rather than nothing.
Measured on a 4 s 1280x720 source: GIF 1.08 MB, WebP 0.10 MB. GitHub renders both in READMEs,
issues and PR comments, so webp is the better default whenever the audience is GitHub only; gif is
what still works in npm, PyPI and older wikis.
Status
Working: the guided flow, doctor, rehearse, record, restore. Presets boardroom, helpdesk
and readme, locale pt-BR. Output as MP4, GIF or animated WebP. Resolutions from 720p to reels
(9:16 vertical), plus any of Playwright's 207 device names.
Requirements
- Linux/X11. Window capture is native there. Wayland falls back to the desktop portal and is untested.
- Node ≥ 20, a Chromium-family browser (Brave, Chrome, Chromium — Firefox will not work, no CDP), ffmpeg and xdotool.
- Two API keys, split by job rather than by vendor.
OPENAI_API_KEYwrites the storyboard and the commercial edit (gpt-5.4) and synthesises the narration.DEEPSEEK_API_KEYis spent only by thepiagent below. Recording a hand-written storyboard with--format gif|webpneeds no key at all — no prose to write, no narration to synthesise. - The
picoding agent — the only thing that spendsDEEPSEEK_API_KEY, and it is called only when demovid cannot work out how to run your project from its files, which is the case for anything with a non-standard dev server. A project it recognises never calls it, and--no-discoveror--urlopt out entirely. - A screen recorder — optional. demovid prefers
gpu-screen-recorderand drives it directly; there is no wrapper script to install. Without it, it falls back to ffmpeg (x11grab+ the PulseAudio monitor, with NVENC or VAAPI when available). The fallback cannot pause and cannot follow a window that moves, and it says so rather than pretending otherwise.
Run demovid doctor — it names the backend it picked and why.
What comes out
Two files. The MP4, and a .timeline.json that demovid can produce and a screen recorder cannot,
because demovid caused everything in the frame:
{ "clock": { "method": "first-frame-ts", "residualMs": 17 },
"narration": [{ "text": "Aqui em cima ficam os indicadores…",
"startMs": 15341, "endMs": 18805, "measured": true }],
"events": [{ "t": "camera-move", "startMs": 14980, "endMs": 15430 }],
"cuts": [{ "atMs": 33174, "score": 0.75, "kind": "entre-passos",
"reasons": ["silêncio de 4375ms (+0.35)", "câmera parada (+0.25)",
"fronteira de passo (+0.15)"] }] }clock.method says which anchor was trusted and residualMs is the honest error bar on every
timestamp in the file. Cut scores carry their derivation, because a score you cannot explain is a
score you cannot tune.
Run demovid doctor — it checks every one of these and tells you which is missing. Add --deep to
spend one minimal API call proving the OpenAI key has credit, not just that it is valid:
/v1/models is free and returns 200 on an account with zero balance.
How it works
┌─ recorder ── captures the browser window + system audio ─────────┐
│ ┌─ Chromium (disposable profile, headed) ──────────────────────┐ │
│ │ document.documentElement │ │
│ │ ├── <div id=__demovid_stage> ← THE STAGE, position:fixed │ │
│ │ │ translate3d(…) scale(k) · transform-origin 0 0 │ │
│ │ │ (the whole app was moved in here) │ │
│ │ └── <div popover=manual> ← THE OVERLAY, top layer │ │
│ │ + shadow root. Balloon · cursor · mask · audio │ │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘The overlay never scales. Top-layer elements escape ancestor transforms — CSS Position L4 §3:
they "generate boxes as if they were siblings of the root element… Ancestor elements with overflow,
opacity, mask, etc. cannot affect it." But the balloon still follows the zoom for free, because
getBoundingClientRect() on an element inside the stage already returns the transformed rect.
Things this project measured, so you do not have to
Most of these contradict what the surrounding literature says. Every one is reproducible from the
tests in test/.
A port is free on IPv4 and taken on IPv6 at the same time. Vite 7 bound [::1]:5273 and nothing
on 127.0.0.1, so a connect probe against IPv4 alone reported the port free while the app was
serving 200 — and demovid started a second dev server against a port that was already in use. Plenty
of servers bind IPv4 only, so neither family can be the one you ask: probe both and OR the result.
"Believe the URL the server prints" is not implementable as a fallback. The announcement has to
race the guessed port, not be consulted after waiting on it — if you wait first, the timeout
expires before you ever read the announcement, which is precisely the case it was written for. But it
cannot simply win, either: a dev script that starts an API and a frontend announces whichever
printed first, and an orphaned process from an earlier run answers on it instantly. A port read from
vite.config.ts therefore gets a head start; only after it expires does an announcement count.
Transforming document.body does not work. It is what the existing open-source tools in this
space do, and it is broken: under a transformed body, position: fixed behaves like absolute. A
header scrolls to y=-800 after scrolling 800px, bottom: 0 resolves against the content height
(y=4004 instead of 669), and height: 100% becomes the whole document. body is viewport-width
but content-height — only half of "coincident with the viewport" is true. The stage has to be a
position: fixed element instead.
scrollbar-width: none on the stage is structural, not cosmetic. The moment any transform is
set — even scale(1) — a position: fixed; left:0; right:0 header switches containing block from
the viewport to the stage's padding box, and a visible scrollbar makes that box 15px narrower. The
header silently goes 1353 → 1338px.
transform: scale() does not blur text. Captured the real window mid-zoom at k=1.4/2/3 and
magnified 200% with nearest-neighbour: clean glyph edges in both a WAAPI and a rAF-driven camera.
What does blur permanently is will-change: transform — it pins the raster scale. Never use it here.
OpenAI TTS narrates imperatives correctly. A widely-repeated claim says gpt-4o-mini-tts answers
commands instead of reading them, which would be fatal for demo scripts. Transcribing the output and
comparing to the input gives 100% fidelity, with and without instructions.
instructions controls pace up to ~140 wpm, then saturates. Asking for 130 delivered 129; asking
for 170 still delivered 140. Above that the only working lever is speed, which is reliable here
and linear (1.15→1.19×, 1.30→1.32×, 1.50→1.58×).
Trim edge silence — with one clip per sentence it is worth ~840 ms each. Measured on a single long
clip it looks like 80 ms and not worth doing. That is the wrong measurement for this architecture:
short clips carry proportionally huge padding, about 17 s of dead air across a 20-sentence script —
and since onended drives advance, that is 17 s of frozen screen. Trimming took a clip from 101 wpm
to 140 wpm, and makes the gap between steps a preset knob instead of an accident.
Development
npm install
npm run verify # typecheck + bake springs + build + unit tests + 2 browser e2e (one records)The e2e tests open a real browser and one of them records ~8 s of real video. They are not mocked
because the things worth proving — that the top layer escapes a transform, that a position: fixed
header survives the scroller swap, that audio actually reaches the file — only exist in a real
engine.
Prior art
This space got crowded in 2026. Read these before assuming demovid is the right tool for you:
playwright-recast,
argo,
shot-scraper video,
Cap.
demovid's difference is the single pass: OS-level capture at 60 fps with GPU encoding, and live audio playback instead of a post-production timeline.
Ideas and constants borrowed with thanks — see NOTICE.
License
Apache-2.0. See LICENSE.
