@develemit/demo-kit
v0.1.1
Published
Shared harness for recording narrated Playwright walkthroughs for develemit-hq's Media Studio: caption timing (SRT, WebVTT and a read-aloud voice script, all from the same take), pacing, a synthetic cursor, focus rings, an optional on-screen narration pan
Readme
@develemit/demo-kit
Shared harness for recording narrated Playwright walkthroughs for develemit-hq's Media Studio: caption timing (SRT, WebVTT and a read-aloud voice script, all from the same take), pacing, a synthetic cursor, focus rings, an optional on-screen narration panel, a Playwright config preset, and the teardown that files the finished recording next to its captions.
The SRT output is pinned to develemit-hq's parser by its
demo-kit-contract.test.ts, because a timing bug here shows up only as subtle
audio/video drift in a finished video.
Entry points
@develemit/demo-kit— the runtime (everything below).@develemit/demo-kit/config—defineDemoConfig, the Playwright config preset.@develemit/demo-kit/teardown— the preset'sglobalTeardown.defineDemoConfigwires it in; you don't import it.
Config
// playwright.demo.config.ts
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineDemoConfig } from "@develemit/demo-kit/config";
const HERE = dirname(fileURLToPath(import.meta.url)); // CommonJS: __dirname
export default defineDemoConfig({
repoRoot: resolve(HERE, "../.."),
testDir: "apps/web-e2e/demo",
testMatch: "**/*.demo.spec.ts",
baseURL: "http://localhost:3000",
basename: process.env.DEMO_BASENAME ?? "myproject-walkthrough",
mediaDir: process.env.DEMO_OUTPUT_DIR ?? "media-studio",
// optional: viewport (default 1920×1080), webServer, globalSetup, timeout
// (default 15 min), outputDir (default .demo-results), and extra `use`
// options such as { launchOptions: { slowMo: 150 } }
});One worker, no retries (a retry records a second, conflicting take), video on at
the viewport size, every path resolved from repoRoot. It sets
DEMO_BASENAME, DEMO_KIT_MEDIA_DIR and DEMO_KIT_OUTPUT_DIR, which the
runtime and teardown fall back to, so specs rarely pass paths. mediaDir may
not sit inside outputDir, which Playwright empties every run.
Three ways to time a take
All three measure the take as it runs, never from a plan, so the captions match the video the same run produced.
1. A cue list — recordCues
Each cue's dwell comes from its word count (or dwellMs). Best for a new demo.
import { test } from "@playwright/test";
import { recordCues, type Cue } from "@develemit/demo-kit";
const CUES: Cue[] = [
{ text: "This is the dashboard.", run: async (page) => { await page.goto("/"); } },
{ text: "Every figure here is synthetic.", focus: (page) => page.getByTestId("kpi-total") },
];
test("walkthrough", async ({ page }) => {
const clockStartedAt = Date.now(); // the page fixture's video is already rolling
await recordCues(page, CUES, {
title: "My Project",
clockStartedAt,
annotate: process.env.DEMO_ANNOTATE === "1",
tailMs: 1200,
});
});| Option | Default | |
|---|---|---|
| basename, mediaDir | from defineDemoConfig | output file stem and directory |
| annotate | false | ring each cue's focus; the ring's time comes out of the dwell, so an annotated take keeps the plain take's cue spans (variant adoption depends on it) |
| cursor | annotate | draw the synthetic cursor |
| narration | false | show each cue (with optional segment/title) in the on-screen narration panel |
| pacing | 155 wpm, 650ms pad, 2200ms floor | { wordsPerMinute, padMs, minMs } |
| clockStartedAt | when recordCues is called | epoch ms the video started; anything the test did before the call otherwise shifts every caption early |
| captionEnd | "dwell" | "next-cue": a caption runs until the next cue starts, the last to the end of the take |
| tailMs | 0 | hold on the final frame |
| requireVideo | true | fail if the page isn't recording |
| straightQuotes | false | write straight quotes, for consumers that mangle curly ones |
2. Free-form — createCaptionLog
For specs that pace themselves (fixed beats, interactions of unknown length). Each caption runs until the next mark.
const log = createCaptionLog({ clockStartedAt });
log.mark("Credentials are server-side configuration.");
await page.waitForTimeout(12_000);
log.mark("Tokens are cached until they expire.");
// ...
log.write({ title: "Code walk", straightQuotes: true });3. Planned windows — createTimeline
Segments with planned start/end times, captions at offsets inside each, and highlights fired on schedule while the segment's actions run. Planned windows keep repeated takes (before/after a redesign) aligned segment by segment; an overrun is reported and the captions still follow the video.
const { context, page, videoStartedAt } = await openRecordedPage(browser, { baseURL, viewport, storageState });
await page.goto("/control-tower");
const timeline = createTimeline(SEGMENTS, {
videoOffsetMs: Date.now() - videoStartedAt,
runHighlight: (h) => focusOn(page, page.getByTestId(h.testId), h.testId, { moveCursor: false }),
});
await timeline.beat("control-tower", async () => { /* actions */ });
const { timings } = timeline.writeArtifacts({ title: "Demo", vtt: true, timingsJson: true, narrationScript: true });
await context.close(); // finalises the video; the teardown files itBuilding blocks
warmUpOffCamera(browser, contextOptions, run)— sign in and compile routes in an unrecorded context; returnsstorageState.openRecordedPage(browser, options)— a recording context for({ browser })specs, video into the teardown's directory, cursor installed; returns{ context, page, videoStartedAt }.installDemoCursor(page, { travelMs, size, clickRing })— Playwright's video has no pointer.focusOn(page, locator, label, { durationMs, ring, moveCursor, timeoutMs })— glide to and ring an element; a stale target fails the take.glideScroll(page, px, { steps, stepDelayMs })— scroll that reads as movement.installNarrationPanel/showNarration/showNetworkActivity/clearNetworkActivity— on-screen narration and a mirrored API-traffic panel, for silent or rehearsal takes. Leave it off for Media Studio takes; HQ burns captions itself.showCode(page, { file, startLine, endLine, caption })— render a real source file with a highlighted band, for code walks. Refuses.envfiles.warmRoutes(baseUrl, routes)— prefetch dev-server routes from a global setup.buildSrt,buildVtt,buildVoiceScript,cueDurationMs,formatClock— the pure pieces.
Migrating a bespoke harness
| Bespoke piece | demo-kit |
|---|---|
| paths.ts, hand-written Playwright recording config | defineDemoConfig |
| collect-recording.ts, or copying video.path() by hand | the preset teardown |
| srt.ts, voice-script.ts, pacing.ts, cue loop | recordCues |
| timeline.ts + script.ts segments (centraflow, martialops) | createTimeline; layoutSegments for duration-drafted scripts; writeArtifacts({ vtt, timingsJson, narrationScript }) for the extra files |
| showHighlight(page, { testId }) | focusOn(page, page.getByTestId(id), id, { moveCursor: false }) |
| warm context + recordVideo context | warmUpOffCamera + openRecordedPage |
| demo-overlay.ts say / writeSrt(path, totalMs) (immigration-app) | showNarration + createCaptionLog, or recordCues({ narration: true, captionEnd: "next-cue" }) |
| captureApiTraffic / clearNet | showNetworkActivity / clearNetworkActivity |
| scroll glide helper, slowMo | glideScroll, config use.launchOptions.slowMo |
| code-view.ts showCode (immigration-app) | showCode (refuses .env files) |
Only migrate on a new or revised script. A re-capture of an already-ingested demo must reproduce its cue spans for variant adoption, and moving to different pacing shifts them.
Rehearsing: DEMO_PACE
cueDurationMs multiplies every paced dwell by DEMO_PACE (default 1). Run
DEMO_PACE=0.1 DEMO_OUTPUT_DIR=/tmp/rehearse to check locators and assertions
in seconds. Never keep a rehearsal take.
The SRT and video must come from the same take
Re-recording shifts every cue. Never pair a .srt from one take with a
.webm from another — it reads as a subtle sync bug, and nothing here can
detect it.
Developing
pnpm test (unit), pnpm typecheck, and pnpm test:e2e, which builds the
package and records two short real-browser takes against it.
