/community/demo reel
innernet
docsskillscommunitycompanydeveloperssecurityprivacytermscontact

a skill by hasuwini77, brought here by SD

demo reel

paste this link into your ai. it will know what to do.

/skills/hasuwini77-demo-reel

Record a smooth, frame-exact 60 fps demo video of any web app from a short script — scripted cursor with a highlight halo, click ripples, captions and a corner badge, rendered on a virtual clock so it never stutters, encoded to an MP4 that loops in PowerPoint, Keynote or on the web. Use when the user wants a demo video, product walkthrough, screen recording, feature tour, before/after comparison video, a clip for a slide deck or landing page, or says "record the app", "make a video of", "screen capture", "show how it feels", "demo reel". Also for turning a Playwright flow into a video or makin

kindskill
last tended2026-09-28
written byhasuwini77
sourcegithub.com/hasuwini77/demo-reel
kept currentwith its source · last read 2026-11-13
brought here bySD
reuseMIT
yours?it stays under your name. to change it or take it down, write to connect@innernet.live
reading15 min

demo-reel

Turn a scripted walk through a web app into a video that looks like a polished screen recording: smooth 60 fps motion, a visible cursor with a halo, click ripples, captions. It drives the real app in headless Chromium — the footage is the product, not a mock-up.

Why the video is smooth

A normal screen recording captures frames when the browser happens to paint; on a slow machine (or software WebGL) that is ~10–15 fps and it stutters. demo-reel instead injects a virtual clock (scripts/inject.js): requestAnimationFrame, performance.now and Date only advance when the recorder says so, and CSS/Web animations are stepped via document.getAnimations(). Each frame is advanced exactly 1/fps, screenshotted, and piped into ffmpeg. However long a frame takes to render, the result plays back perfectly smooth.

