obra-chrome-video-capture
v0.1.0
Published
Scripted, retina-quality video and screenshot capture of web apps via headless Chrome — scenario-driven loops for portfolios, docs, and marketing.
Downloads
133
Maintainers
Readme
obra-chrome-video-capture
Scripted, retina-quality video and screenshot capture of web apps via headless
Chrome. You describe a scenario — a URL plus a choreography of cursor moves,
clicks, and holds — and the CLI records it as a looping mp4 + webm (with
poster) or as a 1x/2x PNG + WebP still set. Built for design portfolios,
docs, and marketing pages where screen-recorder output isn't good enough.
- True device-pixel capture (2× by default): 2800×1672 from a 1400×836 viewport
- 60fps output assembled from real frame timestamps (still moments cost nothing)
- A synthetic cursor with easing and click ripples (screencasts have no cursor)
- Deterministic and headless — no windows, no screen-recording permissions
Requirements
- Node 18+,
ffmpegon PATH (override withCAPTURE_FFMPEG) - A Chromium binary — resolved from
CAPTURE_CHROME, the Playwright browser cache (~/Library/Caches/ms-playwright), or system Chrome, in that order. Nonpx playwright installneeded if any of those exist.
Usage
capture record <scenario>|all # → static/videos/<name>.{mp4,webm} + poster
capture shoot <scenario>|all # → static/images/<name>{,@2x}.{png,webp}Run bare (capture record) to list scenarios. Scenarios live in
capture/scenarios/*.mjs under the consuming project; override locations with
--scenarios-dir, --out-videos, --out-images.
Scenario contract
import { fadeOut } from 'obra-chrome-video-capture';
export default {
name: 'my-demo', // output basename
url: 'https://example.com/app', // where the capture starts
viewport: { width: 1400, height: 836 },
deviceScaleFactor: 2, // capture at 2x device pixels
settle: 5000, // ms to wait after load before frame 1
hide: ['.chat-bubble'], // selectors hidden via injected CSS
init: [() => { /* runs before every document, e.g. patch window.open */ }],
warmup: ['https://…'], // pages visited pre-recording to warm caches
video: {
fps: 60, // output fps (default 60)
// width/height: omit to encode at native captured resolution
cursorStart: { x: 56, y: 21 }, // where the cursor begins (see loop rules)
async prepare({ page, sleep }) { /* off-camera state normalization */ },
async run({ page, cursor, sleep }) {
await sleep(1000);
await cursor.clickLocator(page.locator('button:has-text("Go")'), { ms: 700 });
await sleep(1500);
await fadeOut(page, 600); // outro when a seamless loop isn't possible
},
},
stills: [
{ name: 'my-demo-home' }, // uses scenario.url
{ name: 'my-demo-detail', url: '…', async setup({ page, sleep }) {} },
],
};cursor API: moveTo(x, y, ms), click(x, y, {ms}),
clickLocator(locator, {ms}), ensure() (re-install after a navigation),
position().
Making loops seamless
- End the choreography in the exact start state, including the cursor
position — set
cursorStartto where the final click leaves it. - If a beat changes state irreversibly (e.g. a fit % that settles differently
after the first toggle), do one round-trip off-camera in
video.prepare. - If the story ends somewhere with no UI path back, close with
fadeOut()— the loop restart then reads as a deliberate cut.
Hard-won implementation notes
- The
--force-device-scale-factorlaunch flag inlib/browser.mjsis the entire reason videos are 2×. CDP screencasts cap at CSS-pixel resolution; per-contextdeviceScaleFactoremulation does NOT lift that cap, the launch flag does. Don't "simplify" it away. - Frame acks throttle Chrome's screencast delivery —
lib/recorder.mjsacks immediately and writes frames async. Expect ~110fps delivery during motion. - Stills use
Page.captureScreenshot, which honors emulated DPR regardless. ctrl+wheelzoom in canvas apps pins the point under the mouse; to end with a target centered at zoomz1fromz0, aim the wheel atF = (s·N0 − C)/(s − 1)wheres = z1/z0,N0= target center,C= desired end position.- Links that open new tabs break single-tab recordings — patch them via
init:window.open = (url) => { location.href = url; return window; }.