Timers (setTimeout/setInterval) are left real by default — faking them all makes zero-delay timer chains in loaders and schedulers spin forever (this is why Playwright's own page.clock hangs on many WebGL pages).

Hybrid clock (opt-in: clock: "hybrid" in the scenario, or --clock hybrid): timers of 16 ms or more wait on the virtual clock and fire, in order, when a frame (or a d.settle step) reaches them; intervals re-arm. Shorter delays, promises, queueMicrotask and MessageChannel stay real, so zero-delay chains still can't spin. A 3 s toast is then on screen for exactly 3 s of video (180 frames at 60 fps). It also turns behavior: "smooth" in scrollTo/scrollBy/scroll/scrollIntoView into a 400 ms eased scroll stepped per frame. Virtual timers only move while the recorder ticks: to wait for timer-driven UI (a debounced search), use d.settle/d.hold, not Playwright's waitForSelector.

The compositor still runs on real time: image decode, tile raster, GIFs and scroll-behavior: smooth. --capture beginframe (headless only) puts it on the virtual clock too: every frame is drawn on demand by HeadlessExperimental.beginFrame at the frame's virtual time, with raster and decode finished before it is captured. It's opt-in because in the current headless shell, animated GIF/APNG/WebP stay on their first frame until something repaints them, and smooth scrollIntoView/scroll-behavior: smooth jump in one frame instead of animating (with the hybrid clock, script-requested behavior: "smooth" scrolls animate again; CSS scroll-behavior: smooth — anchor links, behavior: "auto" — still jumps). Try it on pages with none of those when a zoom shows half-rastered tiles. d.scroll stays smooth in both modes.

Requirements

  • Node 18+, ffmpeg on PATH.
  • Once, in this skill's directory: npm install && npx playwright install chromium.

Workflow

  1. 1Serve the app the way users see it — ideally a production build (vite build && vite preview, next build && next start) against local or mock data. Dev servers work but are slower and may show dev overlays. Never record real or sensitive data — seed demo data.
  2. 2Write a scenario (*.scenario.mjs, see API below). Start from examples/tasks.scenario.mjs. Plan it as a story: 20–60 s, one idea per caption, 1–2 s holds after each result so viewers can read it.
  3. 3Record: node scripts/record.mjs my.scenario.mjs --out demo.mp4 Iterate with --preview (24 fps, lighter JPEG + x264, same viewport so layout is unchanged), then record the full take. A 60 s clip at 1080p takes roughly 3–10 min (heavy WebGL pages are slowest). Run it in the background.
  4. 4QA: scripts/sheet.sh demo.mp4 writes a contact sheet (one frame every 2.5 s). Look at it: captions match what is on screen, no loading spinners, nothing covered by the caption, badge/cursor visible. It also prints a smoothness report, e.g. jank: 0 suspicious frames, 0 hard cuts: a suspicious frame is a frozen one (nothing changed while the two frames on each side move, a duplicated frame), a hard cut is a scene change above 0.35. Pops (one frame changes > 3× both neighbours) are listed but not counted: typing and discrete UI updates look the same as a real-time timer popping, so check them by eye. A single dropped frame is only ~2× its neighbours and isn't detected, and a pan at ~1 px/frame (the tail of a zoom) can round to the same pixel for a frame and show up as frozen.
  5. 5Deliver (see "Using the video").

Scenario API

export default {
  size: "1920x1080",          // output video size
  fps: 60,
  theme: { captionPosition: "bottom", accent: "#6366f1", cursorStyle: "auto" }, // optional, see Theme
  frame: true,                 // optional — rounded window + shadow on a background, see Frame
  clock: "hybrid",             // optional — timers >= 16 ms and smooth scrolls on the virtual clock
  async run(d) {
    await d.open("http://localhost:4173/");     // navigate + settle (not recorded)
    d.badge("NEW", "#15803d");                  // corner badge (null hides)
    d.caption("The new dashboard");             // caption pill (null hides)
    await d.hold(1500);                          // record 1.5 s as-is
    await d.click(d.page.getByRole("button", { name: "Filters" }));
    await d.zoom("#filters");                  // ease in; the camera follows the cursor
    d.caption("Filters live in one panel");     // set captions AFTER the action
    await d.hold(1200);
    await d.type("Stockholm");                  // human-speed typing
    await d.press("Enter");
    await d.highlight(".results", { style: "box", ms: 1500 });
    await d.callout(".filters", "Filters live here", { ms: 1500 });
    await d.zoom(null);                         // ease back out
    await d.scroll(600);                        // smooth wheel scroll
    await d.card({ title: "Try it", subtitle: "example.com" }); // title / end card
    await d.step("optional part", async () => { /* failures are logged, recording continues */ });
  },
};
CallDoes
d.open(url, { settle, prewarm })Navigate, let the page settle (unrecorded, default 2500 ms), wait for fonts, load + decode every image; prewarm (default on) scrolls to the bottom and back unrecorded so lazy content is in before the first recorded scroll.
d.hold(ms)Record the page as it is.
d.settle(ms)Advance time without recording — skip loading states, lazy chunks, layout animations you don't want to show.
d.move(target, { ms }) / d.hoverGlide the cursor to a target. Without ms the duration follows the distance (320–1100 ms); spring-eased, slightly arced, motion-blurred.
d.click(target, { ms, button })Move, click with a ripple. button: "right" for context menus.
d.doubleClick(target)Double click.
d.type(text, { delay })Type one key at a time (default 90 ms/key).
d.press(key)Keyboard shortcut, e.g. "Escape", "Control+K". Shown as keycaps above the caption for 900 ms (theme.keycaps).
d.scroll(dy, { ms })Smooth wheel scroll.
d.zoom(target, { scale, ms, follow })Ease the camera onto a target (boxes are framed to fit, max 1.8×), then follow the cursor. Non-blocking — plays over the next moves/holds. d.zoom(null) eases out.
d.highlight(target, { style, color, pad, ms })Mark a target: box, circle, spotlight, underline, marker. Clears after ms, or all at once with d.highlight(null).
d.callout(target, text, { side, color, ms })Label a target: a caption-style pill beside it, a leader line and a dot on its edge. side auto (the side with the most room) · top · right · bottom · left. Zooms with the page. Clears after ms, or with d.callout(null).
d.card({ title, subtitle, ms, bg, align })Full-frame title or end card over the page (not zoomed): 350 ms fade in, ms shown (2500), 350 ms fade out, cursor hidden. bg any CSS background (#0f172a), align center · left.
d.caption(text) / d.badge(text, color) / `d.cursor(bool \style)`Overlay state; survives full page navigations. d.cursor("hand") switches shape mid-take.
d.speed(k)Fast-forward: each recorded frame advances the page by k/fps, and hold/type delays count in page time (a hold(8000) at 4× is 2 s of video). Cursor moves, scrolls and the camera keep their on-screen speed. d.speed(1) resets.
d.poster()Mark the next recorded frame as the poster: written as <out>.poster.jpg.
d.chapter(title)Start an MP4 chapter here (listed by QuickTime, YouTube, ffprobe -show_chapters).
d.say(text, { wait })Voice-over from this frame (needs voice, see Voice-over). Returns { at, dur } at once; wait: true holds until the clip ends.
d.step(name, fn)Named step; errors are logged, not fatal.
d.page, d.point(target), d.width, d.heightEscape hatches.

Theme (all optional): accent (one colour for halo, click effect and highlights), cursorStyle (arrow · mac · hand · ibeam · dot · auto = hand over links/buttons, I-beam over text fields), cursorSize (34), halo (true), haloStyle (fill · ring · glow), haloSize, haloFill, haloStroke, clickStyle (ripple · ring · pulse · none), caret (true: the text caret is drawn on the virtual clock — solid while typing, then a steady blink; false keeps Chromium's, which flickers at random in the video), cursorMotion ({ spring, arc, blur }, all on; false turns all off — cubic ease, straight line, no blur), autoZoom (on by default; false turns it off, or { scale, ms, gap, dwell, minHold, clicks } — defaults 1.35, 1400, 10000, 4000, 1500, "smart"), captionPosition, captionSize, badgeTop, font, keycaps (mac default: Control/Meta → ⌘, ⌥ ⇧ ↩; win: Ctrl, Alt, Shift; false hides them), seed (a number: Math.random becomes a seeded PRNG, so apps with random demo data give identical takes).

Frame: frame: true sits the page in a rounded window with a soft shadow, inset on a wallpaper/gradient — omit it and output is unchanged. It costs nothing per frame (one plate image, composited by ffmpeg), and pairs well with zoom — the window stays put while the camera moves inside it.

KeyDoes
backgroundAny CSS background value (gradient, colour…) or a path to a local image (cover-fit).
paddingpx inset on every side; the recorded page becomes size - 2*padding.
radiusWindow corner radius, px.
shadowtrue (soft default), false, or a CSS box-shadow string.
titlebar"none" (default) or "mac": a light 32 px title bar with traffic-light dots on top of the window; the page loses those 32 px of height.

Captions, the badge, cards and the cursor are drawn in the page, so they stay inside the window, never on the background.

Zoom re-renders the visible area at the zoom level (Chromium device-metrics emulation), so text stays sharp; the page never sees a resize and clicks still land where you aim. Camera moves ride a critically damped spring stepped once per frame — they always ease in and out. Captions and the badge keep their screen size; cursor and highlights zoom with the page.

Supersampled zoom (render: 2 in the scenario, or --render 2, opt-in): the page is rastered once at 2× and the zoom becomes a crop + lanczos downscale of the captured frame instead of the emulation above, so letters never re-render during a zoom move; every frame also gets 2× anti-aliasing. It costs ~4× the pixels per frame (slowest on WebGL pages) and a second encode pass, with a lossless full-size intermediate in the temp dir. Beyond 2× zoom the crop is upscaled, so text softens.

Auto-zoom (default): a few gentle zooms without writing any — when typing starts (onto the field), and on a click in the page body followed by a hold ≥ 1.5 s. Clicks are "smart" by default: the recorder watches what the page changes in the 300 ms after the click and zooms onto click + change only when that sits in the body and covers < 35 % of the view — a side panel sliding in at the edge, a new page or no visible change means no zoom. clicks: true always zooms onto the click, false never. At most one per 10 s, 1.35×, slow; out again after 4 s or before a long cursor move. Clicks near the edges (toolbars, nav) never zoom — their result shows elsewhere. The first manual d.zoom() hands the camera to the scenario for the rest of the take.

Targets can be a Playwright Locator, a CSS selector string, { x, y }, or an async function (page) => ({ x, y }).

Canvas-rendered UIs (graphs, maps, charts) have no DOM element per item — pass a function that asks the library for screen coordinates. Example for Cytoscape.js:

const node = (label) => (page) => page.evaluate((label) => {
  const el = [...document.querySelectorAll("div")].find((e) => e._cyreg?.cy);
  const n = el._cyreg.cy.nodes().filter((n) => n.data("label") === label)[0];
  const r = el.getBoundingClientRect(), p = n.renderedPosition();
  return { x: r.left + p.x, y: r.top + p.y };
}, label);
await d.click(node("Server A"));

Voice-over

Name a provider and the take gets an audio track; captions also become subtitles. Nothing is spoken unless the scenario names a provider — there is no key auto-detection, because cloud providers send the text off the machine.

export default {
  voice: { provider: "kokoro", voice: "af_heart", speed: 1, captions: true }, // captions: true speaks every d.caption
  async run(d) {
    await d.say("Filters live in one panel.");                 // starts at this frame, doesn't hold
    await d.say("Results update as you type.", { wait: true }); // holds until the clip ends (+200 ms)
  },
};
providerNeedsDefaults
kokoronpm i kokoro-js in the skill folder (~830 MB, CPU only; not installed by default). First use downloads the model (~80 MB). Local, free.voice af_heart, dtype: "q8"
elevenlabsELEVENLABS_API_KEY in the environmentmodel eleven_flash_v2_5 (eleven_multilingual_v2 for quality), voice = voice id
openaiOPENAI_API_KEY in the environmentmodel gpt-4o-mini-tts, voice alloy
piperpiper on PATH, model: "path/to/voice.onnx"—
commandcommand: "my-tts --out {out} {text}" (split on spaces, no shell)writes a WAV to {out}
  • Keys are read from the environment only — never pass them as CLI arguments; they are never logged.
  • Synthesis happens between frames, so it costs no video time. Clips are cached in .demo-reel-cache/voice/ by provider, voice, model, speed and text: a re-take re-bills and re-synthesizes nothing (voice: cache hit).
  • Clips never overlap: one that would start within 150 ms of the previous clip's end is moved later, with a warning. Give long lines wait: true or a longer d.hold.
  • The clips are mixed under the untouched video (-c:v copy, AAC 160k, 48 kHz). No voice → the MP4 stays silent, as before.
  • Subtitles: every take with captions also writes <out>.srt and <out>.vtt next to the MP4 — a line starts on the frame the caption was set and ends when it is replaced or cleared.
  • Karaoke captions: theme: { captionStyle: "karaoke" } lights each caption word as it is spoken (a caption spoken by voice.captions or by d.say with the same text; unspoken captions show fully lit). ElevenLabs gives real word times (/with-timestamps); other providers are estimated by character count.

Music and sound effects

audio: {
  music: "bed.mp3", musicVolume: 0.18, duck: true, // looped, 1 s fade in / 1.5 s out, ducked under the voice
  sfx: true,                                        // or { click: true, key: true, volume: 0.4 }
},
  • No music is shipped — bring your own licensed track. duck (default on) compresses the music while the voice speaks (sidechain compressor, ratio 8).
  • Click and keystroke sounds are generated by ffmpeg (no audio assets) and land on the click / key frame.

Writing good demos

  • Captions tell the truth. Only claim what the footage shows. If a bug you want to show doesn't reproduce in the take, change the caption — don't keep it.
  • Caption after the action, not before — otherwise the caption for the next screen appears over the previous one.
  • Show outcomes, not clicks. Hold 1–2 s on every result; cut loading with d.settle.
  • Before/after: record two scenarios with the same story and steps, badge BEFORE/AFTER (red/green), and keep them separate files plus an optional concatenation.
  • Hero moments (a landing page, an animation): give them 5–10 s — viewers need a moment to take it in.
  • Keep the cursor calm: leave ms off — moves are timed by distance — and park it away from content while holding.
  • Zoom sparingly: auto-zoom covers typing and click-then-hold when the result lands near the click; add a manual d.zoom only where it misses (it then takes over). One or two zooms per minute, 1.3–1.5×, on the moment that matters (a value changing, small text). Zoom out before a big move across the screen. A highlight is often enough.
  • Real timers keep running while frames render (~50–300 ms real time per frame), so timer-driven UI — toasts, debounced search, auto-dismissing tooltips — may disappear sooner in the video than in real life. Record with clock: "hybrid" to keep their real duration, or trigger them right before you want them on screen.

Using the video

  • PowerPoint: Insert → Video → This Device. Playback tab: Start: Automatically and Loop until Stopped. H.264 yuv420p MP4 (what demo-reel writes) plays on Windows and macOS.
  • Keynote: drag in; Movie → Repeat: Loop.
  • Web: <video src="demo.mp4" autoplay loop muted playsinline></video>.
  • Seamless loop: --loop (or loop: true in the scenario; --loop 800 / loop: 800 for another length in ms) crossfades the last 500 ms into the first 500 ms, so a looping slide or <video loop> doesn't jump from the last frame to the first. The clip gets 500 ms shorter and starts 500 ms in; subtitles and audio shift with it. End the scenario near its start state (cursor parked where it began, caption cleared, same scroll and zoom) or the crossfade shows two screens ghosting over each other.
  • GIF (README, chat): ffmpeg -i demo.mp4 -vf "fps=20,scale=960:-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse" demo.gif
  • Concatenate clips of the same size/fps: printf "file 'a.mp4'\nfile 'b.mp4'\n" > l.txt && ffmpeg -f concat -safe 0 -i l.txt -c copy both.mp4

Troubleshooting

SymptomFix
Blank / "Loading…" framesLonger settle in d.open, or await d.settle(1500) after a navigation click.
Target not visibleIt's off-screen or inside a closed menu — open/scroll first; for canvas items use a point function.
WebGL/3D renders blackKeep the default launch flags (--use-angle=swiftshader); it is slow (~0.25 s/frame) but correct.
Video too short/long vs planDurations are exact: sum of hold + moves. settle time is never recorded.
Colours look washed out in PowerPointDon't re-encode without -pix_fmt yuv420p; demo-reel's output is already correct.
Typed text flickers / letters hopFixed in v0.4.1: caret on the virtual clock, camera snaps when settled. Letters still re-render during a zoom move at the default render — use render: 2, or keep zooms slow and shallow.
Text slightly soft--png captures lossless frames (slower, larger).
Blank or half-rastered tiles mid-zoomTry --capture beginframe (see "Why the video is smooth" for what it breaks).
sheet.sh reports frozen framesA frame repeats the previous one mid-motion. Re-record; if it recurs at the same spot, try --capture beginframe.
--capture beginframe has no effectIt's headless only: --headed Chrome has no BeginFrameControl and always uses screenshots.

keep it where your ai can reach it.

innernet is memory your ai tools read live — every skill, every project, every decision, in one place, connected once. save this skill to yours, or publish one of your own as a link like this.

save it to your innernetsee every skill