talktrack 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":["mkdtemp","join","tmpdir","writeFile","rm","mkdtemp","join","tmpdir","rm","join","mkdir","chromium"],"sources":["../src/human-path.ts","../src/timeline.ts","../src/cursor.ts","../src/bands.ts","../src/filter-graph.ts","../src/ffmpeg.ts","../src/frame.ts","../src/test-adapter.ts","../src/standalone.ts"],"sourcesContent":["// The pure geometry behind the cursor's motion, split out from cursor.ts so it is unit-testable with no browser involved: a human moving a mouse doesn't teleport in a straight line at constant speed. Motion eases in and out, tends to bow slightly off the direct line, sometimes overshoots a touch on longer moves before settling, and has small continuous noise throughout.\n\n/** A point in viewport pixel coordinates. */\nexport interface Point {\n x: number;\n y: number;\n}\n\n/** The fewest points a glide is ever broken into: a short nudge is never reduced to a single frame. */\nexport const MIN_PATH_STEPS = 8;\n\n/** The most points a glide is ever broken into: a cross-viewport move shouldn't take forever. */\nexport const MAX_PATH_STEPS = 34;\n\n/**\n * Uniform noise in -spread..spread, used for micro-jitter along the path.\n */\nexport function jitter(spread: number): number {\n // Centres Math.random()'s 0..1 output on zero before scaling to -spread..spread.\n const RANDOM_UNIT_CENTER = 0.5;\n\n return (Math.random() - RANDOM_UNIT_CENTER) * 2 * spread;\n}\n\n/**\n * Builds the human-feeling path between two points as a list of intermediate points; the caller paces them out over real wall-clock time so a screen recording actually shows the glide. The last point is always exactly `to`, regardless of jitter or overshoot.\n */\nexport function buildHumanPath(\n from: Readonly<Point>,\n to: Readonly<Point>,\n): Point[] {\n const dx = to.x - from.x;\n const dy = to.y - from.y;\n const distance = Math.hypot(dx, dy);\n if (distance < 2) return [to];\n\n // More steps for longer distances, bounded by MIN_PATH_STEPS and MAX_PATH_STEPS above. Roughly one path point per this many pixels of travel.\n const PATH_STEP_DISTANCE_PX = 14;\n const steps = Math.max(\n MIN_PATH_STEPS,\n Math.min(MAX_PATH_STEPS, Math.round(distance / PATH_STEP_DISTANCE_PX)),\n );\n // Perpendicular unit vector, for the mid-path bow.\n const perpX = -dy / distance;\n const perpY = dx / distance;\n const MAX_BOW_MAGNITUDE_PX = 40;\n // The bow scales with distance, up to the cap above.\n const BOW_MAGNITUDE_RATIO = 0.12;\n // Coin flip: bow left or right of the direct line.\n const BOW_DIRECTION_PROBABILITY = 0.5;\n const bowMagnitude =\n Math.min(MAX_BOW_MAGNITUDE_PX, distance * BOW_MAGNITUDE_RATIO) *\n (Math.random() < BOW_DIRECTION_PROBABILITY ? 1 : -1);\n // Only longer moves are eligible to overshoot.\n const OVERSHOOT_MIN_DISTANCE_PX = 180;\n const OVERSHOOT_PROBABILITY = 0.6;\n const overshoot =\n distance > OVERSHOOT_MIN_DISTANCE_PX &&\n Math.random() < OVERSHOOT_PROBABILITY;\n const OVERSHOOT_MIN_PX = 6;\n const OVERSHOOT_RANDOM_RANGE_PX = 10;\n const overshootPx = overshoot\n ? OVERSHOOT_MIN_PX + Math.random() * OVERSHOOT_RANDOM_RANGE_PX\n : 0;\n // Fraction of the path where the overshoot begins, and where it has fully settled.\n const OVERSHOOT_WINDOW_START_T = 0.8;\n const OVERSHOOT_WINDOW_END_T = 0.97;\n // OVERSHOOT_WINDOW_END_T minus OVERSHOOT_WINDOW_START_T, used to normalise t across the window.\n const OVERSHOOT_WINDOW_WIDTH_T = 0.17;\n const PATH_POINT_JITTER_PX = 0.6;\n\n const path: Point[] = [];\n for (let i = 1; i <= steps; i++) {\n const t = i / steps;\n // Accelerate away from the start, decelerate into the target; the curve itself is easeInOutCubic below.\n const eased = easeInOutCubic(t);\n // The bow peaks at the path's midpoint and fades to zero at both ends.\n const bow = Math.sin(t * Math.PI) * bowMagnitude;\n let px = from.x + dx * eased + perpX * bow;\n let py = from.y + dy * eased + perpY * bow;\n if (\n overshoot &&\n t > OVERSHOOT_WINDOW_START_T &&\n t < OVERSHOOT_WINDOW_END_T\n ) {\n // 0 -> 1 across the overshoot window.\n const overshootT =\n (t - OVERSHOOT_WINDOW_START_T) / OVERSHOOT_WINDOW_WIDTH_T;\n // Out and back.\n const settle = Math.sin(overshootT * Math.PI);\n px += (dx / distance) * overshootPx * settle;\n py += (dy / distance) * overshootPx * settle;\n }\n path.push({\n x: px + jitter(PATH_POINT_JITTER_PX),\n y: py + jitter(PATH_POINT_JITTER_PX),\n });\n }\n // Land exactly on target regardless of jitter/overshoot.\n path[path.length - 1] = to;\n\n return path;\n}\n\n/**\n * Ease-in-out cubic, shared with naturalScrollIntoView's own identical curve: a real scroll (wheel or trackpad) accelerates away from rest and decelerates into rest, it doesn't move at constant speed.\n */\nexport function easeInOutCubic(t: number): number {\n // Fraction of progress where the curve switches from accelerating to decelerating.\n const EASE_MIDPOINT_T = 0.5;\n // Scales the accelerating half's t^3 term.\n const EASE_IN_CUBIC_SCALE = 4;\n // Coefficient of t inside the decelerating half's inner expression, and the exponent applied to that inner expression.\n const EASE_OUT_LINEAR_COEFFICIENT = -2;\n const EASE_OUT_CUBIC_EXPONENT = 3;\n\n return t < EASE_MIDPOINT_T\n ? EASE_IN_CUBIC_SCALE * t * t * t\n : 1 -\n Math.pow(EASE_OUT_LINEAR_COEFFICIENT * t + 2, EASE_OUT_CUBIC_EXPONENT) /\n 2;\n}\n","import { type Page } from \"@playwright/test\";\n\n// The narration timeline behind the footer band: each showCaption/hideCaption call is timestamped against the recording's own start, and the framing pass later turns that event list into \"this text was on screen from here to here\" overlay windows. Keeping it as per-page WeakMap state (rather than threading it through every helper signature) is what lets both the test-runner adapter and the standalone runner share one helper surface.\n\nexport interface CaptionEvent {\n atMs: number;\n text: string | null;\n}\n\nexport interface RecordingTimeline {\n startedAt: number;\n events: CaptionEvent[];\n}\n\n// Per-page recording start time (t0) and narration timeline, keyed the way cursor positions are (see cursor.ts): per-page state that must survive across many helper calls without being threaded through every function signature.\nconst recordingStartedAt = new WeakMap<Page, number>();\nconst captionTimeline = new WeakMap<Page, CaptionEvent[]>();\n\n/**\n * Called once, as early as possible (installCursor's own first line). First call wins, so a demo that navigates before installing the overlay doesn't shift t0 later than the recording's own real start.\n */\nexport function markRecordingStart(page: Page): void {\n if (!recordingStartedAt.has(page)) recordingStartedAt.set(page, Date.now());\n}\n\n/**\n * Appends one narration event for the page: a string shows that caption from now, null hides whatever is showing. Called by showCaption/hideCaption; direct use is only for advanced driving.\n */\nexport function recordCaptionEvent(page: Page, text: string | null): void {\n const startedAt = recordingStartedAt.get(page) ?? Date.now();\n const events = captionTimeline.get(page) ?? [];\n events.push({ atMs: Date.now() - startedAt, text });\n captionTimeline.set(page, events);\n}\n\n/**\n * Snapshot for the framing pass. Read after the context has closed is fine: the map is keyed by the Page object itself, which outlives its browser context.\n */\nexport function getRecordingTimeline(page: Page): RecordingTimeline {\n return {\n startedAt: recordingStartedAt.get(page) ?? Date.now(),\n events: captionTimeline.get(page) ?? [],\n };\n}\n","import { type Locator, type Page } from \"@playwright/test\";\nimport { buildHumanPath, easeInOutCubic, type Point } from \"./human-path\";\nimport { markRecordingStart, recordCaptionEvent } from \"./timeline\";\n\n// Playwright drives the DOM directly and never moves a visible pointer, so a screen recording of an automated run shows silent jump-cuts with no indication of what was clicked or how. This installs a synthetic cursor overlay (a dot that tracks real `mousemove` events, animates on click, and persists its position across navigations instead of resetting to the corner), glides the cursor along a slightly curved, eased, jittered path rather than a robotic straight-line teleport, and exposes moveAndClick/fillVisibly/gotoWithCursor/showCaption/hideCaption helpers built on top, so recordings made with them read as a person driving the UI with someone narrating over their shoulder, not a silent script. Narration itself is not an in-page overlay: showCaption/hideCaption below timestamp the narration text against the recording's own start time (see ./timeline), and the actual banner is composited into a dedicated band below the recorded app window during post-processing (see ./frame for why an in-page overlay can't produce that layout on its own).\n\n// TALKTRACK_OVERLAY_INIT runs inside the page (via page.evaluate/addInitScript), so it attaches its handles to the real browser `window` rather than returning them. This augmentation is what lets moveAndClick below call window.__talktrackPress directly, with no `as unknown as` cast.\ndeclare global {\n interface Window {\n __talktrackPress?: (x: number, y: number) => void;\n __talktrackRelease?: () => void;\n }\n}\n\nconst TALKTRACK_OVERLAY_INIT = () => {\n if (document.getElementById(\"__talktrack_cursor__\")) return;\n const style = document.createElement(\"style\");\n style.textContent = `\n #__talktrack_cursor__ {\n position: fixed; top: 0; left: 0; width: 22px; height: 22px;\n border-radius: 50%; background: rgba(220,38,38,0.88);\n border: 2px solid white; box-shadow: 0 1px 5px rgba(0,0,0,0.6);\n pointer-events: none; z-index: 2147483647; transform: translate(-50%, -50%) scale(1);\n transition: transform 0.12s ease-out, background-color 0.12s ease-out;\n }\n #__talktrack_cursor__.__talktrack_pressed__ {\n transform: translate(-50%, -50%) scale(0.55);\n background: rgba(153,27,27,0.95);\n }\n .__talktrack_ripple__ {\n position: fixed; width: 14px; height: 14px; border-radius: 50%;\n border: 3px solid rgba(220,38,38,0.9); pointer-events: none; z-index: 2147483647;\n transform: translate(-50%, -50%); animation: __talktrack_ripple_anim__ 0.55s ease-out forwards;\n }\n @keyframes __talktrack_ripple_anim__ {\n from { width: 14px; height: 14px; opacity: 1; }\n to { width: 58px; height: 58px; opacity: 0; }\n }\n `;\n document.head.appendChild(style);\n const cursor = document.createElement(\"div\");\n cursor.id = \"__talktrack_cursor__\";\n document.body.appendChild(cursor);\n window.addEventListener(\n \"mousemove\",\n (e) => {\n cursor.style.left = `${String(e.clientX)}px`;\n cursor.style.top = `${String(e.clientY)}px`;\n },\n true,\n );\n window.__talktrackPress = (x, y) => {\n cursor.classList.add(\"__talktrack_pressed__\");\n const ripple = document.createElement(\"div\");\n ripple.className = \"__talktrack_ripple__\";\n ripple.style.left = `${String(x)}px`;\n ripple.style.top = `${String(y)}px`;\n document.body.appendChild(ripple);\n const RIPPLE_REMOVE_DELAY_MS = 600;\n setTimeout(() => {\n ripple.remove();\n }, RIPPLE_REMOVE_DELAY_MS);\n };\n window.__talktrackRelease = () => {\n cursor.classList.remove(\"__talktrack_pressed__\");\n };\n};\n\n// Per-page last-known cursor position, so a navigation can restore it on the fresh document instead of the overlay resetting to the top-left corner, and so the next glide starts from where the cursor actually is rather than an arbitrary origin.\nconst currentPosition = new WeakMap<Page, Point>();\n\nasync function glideTo(page: Page, to: Readonly<Point>): Promise<void> {\n const from = currentPosition.get(page) ?? { x: to.x, y: to.y };\n const path = buildHumanPath(from, to);\n const MOVE_STEP_MIN_DELAY_MS = 12;\n const MOVE_STEP_DELAY_RANGE_MS = 10;\n for (const point of path) {\n await page.mouse.move(point.x, point.y);\n // ~12-22ms per step: roughly natural mouse-sampling pace, with enough jitter that consecutive glides don't all take an identical, obviously-scripted duration.\n await page.waitForTimeout(\n MOVE_STEP_MIN_DELAY_MS + Math.random() * MOVE_STEP_DELAY_RANGE_MS,\n );\n }\n currentPosition.set(page, to);\n}\n\n// page.evaluate right after a navigation races the document it targets (a redirect or a late client transition can swap the page out from under it), which throws \"Execution context was destroyed\". That's expected, not a real failure of this cosmetic helper, so it's tolerated here rather than aborting the whole recording over it.\nasync function tolerateNavigationRace<T>(\n promise: Readonly<Promise<T>>,\n): Promise<T | undefined> {\n return promise.catch(() => undefined);\n}\n\n/**\n * Playwright's own locator.scrollIntoViewIfNeeded() is a synchronous DOM scrollIntoView: an instant jump-cut in a recording, not something a person watching would recognise as scrolling at all. This drives a real sequence of wheel events instead, eased and paced like glideTo's own mouse path, so a recording shows the page actually gliding to the target rather than teleporting to it. Exported (not just used internally by moveAndClick below) so a demo can bring a target into view on its own, with no click attached (for example before narrating over a tall diagram whose own heading satisfies a \"content rendered\" assertion long before the diagram's body, the actual thing being narrated, has scrolled into the visible viewport).\n */\nexport async function naturalScrollIntoView(\n page: Page,\n locator: Readonly<Locator>,\n): Promise<void> {\n const viewport = page.viewportSize();\n if (!viewport) return;\n\n // Real wheel events hit-test against whatever scrollable element is under the CURRENT mouse position, not the target: moving the mouse toward the viewport's own centre first (rather than wherever the previous click left it) makes that hit-test land on the main scrollable content area rather than, say, a fixed sidebar nav the cursor happens to still be resting over.\n const hoverPoint = { x: viewport.width / 2, y: viewport.height / 2 };\n await tolerateNavigationRace(page.mouse.move(hoverPoint.x, hoverPoint.y));\n\n const box = await tolerateNavigationRace(locator.boundingBox());\n // Let the caller's own click flow surface a clear \"no bounding box\" error instead of failing silently here.\n if (!box) return;\n\n // A comfortable margin from the viewport edge, not the literal edge: a person scrolls until they can see the thing clearly, not until it's pixel-perfect at the boundary.\n const margin = 96;\n // Already comfortably in view: nothing to scroll.\n if (box.y >= margin && box.y + box.height <= viewport.height - margin) return;\n\n // Scroll enough to land the target roughly centred, not pinned to the very top, matching how a person actually scrolls toward something they want to interact with next.\n const totalDelta = box.y + box.height / 2 - viewport.height / 2;\n const MIN_SCROLL_DELTA_PX = 12;\n if (Math.abs(totalDelta) < MIN_SCROLL_DELTA_PX) return;\n\n const MIN_SCROLL_STEPS = 7;\n const MAX_SCROLL_STEPS = 26;\n const PIXELS_PER_SCROLL_STEP = 55;\n const steps = Math.max(\n MIN_SCROLL_STEPS,\n Math.min(\n MAX_SCROLL_STEPS,\n Math.round(Math.abs(totalDelta) / PIXELS_PER_SCROLL_STEP),\n ),\n );\n let deliveredSoFar = 0;\n const SCROLL_STEP_MIN_DELAY_MS = 14;\n const SCROLL_STEP_DELAY_RANGE_MS = 12;\n for (let i = 1; i <= steps; i++) {\n const eased = easeInOutCubic(i / steps);\n const target = totalDelta * eased;\n const stepDelta = target - deliveredSoFar;\n deliveredSoFar = target;\n await page.mouse.wheel(0, stepDelta);\n // ~14-26ms per step: a real scroll-wheel/trackpad delivers a burst of small deltas at roughly this cadence, with jitter so consecutive scrolls in the same recording don't all take an identical, obviously-scripted duration.\n await page.waitForTimeout(\n SCROLL_STEP_MIN_DELAY_MS + Math.random() * SCROLL_STEP_DELAY_RANGE_MS,\n );\n }\n // A brief settle beat after the scroll stops, the way a person pauses a moment before their next action rather than clicking mid-scroll.\n const SCROLL_SETTLE_MIN_DELAY_MS = 140;\n const SCROLL_SETTLE_DELAY_RANGE_MS = 120;\n await page.waitForTimeout(\n SCROLL_SETTLE_MIN_DELAY_MS + Math.random() * SCROLL_SETTLE_DELAY_RANGE_MS,\n );\n}\n\nasync function applyCursor(page: Page): Promise<void> {\n await tolerateNavigationRace(page.evaluate(TALKTRACK_OVERLAY_INIT));\n const pos = currentPosition.get(page);\n if (!pos) return;\n await tolerateNavigationRace(\n page.evaluate(\n ([x, y]) => {\n const cursor = document.getElementById(\"__talktrack_cursor__\");\n if (cursor) {\n cursor.style.left = `${String(x)}px`;\n cursor.style.top = `${String(y)}px`;\n }\n },\n [pos.x, pos.y] as const,\n ),\n );\n // Keeps Playwright's own virtual mouse position in sync with the overlay: otherwise the next glide would start from (0,0), not from where the cursor visually is.\n await tolerateNavigationRace(page.mouse.move(pos.x, pos.y, { steps: 1 }));\n}\n\n/**\n * Re-injects on every navigation (addInitScript) and once immediately for the current document, since addInitScript only applies to documents created after it's registered. Also marks the recording's own t0 (see ./timeline): every demo calls this as its very first action, which is the closest available proxy for \"when the video recording actually began\" without needing frame-exact precision a demo narration track doesn't need anyway.\n */\nexport async function installCursor(page: Page): Promise<void> {\n markRecordingStart(page);\n await page.addInitScript(TALKTRACK_OVERLAY_INIT);\n await applyCursor(page);\n}\n\n/**\n * Use in place of page.goto() for any navigation after installCursor(): restores the cursor to its last known state on the new document instead of letting it reset, so navigating doesn't read as a visual glitch in the recording.\n */\nexport async function gotoWithCursor(page: Page, url: string): Promise<void> {\n await page.goto(url);\n await applyCursor(page);\n}\n\n/**\n * Use in place of page.reload() for any navigation after installCursor(): restores the cursor to its last known state on the new document the same way gotoWithCursor does for page.goto(). A bare page.reload() in a demo that has already called installCursor() is the one navigation path the overlay can't cover on its own: addInitScript recreates the cursor DOM element on the fresh document (so it doesn't vanish outright), but nothing repositions it, so a reload leaves the cursor visibly pinned to the corner until the next moveAndClick's glide happens to move it (confirmed live as the cause of a cursor going missing mid-recording during a demo's own periodic safety-net reloads).\n */\nexport async function reloadWithCursor(page: Page): Promise<void> {\n await page.reload();\n await applyCursor(page);\n}\n\n/**\n * Sets (or updates) the narration shown in the footer band below the recorded app window; see ./timeline for how this timestamped event actually becomes an on-screen banner during post-processing. Pass null (or call hideCaption) to clear it between unrelated steps rather than leaving a stale caption shown throughout the gap.\n */\nexport function showCaption(page: Page, text: string): void {\n recordCaptionEvent(page, text);\n}\n\n/**\n * Clears the narration band (closes the window the last showCaption opened). Hide between unrelated steps rather than leaving a stale caption shown throughout the gap.\n */\nexport function hideCaption(page: Page): void {\n recordCaptionEvent(page, null);\n}\n\n// +/- ~25% jitter around a base duration, so repeated pauses in a recording don't all read as the exact same, obviously-scripted length.\nfunction humanPause(baseMs: number): number {\n const PAUSE_JITTER_MIN_MULTIPLIER = 0.75;\n const PAUSE_JITTER_RANGE_MULTIPLIER = 0.5;\n\n return Math.round(\n baseMs *\n (PAUSE_JITTER_MIN_MULTIPLIER +\n Math.random() * PAUSE_JITTER_RANGE_MULTIPLIER),\n );\n}\n\n/**\n * Moves the cursor humanly to the locator (scrolling it into view first), clicks it, and holds a jittered pause afterwards. Use in place of locator.click() in any recording.\n */\nexport async function moveAndClick(\n page: Page,\n locator: Readonly<Locator>,\n pauseMs = 250,\n): Promise<void> {\n await naturalScrollIntoView(page, locator);\n const box = await locator.boundingBox();\n if (!box)\n throw new Error(\"Could not resolve a bounding box for the click target\");\n // Aim slightly off dead-centre, the way a person rarely clicks a target's exact geometric middle, while staying comfortably inside the element's own bounds.\n const CLICK_TARGET_MIN_OFFSET_RATIO = 0.4;\n const CLICK_TARGET_OFFSET_RANGE_RATIO = 0.2;\n const target = {\n x:\n box.x +\n box.width *\n (CLICK_TARGET_MIN_OFFSET_RATIO +\n Math.random() * CLICK_TARGET_OFFSET_RANGE_RATIO),\n y:\n box.y +\n box.height *\n (CLICK_TARGET_MIN_OFFSET_RATIO +\n Math.random() * CLICK_TARGET_OFFSET_RANGE_RATIO),\n };\n await glideTo(page, target);\n const HOVER_BEFORE_CLICK_MS = 180;\n // A brief hover before committing to the click.\n await page.waitForTimeout(humanPause(HOVER_BEFORE_CLICK_MS));\n await tolerateNavigationRace(\n page.evaluate(([px, py]) => window.__talktrackPress?.(px, py), [\n target.x,\n target.y,\n ] as const),\n );\n // The actual click goes through locator.click(), not raw page.mouse.down()/up() at the coordinates captured above. Those coordinates are a snapshot from before the glide animation and hover pause, which together take several hundred milliseconds; long enough for an async-populated dropdown (options that load after mount) to re-render and shift the option out from under a stale coordinate. Raw mouse events have no actionability check the way locator.click() does, so a stale target silently clicks empty space (or the wrong element) instead of failing loudly or retargeting (confirmed live: this silently produced an unselected field with no error anywhere in the run). locator.click() re-resolves the element and waits for it to be stable/attached/visible immediately before dispatching, which is exactly the robustness raw coordinates can't provide; { force: true } is not used, so a genuinely non-actionable target still fails loudly rather than clicking through.\n await locator.click();\n const PAUSE_AFTER_CLICK_MS = 100;\n await page.waitForTimeout(humanPause(PAUSE_AFTER_CLICK_MS));\n await tolerateNavigationRace(\n page.evaluate(() => window.__talktrackRelease?.()),\n );\n // The click itself may have triggered a hard navigation (a plain <a> link, a form submit's redirect): that swaps in a fresh document, and addInitScript's TALKTRACK_OVERLAY_INIT creates a new cursor at its unset default (pinned to the top-left corner) until something restores it. A single check right after mouse.up() isn't reliable, because the click can take a beat to actually start navigating, so that one check can land before the new document even exists, restoring onto the page that's about to be torn down anyway rather than the one that replaces it. Reapplying a few times over the following stretch means whichever check lands after the real swap picks it back up almost immediately, instead of leaving it missing until some later action reveals it. For a soft navigation or no navigation at all, every extra check is a harmless no-op: the cursor already exists at the right position.\n const CURSOR_REAPPLY_ATTEMPTS = 4;\n const CURSOR_REAPPLY_INTERVAL_MS = 90;\n for (let i = 0; i < CURSOR_REAPPLY_ATTEMPTS; i++) {\n // currentPosition already holds `target`, set by glideTo above.\n await applyCursor(page);\n await page.waitForTimeout(CURSOR_REAPPLY_INTERVAL_MS);\n }\n await page.waitForTimeout(humanPause(pauseMs));\n}\n\n// Per-character delay for typeCharByChar, centred around a real, fast-but-plausible typing speed (~45ms/char average is roughly 210 characters/minute, comfortably within normal touch-typing range), with real per-character variance rather than a single fixed delay, plus a slightly longer beat after a space (the natural word-boundary pause) and an occasional, longer \"thinking\" pause mid-string, the way someone typing a value they're actively composing (a path, a flag list) rather than reciting from memory does.\nasync function typeCharByChar(page: Page, text: string): Promise<void> {\n const CHAR_TYPE_MIN_DELAY_MS = 28;\n const CHAR_TYPE_DELAY_RANGE_MS = 55;\n const SPACE_PAUSE_MIN_DELAY_MS = 40;\n const SPACE_PAUSE_DELAY_RANGE_MS = 60;\n const THINKING_PAUSE_PROBABILITY = 0.04;\n const THINKING_PAUSE_MIN_DELAY_MS = 200;\n const THINKING_PAUSE_DELAY_RANGE_MS = 400;\n for (const ch of text) {\n await page.keyboard.type(ch);\n let delay =\n CHAR_TYPE_MIN_DELAY_MS + Math.random() * CHAR_TYPE_DELAY_RANGE_MS;\n if (ch === \" \")\n delay +=\n SPACE_PAUSE_MIN_DELAY_MS + Math.random() * SPACE_PAUSE_DELAY_RANGE_MS;\n // An occasional longer \"thinking\" pause.\n if (Math.random() < THINKING_PAUSE_PROBABILITY)\n delay +=\n THINKING_PAUSE_MIN_DELAY_MS +\n Math.random() * THINKING_PAUSE_DELAY_RANGE_MS;\n await page.waitForTimeout(delay);\n }\n}\n\n/**\n * Clicks into the locator and types the text character by character at human speed, so a recording shows the value appearing as it's typed rather than arriving at once.\n */\nexport async function fillVisibly(\n page: Page,\n locator: Readonly<Locator>,\n text: string,\n pauseMs = 300,\n): Promise<void> {\n const PRE_FILL_CLICK_PAUSE_MS = 150;\n await moveAndClick(page, locator, humanPause(PRE_FILL_CLICK_PAUSE_MS));\n // Clears any existing value in one step: clearing isn't the part worth watching character by character, typing the new value is.\n await locator.fill(\"\");\n await typeCharByChar(page, text);\n await page.waitForTimeout(humanPause(pauseMs));\n}\n\n/**\n * Exported so demos can pace their own non-interaction \"reading\" pauses (for example holding on a state that just changed) with the same jittered, human-feeling timing.\n */\nexport function humanBeatMs(baseMs: number): number {\n return humanPause(baseMs);\n}\n\n/**\n * Waits a jittered pause of roughly `ms` milliseconds on the page, for \"reading\" beats between interactions.\n */\nexport async function beat(page: Page, ms = 600): Promise<void> {\n await page.waitForTimeout(humanBeatMs(ms));\n}\n\n// Replaces the two shapes a demo would otherwise hand-roll around showCaption/hideCaption: caption-then-fixed-sleep-then-action (the sleep is unrelated to any page state, so the action can start before or long after the caption's own hold ends) and assert-fully-then-caption (no lead-in at all, since the caption only ever appears once its claim is already true). Neither ties a caption's own on-screen duration to genuine page-state readiness.\n//\n// showCaption fires immediately, so the caption is up before `settle` (whatever assertion, poll, or action already proves the caption's own claim) has resolved: a real lead-in, not a guess. `settle` and a minimum `leadInMs` floor run concurrently, so a `settle` that resolves in under `leadInMs` still gets a caption that's been up for at least that long, and a `settle` that takes minutes (a capacity poll, say) keeps the caption up for the whole genuine wait rather than a short fixed hold that's already elapsed by the time anything real happens. Once `settle` resolves, the caption holds for `dwellMs` (a real \"look at this\" pause) before hiding: exactly the digest window a fixed-hold constant was always trying to give, just now added on top of true settle time rather than instead of it.\nconst NARRATE_SCENE_DEFAULT_LEAD_IN_MS = 800;\nconst NARRATE_SCENE_DEFAULT_DWELL_MS = 1200;\n/**\n * Shows a caption, waits for a real page-state condition to settle while the caption stays up, holds a digest dwell, then hides it. The caption's on-screen duration is tied to genuine readiness (the `settle` promise) rather than a fixed sleep: `leadInMs` is a minimum floor running concurrently with `settle`, and `dwellMs` is the \"look at this\" hold after it resolves.\n */\nexport async function narrateScene<T>(\n page: Page,\n text: string,\n settle: () => Promise<T>,\n opts?: { leadInMs?: number; dwellMs?: number },\n): Promise<T> {\n const leadInMs = opts?.leadInMs ?? NARRATE_SCENE_DEFAULT_LEAD_IN_MS;\n const dwellMs = opts?.dwellMs ?? NARRATE_SCENE_DEFAULT_DWELL_MS;\n showCaption(page, text);\n const [result] = await Promise.all([settle(), beat(page, leadInMs)]);\n await beat(page, dwellMs);\n hideCaption(page);\n\n return result;\n}\n","import { type BrowserContext } from \"@playwright/test\";\n\n/**\n * Styling of the header and footer bands composited around the recorded app window. The defaults are deliberately unopinionated (a dark neutral band with the system font stack); override any subset through the `bands` option on finalizeFramedRecording/recordDemo to match the application being recorded.\n */\nexport interface BandStyle {\n headerHeightPx: number;\n footerHeightPx: number;\n background: string;\n textColor: string;\n fontStack: string;\n headerFontSizePx: number;\n footerFontSizePx: number;\n}\n\n/** The band look talktrack frames recordings with when no `bands` override is given. */\nexport const defaultBandStyle: BandStyle = {\n headerHeightPx: 48,\n footerHeightPx: 96,\n background: \"#111827\",\n textColor: \"#f8fafc\",\n fontStack:\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif',\n headerFontSizePx: 18,\n footerFontSizePx: 20,\n};\n\n/**\n * Merges a caller's partial band overrides over {@link defaultBandStyle}; an absent overrides object yields the defaults unchanged.\n */\nexport function resolveBandStyle(overrides?: Partial<BandStyle>): BandStyle {\n return { ...defaultBandStyle, ...overrides };\n}\n\n/**\n * Escapes the five characters HTML treats as markup, so arbitrary narration text can be embedded in the band page's template safely.\n */\nexport function escapeHtml(text: string): string {\n return text\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\n/** Everything one composited band needs: its pixel size, its text, and the font size to render that text at. */\nexport interface TextBandSpec {\n width: number;\n height: number;\n fontSizePx: number;\n text: string;\n}\n\n/**\n * Renders one band as a PNG. Takes a dedicated throwaway BrowserContext, not the demo's own page/context: a context recording video (recordVideo or Playwright Test's video option) records every page opened within it, so rendering the bands through the demo's own context would leave junk video files alongside the real recording. A fresh context with no video option set (off by default) avoids that entirely.\n *\n * Text is rendered as an ordinary PNG screenshot of that throwaway page rather than via ffmpeg's drawtext filter, since drawtext needs libfreetype/libfontconfig support that common ffmpeg builds (a plain Homebrew install, for instance) don't ship. Rendering text via the same browser already driving the recording sidesteps that dependency and gives exact font parity with the recorded application for free.\n */\nexport async function renderTextBand(\n bandContext: BrowserContext,\n style: Readonly<BandStyle>,\n band: Readonly<TextBandSpec>,\n): Promise<Buffer> {\n const bandPage = await bandContext.newPage();\n try {\n await bandPage.setViewportSize({\n width: band.width,\n height: band.height,\n });\n await bandPage.setContent(`<!doctype html>\n<html><head><style>\n html, body { margin: 0; width: ${String(band.width)}px; height: ${String(band.height)}px; background: ${style.background}; display: flex; align-items: center; justify-content: center; }\n p { margin: 0; padding: 0 28px; max-width: 100%; box-sizing: border-box; color: ${style.textColor}; font: 500 ${String(band.fontSizePx)}px/1.35 ${style.fontStack}; text-align: center; overflow-wrap: break-word; }\n</style></head>\n<body><p>${escapeHtml(band.text)}</p></body></html>`);\n\n return await bandPage.screenshot({ type: \"png\" });\n } finally {\n await bandPage.close();\n }\n}\n","import type { BandStyle } from \"./bands\";\nimport type { CaptionEvent } from \"./timeline\";\n\n// The pure half of the framing pass: turning a caption timeline into (a) concrete on-screen windows and (b) an ffmpeg filter graph, with no browser, no filesystem and no ffmpeg involved. Everything here is unit-testable; everything that touches the outside world lives in frame.ts.\n\nexport interface CaptionWindow {\n text: string;\n startMs: number;\n endMs: number;\n}\n\n/**\n * A window opens at each show event and closes at whatever event follows it (an explicit hide, or a subsequent show replacing the line), or at the video's own end if nothing follows. A hide event only ever closes the preceding show's window: it opens no window of its own.\n */\nexport function captionWindows(\n events: readonly CaptionEvent[],\n videoDurationMs: number,\n): CaptionWindow[] {\n const windows: CaptionWindow[] = [];\n for (const [i, event] of events.entries()) {\n if (event.text === null) continue;\n const nextEvent = events[i + 1];\n windows.push({\n text: event.text,\n startMs: event.atMs,\n endMs: nextEvent === undefined ? videoDurationMs : nextEvent.atMs,\n });\n }\n\n return windows;\n}\n\nconst MS_PER_SECOND = 1000;\n// ffmpeg's between(t,start,end) expression accepts fractional seconds; millisecond precision is more than enough for a narration cue.\nconst FFMPEG_TIME_DECIMAL_PLACES = 3;\n\n/** The ffmpeg inputs and filter graph one framing pass drives: how many -i inputs the command takes, the graph string itself, and the label its final overlay writes. */\nexport interface FramePlan {\n inputCount: number;\n filterGraph: string;\n finalLabel: string;\n}\n\n/**\n * Input 0 is the raw recording, input 1 the header band PNG, inputs 2.. the caption band PNGs in window order. The graph pads input 0 onto a taller canvas of band colour (header band above, footer band below), overlays the header at the top, then chains one time-gated overlay per caption window onto the footer region.\n */\nexport function buildFramePlan(\n viewport: Readonly<{ width: number; height: number }>,\n style: Readonly<BandStyle>,\n windows: readonly CaptionWindow[],\n): FramePlan {\n const padHeight =\n viewport.height + style.headerHeightPx + style.footerHeightPx;\n let filterGraph = `[0:v]pad=${String(viewport.width)}:${String(padHeight)}:0:${String(style.headerHeightPx)}:color=${style.background}[bg];[bg][1:v]overlay=0:0[v0]`;\n let lastLabel = \"v0\";\n\n let inputIndex = 2;\n for (const [i, window] of windows.entries()) {\n const startS = (window.startMs / MS_PER_SECOND).toFixed(\n FFMPEG_TIME_DECIMAL_PLACES,\n );\n const endS = (window.endMs / MS_PER_SECOND).toFixed(\n FFMPEG_TIME_DECIMAL_PLACES,\n );\n const nextLabel = `v${String(i + 1)}`;\n filterGraph += `;[${lastLabel}][${String(inputIndex)}:v]overlay=0:${String(style.headerHeightPx + viewport.height)}:enable='between(t,${startS},${endS})'[${nextLabel}]`;\n lastLabel = nextLabel;\n inputIndex++;\n }\n\n return { inputCount: inputIndex, filterGraph, finalLabel: lastLabel };\n}\n","import { execFile } from \"node:child_process\";\n\n// A hand-written wrapper rather than util.promisify(execFile): execFile's own overloaded callback signature (its non-callback form returns a live ChildProcess, distinct from the callback form promisify actually targets) trips @typescript-eslint/strict-void-return on the promisified result. This sidesteps the overload ambiguity entirely rather than suppressing the lint rule.\nasync function runCommand(\n command: string,\n args: readonly string[],\n label: string,\n): Promise<string> {\n return await new Promise<string>((resolve, reject) => {\n execFile(command, args, (error, stdout) => {\n // ExecFileException is a mapped/Omit-derived type over NodeJS.ErrnoException, not a literal `extends Error` class chain: wrapping it as the `cause` of a real Error satisfies the rejection-must-be-an-Error rule and preserves the original message/stack.\n if (error) {\n // ENOENT gets its own message because \"spawn ffmpeg ENOENT\" names the wrong thing to fix: the missing piece is the documented external binary, not the spawn call.\n const reason =\n error.code === \"ENOENT\"\n ? `${label} not found on PATH. talktrack drives the external ${label} binary for video post-processing; install it (for example \"brew install ffmpeg\" or \"apt-get install ffmpeg\") and retry.`\n : `${label} failed: ${error.message}`;\n reject(new Error(reason, { cause: error }));\n } else {\n resolve(stdout);\n }\n });\n });\n}\n\n/**\n * Runs the external ffmpeg binary with the given arguments. Rejects with an install hint rather than a bare spawn error when ffmpeg is not on PATH.\n */\nexport async function runFfmpeg(args: readonly string[]): Promise<string> {\n return await runCommand(\"ffmpeg\", args, \"ffmpeg\");\n}\n\n/** The dimensions and duration ffprobe reports for a video file. */\nexport interface VideoFacts {\n durationMs: number;\n width: number;\n height: number;\n}\n\n/**\n * Throws unless the recorded video's pixel size equals the viewport the bands are laid out against. Playwright records a smaller video than the viewport unless `video.size` is set to match (its default scales the recording to fit inside 800x800), and the framing pass would then pad that smaller picture onto a canvas sized for the full viewport: a silently mis-laid-out result that still looks like a successful run.\n */\nexport function assertVideoMatchesViewport(\n facts: Readonly<VideoFacts>,\n viewport: Readonly<{ width: number; height: number }>,\n videoPath: string,\n): void {\n if (facts.width === viewport.width && facts.height === viewport.height)\n return;\n throw new Error(\n `talktrack cannot frame ${videoPath}: the recording is ${String(facts.width)}x${String(facts.height)} but the viewport is ${String(viewport.width)}x${String(viewport.height)}. Set the recording size to the viewport size (Playwright Test: video: { mode: \"on\", size }; recordDemo does this itself) so the bands line up with the picture.`,\n );\n}\n\n/**\n * Narrows a parsed JSON value to a plain object, per the house unknown-handling rule: never an assertion, always a guard.\n */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * Probes a video file with ffprobe for its pixel dimensions and duration in milliseconds. Any output shape other than the documented stream+format JSON object rejects with the path and what was wrong.\n */\nexport async function probeVideo(videoPath: string): Promise<VideoFacts> {\n const stdout = await runCommand(\n \"ffprobe\",\n [\n \"-v\",\n \"error\",\n \"-select_streams\",\n \"v:0\",\n \"-show_entries\",\n \"stream=width,height:format=duration\",\n \"-of\",\n \"json\",\n videoPath,\n ],\n \"ffprobe\",\n );\n // ffprobe -of json prints { streams: [{ width, height }], format: { duration } } with the duration as a decimal-seconds string.\n const parsed: unknown = JSON.parse(stdout);\n if (!isRecord(parsed)) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: not a JSON object`,\n );\n }\n const streams = parsed.streams;\n const format = parsed.format;\n if (!Array.isArray(streams) || !isRecord(format)) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: streams is not an array or format is not an object`,\n );\n }\n const firstStream: unknown = streams[0];\n if (!isRecord(firstStream)) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: first stream is not an object`,\n );\n }\n const width = firstStream.width;\n const height = firstStream.height;\n const durationRaw = format.duration;\n if (\n typeof width !== \"number\" ||\n typeof height !== \"number\" ||\n typeof durationRaw !== \"string\"\n ) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: stream width/height or format.duration has the wrong type`,\n );\n }\n const MS_PER_SECOND = 1000;\n\n return {\n durationMs: Math.round(Number.parseFloat(durationRaw) * MS_PER_SECOND),\n width,\n height,\n };\n}\n","import { execFile } from \"node:child_process\";\nimport { mkdtemp, rm, writeFile } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { type Browser, type Page } from \"@playwright/test\";\nimport { renderTextBand, resolveBandStyle, type BandStyle } from \"./bands\";\nimport { assertVideoMatchesViewport, probeVideo, runFfmpeg } from \"./ffmpeg\";\nimport { buildFramePlan, captionWindows } from \"./filter-graph\";\nimport { getRecordingTimeline } from \"./timeline\";\n\n// The framing pass. Playwright's video recorder captures exactly the browser viewport's own pixels, so an in-page \"narrate below the app\" or \"commit/date above the app\" banner is a contradiction in terms: anything the page itself renders is, by definition, inside the recorded viewport. This module produces that layout as a genuinely separate post-processing pass instead: the raw recording stays exactly the app's own window, and frameRecording pads a taller canvas around it (a static header band and a footer band that shows each narration line only for the real span of time it was actually on screen during the run). ffmpeg's own pad+overlay filters do the compositing.\n\n/**\n * Everything the framing pass needs. Both adapters (the Playwright Test one and the standalone runner) reduce to this: a flushed raw recording, the viewport it was recorded at, a live browser to render band PNGs through, and where to write the framed copy.\n */\nexport interface FrameRecordingInput {\n // Timeline source. Read after the context has closed is fine: the state is keyed by the Page object itself, which outlives its context.\n page: Page;\n // Path to the raw recording, already flushed to disk (Playwright Test flushes it before afterEach; the standalone runner closes the context first).\n videoPath: string;\n // The recorded viewport, captured before the context closed (page.viewportSize() throws on a closed page).\n viewport: { width: number; height: number };\n // Band PNGs are rendered through a throwaway context on this browser (see renderTextBand).\n browser: Browser;\n headerText: string | Promise<string>;\n framedPath: string;\n bands?: Partial<BandStyle>;\n}\n\n/**\n * The default header band: the word \"commit\", the short sha, a middot, and the ISO recording-start timestamp, or just the timestamp when not inside a git working tree (demos run from anywhere, not only repos).\n */\nexport async function defaultHeaderText(startedAtMs: number): Promise<string> {\n const createdAtIso = new Date(startedAtMs).toISOString();\n try {\n const sha = await new Promise<string>((resolve, reject) => {\n execFile(\"git\", [\"rev-parse\", \"--short\", \"HEAD\"], (error, stdout) => {\n if (error) reject(new Error(error.message, { cause: error }));\n else resolve(stdout.trim());\n });\n });\n\n return `commit ${sha} · ${createdAtIso}`;\n } catch {\n return createdAtIso;\n }\n}\n\n/**\n * Frames one raw recording: renders the header and caption band PNGs, probes the raw video's duration, and composites everything into {@link FrameRecordingInput.framedPath} with ffmpeg. Returns that path.\n */\nexport async function frameRecording(\n input: Readonly<FrameRecordingInput>,\n): Promise<string> {\n const style = resolveBandStyle(input.bands);\n const timeline = getRecordingTimeline(input.page);\n const facts = await probeVideo(input.videoPath);\n assertVideoMatchesViewport(facts, input.viewport, input.videoPath);\n const windows = captionWindows(timeline.events, facts.durationMs);\n const plan = buildFramePlan(input.viewport, style, windows);\n\n // Deliberately separate from the demo's own recording context (see renderTextBand's own comment for why).\n const bandContext = await input.browser.newContext();\n const workDir = await mkdtemp(join(tmpdir(), \"talktrack-frame-\"));\n try {\n const inputArgs: string[] = [\"-i\", input.videoPath];\n const headerPngPath = join(workDir, \"header.png\");\n await writeFile(\n headerPngPath,\n await renderTextBand(bandContext, style, {\n width: input.viewport.width,\n height: style.headerHeightPx,\n fontSizePx: style.headerFontSizePx,\n text: await input.headerText,\n }),\n );\n inputArgs.push(\"-i\", headerPngPath);\n for (const [i, window] of windows.entries()) {\n const captionPngPath = join(workDir, `caption-${String(i)}.png`);\n await writeFile(\n captionPngPath,\n await renderTextBand(bandContext, style, {\n width: input.viewport.width,\n height: style.footerHeightPx,\n fontSizePx: style.footerFontSizePx,\n text: window.text,\n }),\n );\n inputArgs.push(\"-i\", captionPngPath);\n }\n\n await runFfmpeg([\n \"-y\",\n ...inputArgs,\n \"-filter_complex\",\n plan.filterGraph,\n \"-map\",\n `[${plan.finalLabel}]`,\n \"-c:v\",\n \"libvpx-vp9\",\n \"-crf\",\n \"32\",\n \"-b:v\",\n \"0\",\n \"-deadline\",\n \"good\",\n \"-cpu-used\",\n \"3\",\n input.framedPath,\n ]);\n\n return input.framedPath;\n } finally {\n await bandContext.close();\n await rm(workDir, { recursive: true, force: true });\n }\n}\n","import { mkdtemp, rm } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { type Page, type TestInfo } from \"@playwright/test\";\nimport { type BandStyle } from \"./bands\";\nimport { defaultHeaderText, frameRecording } from \"./frame\";\nimport { getRecordingTimeline } from \"./timeline\";\n\n// The @playwright/test adapter. Call from afterEach: it closes the spec's page so Playwright finalises the video, frames the recording, and attaches the framed copy next to the raw one as a test attachment.\n\n/**\n * Optional overrides for {@link finalizeFramedRecording}: the header band's text, partial band styling, and where the framed copy is written (default: next to the raw video, with a -framed suffix).\n */\nexport interface FinalizeOptions {\n headerText?: string | Promise<string>;\n bands?: Partial<BandStyle>;\n framedPath?: string;\n}\n\n/**\n * Produces a taller, framed copy of the just-recorded spec video: the app window exactly as recorded, a static header above it, and a footer below it showing each narration line only while it was actually shown during the run. A no-op returning undefined (not a failure) when there's no video to frame at all (a spec run without video enabled) or no viewport to size bands against.\n *\n * Closes the page. Playwright only finalises a page's video once the page is closed, and an afterEach hook runs while the test's page is still open, so the recording on disk is incomplete until then: the adapter closes the page itself and waits for the finalised video (video.saveAs waits for exactly that) before reading it. The page is unusable afterwards, which is why this belongs in afterEach and not mid-test.\n */\nexport async function finalizeFramedRecording(\n page: Page,\n testInfo: TestInfo,\n opts?: FinalizeOptions,\n): Promise<string | undefined> {\n const video = page.video();\n if (!video) return undefined;\n const viewport = page.viewportSize();\n if (!viewport) return undefined;\n const browser = page.context().browser();\n if (!browser)\n throw new Error(\n \"talktrack cannot frame this recording: a persistent browser context has no separate Browser handle to render caption bands through\",\n );\n\n // Only used for naming the framed copy: path() reports where the video is being written without waiting for it to finish.\n const rawPath = await video.path();\n const framedPath =\n opts?.framedPath ?? rawPath.replace(/\\.webm$/, \"-framed.webm\");\n const headerText =\n opts?.headerText ?? defaultHeaderText(getRecordingTimeline(page).startedAt);\n\n const scratchDir = await mkdtemp(join(tmpdir(), \"talktrack-raw-\"));\n try {\n await page.close();\n const finalisedRawPath = join(scratchDir, \"raw.webm\");\n await video.saveAs(finalisedRawPath);\n\n await frameRecording({\n page,\n videoPath: finalisedRawPath,\n viewport,\n browser,\n headerText,\n framedPath,\n ...(opts?.bands ? { bands: opts.bands } : {}),\n });\n } finally {\n await rm(scratchDir, { recursive: true, force: true });\n }\n await testInfo.attach(\"framed-recording\", {\n path: framedPath,\n contentType: \"video/webm\",\n });\n\n return framedPath;\n}\n","import { mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport {\n type Browser,\n type BrowserContext,\n type BrowserContextOptions,\n type BrowserType,\n type LaunchOptions,\n type Locator,\n chromium,\n type Page,\n} from \"@playwright/test\";\nimport { type BandStyle } from \"./bands\";\nimport {\n beat,\n fillVisibly,\n gotoWithCursor,\n hideCaption,\n installCursor,\n moveAndClick,\n narrateScene,\n naturalScrollIntoView,\n reloadWithCursor,\n showCaption,\n} from \"./cursor\";\nimport { defaultHeaderText, frameRecording } from \"./frame\";\nimport { getRecordingTimeline } from \"./timeline\";\n\n// The standalone runner: everything installCursor/narrateScene give a @playwright/test spec, but from a plain script with no test runner involved. recordDemo launches (or borrows) a browser, opens a video-recording context, installs the cursor, and returns the same helper surface bound to that page; finish() closes the context (which flushes Playwright's webm), runs the framing pass, and returns the framed recording's path.\n\n/**\n * Everything recordDemo needs: the recorded viewport, where to start, where outputs go, and optional overrides for the browser, context, header band, and band styling.\n */\nexport interface RecordDemoOptions {\n // Recorded viewport. Playwright records exactly this rectangle; 16:9 ratios frame cleanly.\n viewport: { width: number; height: number };\n // Initial navigation; omit to drive the page entirely by hand.\n url?: string;\n // Where Playwright writes the raw recording and talktrack writes the framed one. Default: talktrack-output under the working directory.\n outputDir?: string;\n // Bring your own browser (shared across demos); by default one is launched per recordDemo call and closed by finish().\n browser?: Browser;\n // Which browser to launch when `browser` is not supplied. Default: chromium.\n browserType?: BrowserType<Browser>;\n launchOptions?: LaunchOptions;\n // Extra context options (locale, colorScheme, ...). recordVideo and viewport are owned by the runner.\n contextOptions?: Omit<BrowserContextOptions, \"recordVideo\" | \"viewport\">;\n headerText?: string | Promise<string>;\n bands?: Partial<BandStyle>;\n}\n\n/**\n * The bound helper surface recordDemo returns: the cursor/narration helpers a spec gets, wrapped around one recording page, plus {@link TalktrackSession.finish} to flush and frame the video.\n */\nexport interface TalktrackSession {\n readonly page: Page;\n readonly context: BrowserContext;\n moveAndClick: (locator: Readonly<Locator>, pauseMs?: number) => Promise<void>;\n fillVisibly: (\n locator: Readonly<Locator>,\n text: string,\n pauseMs?: number,\n ) => Promise<void>;\n naturalScrollIntoView: (locator: Readonly<Locator>) => Promise<void>;\n goto: (url: string) => Promise<void>;\n reload: () => Promise<void>;\n caption: (text: string) => void;\n hideCaption: () => void;\n narrateScene: <T>(\n text: string,\n settle: () => Promise<T>,\n opts?: { leadInMs?: number; dwellMs?: number },\n ) => Promise<T>;\n beat: (ms?: number) => Promise<void>;\n // Closes the recording context, frames the raw video, closes the browser if this session launched it, and returns the framed recording's path. Call exactly once, at the end.\n finish: () => Promise<string>;\n}\n\n/**\n * Starts one standalone demo recording: launches (or borrows) a browser, opens a video-recording context at the given viewport, installs the cursor, optionally navigates to `url`, and returns the session. Drive the page through the session's helpers, then `await session.finish()` once; it returns the framed recording's path.\n */\nexport async function recordDemo(\n name: string,\n opts: Readonly<RecordDemoOptions>,\n): Promise<TalktrackSession> {\n const outputDir = opts.outputDir ?? join(process.cwd(), \"talktrack-output\");\n await mkdir(outputDir, { recursive: true });\n\n const ownsBrowser = opts.browser === undefined;\n const browser =\n opts.browser ??\n (await (opts.browserType ?? chromium).launch(opts.launchOptions ?? {}));\n const context = await browser.newContext({\n ...opts.contextOptions,\n viewport: opts.viewport,\n recordVideo: { dir: outputDir, size: opts.viewport },\n });\n const page = await context.newPage();\n await installCursor(page);\n if (opts.url !== undefined) await gotoWithCursor(page, opts.url);\n\n const finish = async (): Promise<string> => {\n // Captured before close: both throw on a closed context/page, and the framing pass needs them after the video is flushed.\n const viewport = page.viewportSize();\n if (!viewport)\n throw new Error(\n \"talktrack could not read the viewport before closing the demo's context\",\n );\n const video = page.video();\n if (!video)\n throw new Error(\"talktrack's recording context produced no video handle\");\n\n const videoPathPromise = video.path();\n await context.close();\n const videoPath = await videoPathPromise;\n\n const framedPath = join(outputDir, `${name}-framed.webm`);\n const headerText =\n opts.headerText ??\n defaultHeaderText(getRecordingTimeline(page).startedAt);\n const framed = await frameRecording({\n page,\n videoPath,\n viewport,\n browser,\n headerText,\n framedPath,\n ...(opts.bands ? { bands: opts.bands } : {}),\n });\n if (ownsBrowser) await browser.close();\n\n return framed;\n };\n\n return {\n page,\n context,\n moveAndClick: async (locator, pauseMs) =>\n moveAndClick(page, locator, pauseMs),\n fillVisibly: async (locator, text, pauseMs) =>\n fillVisibly(page, locator, text, pauseMs),\n naturalScrollIntoView: async (locator) =>\n naturalScrollIntoView(page, locator),\n goto: async (url) => gotoWithCursor(page, url),\n reload: async () => reloadWithCursor(page),\n caption: (text) => {\n showCaption(page, text);\n },\n hideCaption: () => {\n hideCaption(page);\n },\n narrateScene: async (text, settle, narrateOpts) =>\n narrateScene(page, text, settle, narrateOpts),\n beat: async (ms) => beat(page, ms),\n finish,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["mkdtemp","join","tmpdir","writeFile","rm","mkdtemp","join","tmpdir","rm","join","mkdir","chromium"],"sources":["../src/human-path.ts","../src/timeline.ts","../src/cursor.ts","../src/bands.ts","../src/filter-graph.ts","../src/ffmpeg.ts","../src/frame.ts","../src/test-adapter.ts","../src/standalone.ts"],"sourcesContent":["// The pure geometry behind the cursor's motion, split out from cursor.ts so it is unit-testable with no browser involved: a human moving a mouse doesn't teleport in a straight line at constant speed. Motion eases in and out, tends to bow slightly off the direct line, sometimes overshoots a touch on longer moves before settling, and has small continuous noise throughout.\n\n/** A point in viewport pixel coordinates. */\nexport interface Point {\n x: number;\n y: number;\n}\n\n/** The fewest points a glide is ever broken into: a short nudge is never reduced to a single frame. */\nexport const MIN_PATH_STEPS = 8;\n\n/** The most points a glide is ever broken into: a cross-viewport move shouldn't take forever. */\nexport const MAX_PATH_STEPS = 34;\n\n/**\n * Uniform noise in -spread..spread, used for micro-jitter along the path.\n */\nexport function jitter(spread: number): number {\n // Centres Math.random()'s 0..1 output on zero before scaling to -spread..spread.\n const RANDOM_UNIT_CENTER = 0.5;\n\n return (Math.random() - RANDOM_UNIT_CENTER) * 2 * spread;\n}\n\n/**\n * Builds the human-feeling path between two points as a list of intermediate points; the caller paces them out over real wall-clock time so a screen recording actually shows the glide. The last point is always exactly `to`, regardless of jitter or overshoot.\n */\nexport function buildHumanPath(\n from: Readonly<Point>,\n to: Readonly<Point>,\n): Point[] {\n const dx = to.x - from.x;\n const dy = to.y - from.y;\n const distance = Math.hypot(dx, dy);\n if (distance < 2) return [to];\n\n // More steps for longer distances, bounded by MIN_PATH_STEPS and MAX_PATH_STEPS above. Roughly one path point per this many pixels of travel.\n const PATH_STEP_DISTANCE_PX = 14;\n const steps = Math.max(\n MIN_PATH_STEPS,\n Math.min(MAX_PATH_STEPS, Math.round(distance / PATH_STEP_DISTANCE_PX)),\n );\n // Perpendicular unit vector, for the mid-path bow.\n const perpX = -dy / distance;\n const perpY = dx / distance;\n const MAX_BOW_MAGNITUDE_PX = 40;\n // The bow scales with distance, up to the cap above.\n const BOW_MAGNITUDE_RATIO = 0.12;\n // Coin flip: bow left or right of the direct line.\n const BOW_DIRECTION_PROBABILITY = 0.5;\n const bowMagnitude =\n Math.min(MAX_BOW_MAGNITUDE_PX, distance * BOW_MAGNITUDE_RATIO) *\n (Math.random() < BOW_DIRECTION_PROBABILITY ? 1 : -1);\n // Only longer moves are eligible to overshoot.\n const OVERSHOOT_MIN_DISTANCE_PX = 180;\n const OVERSHOOT_PROBABILITY = 0.6;\n const overshoot =\n distance > OVERSHOOT_MIN_DISTANCE_PX &&\n Math.random() < OVERSHOOT_PROBABILITY;\n const OVERSHOOT_MIN_PX = 6;\n const OVERSHOOT_RANDOM_RANGE_PX = 10;\n const overshootPx = overshoot\n ? OVERSHOOT_MIN_PX + Math.random() * OVERSHOOT_RANDOM_RANGE_PX\n : 0;\n // Fraction of the path where the overshoot begins, and where it has fully settled.\n const OVERSHOOT_WINDOW_START_T = 0.8;\n const OVERSHOOT_WINDOW_END_T = 0.97;\n // OVERSHOOT_WINDOW_END_T minus OVERSHOOT_WINDOW_START_T, used to normalise t across the window.\n const OVERSHOOT_WINDOW_WIDTH_T = 0.17;\n const PATH_POINT_JITTER_PX = 0.6;\n\n const path: Point[] = [];\n for (let i = 1; i <= steps; i++) {\n const t = i / steps;\n // Accelerate away from the start, decelerate into the target; the curve itself is easeInOutCubic below.\n const eased = easeInOutCubic(t);\n // The bow peaks at the path's midpoint and fades to zero at both ends.\n const bow = Math.sin(t * Math.PI) * bowMagnitude;\n let px = from.x + dx * eased + perpX * bow;\n let py = from.y + dy * eased + perpY * bow;\n if (\n overshoot &&\n t > OVERSHOOT_WINDOW_START_T &&\n t < OVERSHOOT_WINDOW_END_T\n ) {\n // 0 -> 1 across the overshoot window.\n const overshootT =\n (t - OVERSHOOT_WINDOW_START_T) / OVERSHOOT_WINDOW_WIDTH_T;\n // Out and back.\n const settle = Math.sin(overshootT * Math.PI);\n px += (dx / distance) * overshootPx * settle;\n py += (dy / distance) * overshootPx * settle;\n }\n path.push({\n x: px + jitter(PATH_POINT_JITTER_PX),\n y: py + jitter(PATH_POINT_JITTER_PX),\n });\n }\n // Land exactly on target regardless of jitter/overshoot.\n path[path.length - 1] = to;\n\n return path;\n}\n\n/**\n * Ease-in-out cubic, shared with naturalScrollIntoView's own identical curve: a real scroll (wheel or trackpad) accelerates away from rest and decelerates into rest, it doesn't move at constant speed.\n */\nexport function easeInOutCubic(t: number): number {\n // Fraction of progress where the curve switches from accelerating to decelerating.\n const EASE_MIDPOINT_T = 0.5;\n // Scales the accelerating half's t^3 term.\n const EASE_IN_CUBIC_SCALE = 4;\n // Coefficient of t inside the decelerating half's inner expression, and the exponent applied to that inner expression.\n const EASE_OUT_LINEAR_COEFFICIENT = -2;\n const EASE_OUT_CUBIC_EXPONENT = 3;\n\n return t < EASE_MIDPOINT_T\n ? EASE_IN_CUBIC_SCALE * t * t * t\n : 1 -\n Math.pow(EASE_OUT_LINEAR_COEFFICIENT * t + 2, EASE_OUT_CUBIC_EXPONENT) /\n 2;\n}\n","import { type Page } from \"@playwright/test\";\n\n// The narration timeline behind the footer band: each showCaption/hideCaption call is timestamped against the recording's own start, and the framing pass later turns that event list into \"this text was on screen from here to here\" overlay windows. Keeping it as per-page WeakMap state (rather than threading it through every helper signature) is what lets both the test-runner adapter and the standalone runner share one helper surface.\n\nexport interface CaptionEvent {\n atMs: number;\n text: string | null;\n}\n\nexport interface RecordingTimeline {\n startedAt: number;\n events: CaptionEvent[];\n}\n\n// Per-page recording start time (t0) and narration timeline, keyed the way cursor positions are (see cursor.ts): per-page state that must survive across many helper calls without being threaded through every function signature.\nconst recordingStartedAt = new WeakMap<Page, number>();\nconst captionTimeline = new WeakMap<Page, CaptionEvent[]>();\n\n/**\n * Called once, as early as possible (installCursor's own first line). First call wins, so a demo that navigates before installing the overlay doesn't shift t0 later than the recording's own real start.\n */\nexport function markRecordingStart(page: Page): void {\n if (!recordingStartedAt.has(page)) recordingStartedAt.set(page, Date.now());\n}\n\n/**\n * Appends one narration event for the page: a string shows that caption from now, null hides whatever is showing. Called by showCaption/hideCaption; direct use is only for advanced driving.\n */\nexport function recordCaptionEvent(page: Page, text: string | null): void {\n const startedAt = recordingStartedAt.get(page) ?? Date.now();\n const events = captionTimeline.get(page) ?? [];\n events.push({ atMs: Date.now() - startedAt, text });\n captionTimeline.set(page, events);\n}\n\n/**\n * Snapshot for the framing pass. Read after the context has closed is fine: the map is keyed by the Page object itself, which outlives its browser context.\n */\nexport function getRecordingTimeline(page: Page): RecordingTimeline {\n return {\n startedAt: recordingStartedAt.get(page) ?? Date.now(),\n events: captionTimeline.get(page) ?? [],\n };\n}\n","import { type Locator, type Page } from \"@playwright/test\";\nimport { buildHumanPath, easeInOutCubic, type Point } from \"./human-path\";\nimport { markRecordingStart, recordCaptionEvent } from \"./timeline\";\n\n// Playwright drives the DOM directly and never moves a visible pointer, so a screen recording of an automated run shows silent jump-cuts with no indication of what was clicked or how. This installs a synthetic cursor overlay (a dot that tracks real `mousemove` events, animates on click, and persists its position across navigations instead of resetting to the corner), glides the cursor along a slightly curved, eased, jittered path rather than a robotic straight-line teleport, and exposes moveAndClick/fillVisibly/gotoWithCursor/showCaption/hideCaption helpers built on top, so recordings made with them read as a person driving the UI with someone narrating over their shoulder, not a silent script. Narration itself is not an in-page overlay: showCaption/hideCaption below timestamp the narration text against the recording's own start time (see ./timeline), and the actual banner is composited into a dedicated band below the recorded app window during post-processing (see ./frame for why an in-page overlay can't produce that layout on its own).\n\n// TALKTRACK_OVERLAY_INIT runs inside the page (via page.evaluate/addInitScript), so it attaches its handles to the real browser `window` rather than returning them. This augmentation is what lets moveAndClick below call window.__talktrackPress directly, with no `as unknown as` cast.\ndeclare global {\n interface Window {\n __talktrackPress?: (x: number, y: number) => void;\n __talktrackRelease?: () => void;\n }\n}\n\nconst TALKTRACK_OVERLAY_INIT = () => {\n if (document.getElementById(\"__talktrack_cursor__\")) return;\n const style = document.createElement(\"style\");\n style.textContent = `\n #__talktrack_cursor__ {\n position: fixed; top: 0; left: 0; width: 22px; height: 22px;\n border-radius: 50%; background: rgba(220,38,38,0.88);\n border: 2px solid white; box-shadow: 0 1px 5px rgba(0,0,0,0.6);\n pointer-events: none; z-index: 2147483647; transform: translate(-50%, -50%) scale(1);\n transition: transform 0.12s ease-out, background-color 0.12s ease-out;\n }\n #__talktrack_cursor__.__talktrack_pressed__ {\n transform: translate(-50%, -50%) scale(0.55);\n background: rgba(153,27,27,0.95);\n }\n .__talktrack_ripple__ {\n position: fixed; width: 14px; height: 14px; border-radius: 50%;\n border: 3px solid rgba(220,38,38,0.9); pointer-events: none; z-index: 2147483647;\n transform: translate(-50%, -50%); animation: __talktrack_ripple_anim__ 0.55s ease-out forwards;\n }\n @keyframes __talktrack_ripple_anim__ {\n from { width: 14px; height: 14px; opacity: 1; }\n to { width: 58px; height: 58px; opacity: 0; }\n }\n `;\n document.head.appendChild(style);\n const cursor = document.createElement(\"div\");\n cursor.id = \"__talktrack_cursor__\";\n document.body.appendChild(cursor);\n window.addEventListener(\n \"mousemove\",\n (e) => {\n cursor.style.left = `${String(e.clientX)}px`;\n cursor.style.top = `${String(e.clientY)}px`;\n },\n true,\n );\n window.__talktrackPress = (x, y) => {\n cursor.classList.add(\"__talktrack_pressed__\");\n const ripple = document.createElement(\"div\");\n ripple.className = \"__talktrack_ripple__\";\n ripple.style.left = `${String(x)}px`;\n ripple.style.top = `${String(y)}px`;\n document.body.appendChild(ripple);\n const RIPPLE_REMOVE_DELAY_MS = 600;\n setTimeout(() => {\n ripple.remove();\n }, RIPPLE_REMOVE_DELAY_MS);\n };\n window.__talktrackRelease = () => {\n cursor.classList.remove(\"__talktrack_pressed__\");\n };\n};\n\n// Per-page last-known cursor position, so a navigation can restore it on the fresh document instead of the overlay resetting to the top-left corner, and so the next glide starts from where the cursor actually is rather than an arbitrary origin.\nconst currentPosition = new WeakMap<Page, Point>();\n\nasync function glideTo(page: Page, to: Readonly<Point>): Promise<void> {\n const from = currentPosition.get(page) ?? { x: to.x, y: to.y };\n const path = buildHumanPath(from, to);\n const MOVE_STEP_MIN_DELAY_MS = 12;\n const MOVE_STEP_DELAY_RANGE_MS = 10;\n for (const point of path) {\n await page.mouse.move(point.x, point.y);\n // ~12-22ms per step: roughly natural mouse-sampling pace, with enough jitter that consecutive glides don't all take an identical, obviously-scripted duration.\n await page.waitForTimeout(\n MOVE_STEP_MIN_DELAY_MS + Math.random() * MOVE_STEP_DELAY_RANGE_MS,\n );\n }\n currentPosition.set(page, to);\n}\n\n// page.evaluate right after a navigation races the document it targets (a redirect or a late client transition can swap the page out from under it), which throws \"Execution context was destroyed\". That's expected, not a real failure of this cosmetic helper, so it's tolerated here rather than aborting the whole recording over it.\nasync function tolerateNavigationRace<T>(\n promise: Readonly<Promise<T>>,\n): Promise<T | undefined> {\n return promise.catch(() => undefined);\n}\n\n/**\n * Playwright's own locator.scrollIntoViewIfNeeded() is a synchronous DOM scrollIntoView: an instant jump-cut in a recording, not something a person watching would recognise as scrolling at all. This drives a real sequence of wheel events instead, eased and paced like glideTo's own mouse path, so a recording shows the page actually gliding to the target rather than teleporting to it. Exported (not just used internally by moveAndClick below) so a demo can bring a target into view on its own, with no click attached (for example before narrating over a tall diagram whose own heading satisfies a \"content rendered\" assertion long before the diagram's body, the actual thing being narrated, has scrolled into the visible viewport).\n */\nexport async function naturalScrollIntoView(\n page: Page,\n locator: Readonly<Locator>,\n): Promise<void> {\n const viewport = page.viewportSize();\n if (!viewport) return;\n\n // Real wheel events hit-test against whatever scrollable element is under the CURRENT mouse position, not the target: moving the mouse toward the viewport's own centre first (rather than wherever the previous click left it) makes that hit-test land on the main scrollable content area rather than, say, a fixed sidebar nav the cursor happens to still be resting over.\n const hoverPoint = { x: viewport.width / 2, y: viewport.height / 2 };\n await tolerateNavigationRace(page.mouse.move(hoverPoint.x, hoverPoint.y));\n\n const box = await tolerateNavigationRace(locator.boundingBox());\n // Let the caller's own click flow surface a clear \"no bounding box\" error instead of failing silently here.\n if (!box) return;\n\n // A comfortable margin from the viewport edge, not the literal edge: a person scrolls until they can see the thing clearly, not until it's pixel-perfect at the boundary.\n const margin = 96;\n // Already comfortably in view: nothing to scroll.\n if (box.y >= margin && box.y + box.height <= viewport.height - margin) return;\n\n // Scroll enough to land the target roughly centred, not pinned to the very top, matching how a person actually scrolls toward something they want to interact with next.\n const totalDelta = box.y + box.height / 2 - viewport.height / 2;\n const MIN_SCROLL_DELTA_PX = 12;\n if (Math.abs(totalDelta) < MIN_SCROLL_DELTA_PX) return;\n\n const MIN_SCROLL_STEPS = 7;\n const MAX_SCROLL_STEPS = 26;\n const PIXELS_PER_SCROLL_STEP = 55;\n const steps = Math.max(\n MIN_SCROLL_STEPS,\n Math.min(\n MAX_SCROLL_STEPS,\n Math.round(Math.abs(totalDelta) / PIXELS_PER_SCROLL_STEP),\n ),\n );\n let deliveredSoFar = 0;\n const SCROLL_STEP_MIN_DELAY_MS = 14;\n const SCROLL_STEP_DELAY_RANGE_MS = 12;\n for (let i = 1; i <= steps; i++) {\n const eased = easeInOutCubic(i / steps);\n const target = totalDelta * eased;\n const stepDelta = target - deliveredSoFar;\n deliveredSoFar = target;\n await page.mouse.wheel(0, stepDelta);\n // ~14-26ms per step: a real scroll-wheel/trackpad delivers a burst of small deltas at roughly this cadence, with jitter so consecutive scrolls in the same recording don't all take an identical, obviously-scripted duration.\n await page.waitForTimeout(\n SCROLL_STEP_MIN_DELAY_MS + Math.random() * SCROLL_STEP_DELAY_RANGE_MS,\n );\n }\n // A brief settle beat after the scroll stops, the way a person pauses a moment before their next action rather than clicking mid-scroll.\n const SCROLL_SETTLE_MIN_DELAY_MS = 140;\n const SCROLL_SETTLE_DELAY_RANGE_MS = 120;\n await page.waitForTimeout(\n SCROLL_SETTLE_MIN_DELAY_MS + Math.random() * SCROLL_SETTLE_DELAY_RANGE_MS,\n );\n}\n\nasync function applyCursor(page: Page): Promise<void> {\n await tolerateNavigationRace(page.evaluate(TALKTRACK_OVERLAY_INIT));\n const pos = currentPosition.get(page);\n if (!pos) return;\n await tolerateNavigationRace(\n page.evaluate(\n ([x, y]) => {\n const cursor = document.getElementById(\"__talktrack_cursor__\");\n if (cursor) {\n cursor.style.left = `${String(x)}px`;\n cursor.style.top = `${String(y)}px`;\n }\n },\n [pos.x, pos.y] as const,\n ),\n );\n // Keeps Playwright's own virtual mouse position in sync with the overlay: otherwise the next glide would start from (0,0), not from where the cursor visually is.\n await tolerateNavigationRace(page.mouse.move(pos.x, pos.y, { steps: 1 }));\n}\n\n/**\n * Re-injects on every navigation (addInitScript) and once immediately for the current document, since addInitScript only applies to documents created after it's registered. Also marks the recording's own t0 (see ./timeline): every demo calls this as its very first action, which is the closest available proxy for \"when the video recording actually began\" without needing frame-exact precision a demo narration track doesn't need anyway.\n */\nexport async function installCursor(page: Page): Promise<void> {\n markRecordingStart(page);\n await page.addInitScript(TALKTRACK_OVERLAY_INIT);\n await applyCursor(page);\n}\n\n/**\n * Use in place of page.goto() for any navigation after installCursor(): restores the cursor to its last known state on the new document instead of letting it reset, so navigating doesn't read as a visual glitch in the recording.\n */\nexport async function gotoWithCursor(page: Page, url: string): Promise<void> {\n await page.goto(url);\n await applyCursor(page);\n}\n\n/**\n * Use in place of page.reload() for any navigation after installCursor(): restores the cursor to its last known state on the new document the same way gotoWithCursor does for page.goto(). A bare page.reload() in a demo that has already called installCursor() is the one navigation path the overlay can't cover on its own: addInitScript recreates the cursor DOM element on the fresh document (so it doesn't vanish outright), but nothing repositions it, so a reload leaves the cursor visibly pinned to the corner until the next moveAndClick's glide happens to move it (confirmed live as the cause of a cursor going missing mid-recording during a demo's own periodic safety-net reloads).\n */\nexport async function reloadWithCursor(page: Page): Promise<void> {\n await page.reload();\n await applyCursor(page);\n}\n\n/**\n * Sets (or updates) the narration shown in the footer band below the recorded app window; see ./timeline for how this timestamped event actually becomes an on-screen banner during post-processing. Pass null (or call hideCaption) to clear it between unrelated steps rather than leaving a stale caption shown throughout the gap.\n */\nexport function showCaption(page: Page, text: string): void {\n recordCaptionEvent(page, text);\n}\n\n/**\n * Clears the narration band (closes the window the last showCaption opened). Hide between unrelated steps rather than leaving a stale caption shown throughout the gap.\n */\nexport function hideCaption(page: Page): void {\n recordCaptionEvent(page, null);\n}\n\n// +/- ~25% jitter around a base duration, so repeated pauses in a recording don't all read as the exact same, obviously-scripted length.\nfunction humanPause(baseMs: number): number {\n const PAUSE_JITTER_MIN_MULTIPLIER = 0.75;\n const PAUSE_JITTER_RANGE_MULTIPLIER = 0.5;\n\n return Math.round(\n baseMs *\n (PAUSE_JITTER_MIN_MULTIPLIER +\n Math.random() * PAUSE_JITTER_RANGE_MULTIPLIER),\n );\n}\n\n/**\n * Moves the cursor humanly to the locator (scrolling it into view first), clicks it, and holds a jittered pause afterwards. Use in place of locator.click() in any recording.\n */\nexport async function moveAndClick(\n page: Page,\n locator: Readonly<Locator>,\n pauseMs = 250,\n): Promise<void> {\n await naturalScrollIntoView(page, locator);\n const box = await locator.boundingBox();\n if (!box)\n throw new Error(\"Could not resolve a bounding box for the click target\");\n // Aim slightly off dead-centre, the way a person rarely clicks a target's exact geometric middle, while staying comfortably inside the element's own bounds.\n const CLICK_TARGET_MIN_OFFSET_RATIO = 0.4;\n const CLICK_TARGET_OFFSET_RANGE_RATIO = 0.2;\n const target = {\n x:\n box.x +\n box.width *\n (CLICK_TARGET_MIN_OFFSET_RATIO +\n Math.random() * CLICK_TARGET_OFFSET_RANGE_RATIO),\n y:\n box.y +\n box.height *\n (CLICK_TARGET_MIN_OFFSET_RATIO +\n Math.random() * CLICK_TARGET_OFFSET_RANGE_RATIO),\n };\n await glideTo(page, target);\n const HOVER_BEFORE_CLICK_MS = 180;\n // A brief hover before committing to the click.\n await page.waitForTimeout(humanPause(HOVER_BEFORE_CLICK_MS));\n await tolerateNavigationRace(\n page.evaluate(([px, py]) => window.__talktrackPress?.(px, py), [\n target.x,\n target.y,\n ] as const),\n );\n // The actual click goes through locator.click(), not raw page.mouse.down()/up() at the coordinates captured above. Those coordinates are a snapshot from before the glide animation and hover pause, which together take several hundred milliseconds; long enough for an async-populated dropdown (options that load after mount) to re-render and shift the option out from under a stale coordinate. Raw mouse events have no actionability check the way locator.click() does, so a stale target silently clicks empty space (or the wrong element) instead of failing loudly or retargeting (confirmed live: this silently produced an unselected field with no error anywhere in the run). locator.click() re-resolves the element and waits for it to be stable/attached/visible immediately before dispatching, which is exactly the robustness raw coordinates can't provide; { force: true } is not used, so a genuinely non-actionable target still fails loudly rather than clicking through.\n await locator.click();\n const PAUSE_AFTER_CLICK_MS = 100;\n await page.waitForTimeout(humanPause(PAUSE_AFTER_CLICK_MS));\n await tolerateNavigationRace(\n page.evaluate(() => window.__talktrackRelease?.()),\n );\n // The click itself may have triggered a hard navigation (a plain <a> link, a form submit's redirect): that swaps in a fresh document, and addInitScript's TALKTRACK_OVERLAY_INIT creates a new cursor at its unset default (pinned to the top-left corner) until something restores it. A single check right after mouse.up() isn't reliable, because the click can take a beat to actually start navigating, so that one check can land before the new document even exists, restoring onto the page that's about to be torn down anyway rather than the one that replaces it. Reapplying a few times over the following stretch means whichever check lands after the real swap picks it back up almost immediately, instead of leaving it missing until some later action reveals it. For a soft navigation or no navigation at all, every extra check is a harmless no-op: the cursor already exists at the right position.\n const CURSOR_REAPPLY_ATTEMPTS = 4;\n const CURSOR_REAPPLY_INTERVAL_MS = 90;\n for (let i = 0; i < CURSOR_REAPPLY_ATTEMPTS; i++) {\n // currentPosition already holds `target`, set by glideTo above.\n await applyCursor(page);\n await page.waitForTimeout(CURSOR_REAPPLY_INTERVAL_MS);\n }\n await page.waitForTimeout(humanPause(pauseMs));\n}\n\n// Per-character delay for typeCharByChar, centred around a real, fast-but-plausible typing speed (~45ms/char average is roughly 210 characters/minute, comfortably within normal touch-typing range), with real per-character variance rather than a single fixed delay, plus a slightly longer beat after a space (the natural word-boundary pause) and an occasional, longer \"thinking\" pause mid-string, the way someone typing a value they're actively composing (a path, a flag list) rather than reciting from memory does.\nasync function typeCharByChar(page: Page, text: string): Promise<void> {\n const CHAR_TYPE_MIN_DELAY_MS = 28;\n const CHAR_TYPE_DELAY_RANGE_MS = 55;\n const SPACE_PAUSE_MIN_DELAY_MS = 40;\n const SPACE_PAUSE_DELAY_RANGE_MS = 60;\n const THINKING_PAUSE_PROBABILITY = 0.04;\n const THINKING_PAUSE_MIN_DELAY_MS = 200;\n const THINKING_PAUSE_DELAY_RANGE_MS = 400;\n for (const ch of text) {\n await page.keyboard.type(ch);\n let delay =\n CHAR_TYPE_MIN_DELAY_MS + Math.random() * CHAR_TYPE_DELAY_RANGE_MS;\n if (ch === \" \")\n delay +=\n SPACE_PAUSE_MIN_DELAY_MS + Math.random() * SPACE_PAUSE_DELAY_RANGE_MS;\n // An occasional longer \"thinking\" pause.\n if (Math.random() < THINKING_PAUSE_PROBABILITY)\n delay +=\n THINKING_PAUSE_MIN_DELAY_MS +\n Math.random() * THINKING_PAUSE_DELAY_RANGE_MS;\n await page.waitForTimeout(delay);\n }\n}\n\n/**\n * Clicks into the locator and types the text character by character at human speed, so a recording shows the value appearing as it's typed rather than arriving at once.\n */\nexport async function fillVisibly(\n page: Page,\n locator: Readonly<Locator>,\n text: string,\n pauseMs = 300,\n): Promise<void> {\n const PRE_FILL_CLICK_PAUSE_MS = 150;\n await moveAndClick(page, locator, humanPause(PRE_FILL_CLICK_PAUSE_MS));\n // Clears any existing value in one step: clearing isn't the part worth watching character by character, typing the new value is.\n await locator.fill(\"\");\n await typeCharByChar(page, text);\n await page.waitForTimeout(humanPause(pauseMs));\n}\n\n/**\n * Exported so demos can pace their own non-interaction \"reading\" pauses (for example holding on a state that just changed) with the same jittered, human-feeling timing.\n */\nexport function humanBeatMs(baseMs: number): number {\n return humanPause(baseMs);\n}\n\n/**\n * Waits a jittered pause of roughly `ms` milliseconds on the page, for \"reading\" beats between interactions.\n */\nexport async function beat(page: Page, ms = 600): Promise<void> {\n await page.waitForTimeout(humanBeatMs(ms));\n}\n\n/* Replaces the two shapes a demo would otherwise hand-roll around showCaption/hideCaption: caption-then-fixed-sleep-then-action (the sleep is unrelated to any page state, so the action can start before or long after the caption's own hold ends) and assert-fully-then-caption (no lead-in at all, since the caption only ever appears once its claim is already true). Neither ties a caption's own on-screen duration to genuine page-state readiness.\n\n showCaption fires immediately, so the caption is up before `settle` (whatever assertion, poll, or action already proves the caption's own claim) has resolved: a real lead-in, not a guess. `settle` and a minimum `leadInMs` floor run concurrently, so a `settle` that resolves in under `leadInMs` still gets a caption that's been up for at least that long, and a `settle` that takes minutes (a capacity poll, say) keeps the caption up for the whole genuine wait rather than a short fixed hold that's already elapsed by the time anything real happens. Once `settle` resolves, the caption holds for `dwellMs` (a real \"look at this\" pause) before hiding: exactly the digest window a fixed-hold constant was always trying to give, just now added on top of true settle time rather than instead of it. */\nconst NARRATE_SCENE_DEFAULT_LEAD_IN_MS = 800;\nconst NARRATE_SCENE_DEFAULT_DWELL_MS = 1200;\n/**\n * Shows a caption, waits for a real page-state condition to settle while the caption stays up, holds a digest dwell, then hides it. The caption's on-screen duration is tied to genuine readiness (the `settle` promise) rather than a fixed sleep: `leadInMs` is a minimum floor running concurrently with `settle`, and `dwellMs` is the \"look at this\" hold after it resolves.\n */\nexport async function narrateScene<T>(\n page: Page,\n text: string,\n settle: () => Promise<T>,\n opts?: { leadInMs?: number; dwellMs?: number },\n): Promise<T> {\n const leadInMs = opts?.leadInMs ?? NARRATE_SCENE_DEFAULT_LEAD_IN_MS;\n const dwellMs = opts?.dwellMs ?? NARRATE_SCENE_DEFAULT_DWELL_MS;\n showCaption(page, text);\n const [result] = await Promise.all([settle(), beat(page, leadInMs)]);\n await beat(page, dwellMs);\n hideCaption(page);\n\n return result;\n}\n","import { type BrowserContext } from \"@playwright/test\";\n\n/**\n * Styling of the header and footer bands composited around the recorded app window. The defaults are deliberately unopinionated (a dark neutral band with the system font stack); override any subset through the `bands` option on finalizeFramedRecording/recordDemo to match the application being recorded.\n */\nexport interface BandStyle {\n headerHeightPx: number;\n footerHeightPx: number;\n background: string;\n textColor: string;\n fontStack: string;\n headerFontSizePx: number;\n footerFontSizePx: number;\n}\n\n/** The band look talktrack frames recordings with when no `bands` override is given. */\nexport const defaultBandStyle: BandStyle = {\n headerHeightPx: 48,\n footerHeightPx: 96,\n background: \"#111827\",\n textColor: \"#f8fafc\",\n fontStack:\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif',\n headerFontSizePx: 18,\n footerFontSizePx: 20,\n};\n\n/**\n * Merges a caller's partial band overrides over {@link defaultBandStyle}; an absent overrides object yields the defaults unchanged.\n */\nexport function resolveBandStyle(overrides?: Partial<BandStyle>): BandStyle {\n return { ...defaultBandStyle, ...overrides };\n}\n\n/**\n * Escapes the five characters HTML treats as markup, so arbitrary narration text can be embedded in the band page's template safely.\n */\nexport function escapeHtml(text: string): string {\n return text\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\n/** Everything one composited band needs: its pixel size, its text, and the font size to render that text at. */\nexport interface TextBandSpec {\n width: number;\n height: number;\n fontSizePx: number;\n text: string;\n}\n\n/**\n * Renders one band as a PNG. Takes a dedicated throwaway BrowserContext, not the demo's own page/context: a context recording video (recordVideo or Playwright Test's video option) records every page opened within it, so rendering the bands through the demo's own context would leave junk video files alongside the real recording. A fresh context with no video option set (off by default) avoids that entirely.\n *\n * Text is rendered as an ordinary PNG screenshot of that throwaway page rather than via ffmpeg's drawtext filter, since drawtext needs libfreetype/libfontconfig support that common ffmpeg builds (a plain Homebrew install, for instance) don't ship. Rendering text via the same browser already driving the recording sidesteps that dependency and gives exact font parity with the recorded application for free.\n */\nexport async function renderTextBand(\n bandContext: BrowserContext,\n style: Readonly<BandStyle>,\n band: Readonly<TextBandSpec>,\n): Promise<Buffer> {\n const bandPage = await bandContext.newPage();\n try {\n await bandPage.setViewportSize({\n width: band.width,\n height: band.height,\n });\n await bandPage.setContent(`<!doctype html>\n<html><head><style>\n html, body { margin: 0; width: ${String(band.width)}px; height: ${String(band.height)}px; background: ${style.background}; display: flex; align-items: center; justify-content: center; }\n p { margin: 0; padding: 0 28px; max-width: 100%; box-sizing: border-box; color: ${style.textColor}; font: 500 ${String(band.fontSizePx)}px/1.35 ${style.fontStack}; text-align: center; overflow-wrap: break-word; }\n</style></head>\n<body><p>${escapeHtml(band.text)}</p></body></html>`);\n\n return await bandPage.screenshot({ type: \"png\" });\n } finally {\n await bandPage.close();\n }\n}\n","import type { BandStyle } from \"./bands\";\nimport type { CaptionEvent } from \"./timeline\";\n\n// The pure half of the framing pass: turning a caption timeline into (a) concrete on-screen windows and (b) an ffmpeg filter graph, with no browser, no filesystem and no ffmpeg involved. Everything here is unit-testable; everything that touches the outside world lives in frame.ts.\n\nexport interface CaptionWindow {\n text: string;\n startMs: number;\n endMs: number;\n}\n\n/**\n * A window opens at each show event and closes at whatever event follows it (an explicit hide, or a subsequent show replacing the line), or at the video's own end if nothing follows. A hide event only ever closes the preceding show's window: it opens no window of its own.\n */\nexport function captionWindows(\n events: readonly CaptionEvent[],\n videoDurationMs: number,\n): CaptionWindow[] {\n const windows: CaptionWindow[] = [];\n for (const [i, event] of events.entries()) {\n if (event.text === null) continue;\n const nextEvent = events[i + 1];\n windows.push({\n text: event.text,\n startMs: event.atMs,\n endMs: nextEvent === undefined ? videoDurationMs : nextEvent.atMs,\n });\n }\n\n return windows;\n}\n\nconst MS_PER_SECOND = 1000;\n// ffmpeg's between(t,start,end) expression accepts fractional seconds; millisecond precision is more than enough for a narration cue.\nconst FFMPEG_TIME_DECIMAL_PLACES = 3;\n\n/** The ffmpeg inputs and filter graph one framing pass drives: how many -i inputs the command takes, the graph string itself, and the label its final overlay writes. */\nexport interface FramePlan {\n inputCount: number;\n filterGraph: string;\n finalLabel: string;\n}\n\n/**\n * Input 0 is the raw recording, input 1 the header band PNG, inputs 2.. the caption band PNGs in window order. The graph pads input 0 onto a taller canvas of band colour (header band above, footer band below), overlays the header at the top, then chains one time-gated overlay per caption window onto the footer region.\n */\nexport function buildFramePlan(\n viewport: Readonly<{ width: number; height: number }>,\n style: Readonly<BandStyle>,\n windows: readonly CaptionWindow[],\n): FramePlan {\n const padHeight =\n viewport.height + style.headerHeightPx + style.footerHeightPx;\n let filterGraph = `[0:v]pad=${String(viewport.width)}:${String(padHeight)}:0:${String(style.headerHeightPx)}:color=${style.background}[bg];[bg][1:v]overlay=0:0[v0]`;\n let lastLabel = \"v0\";\n\n let inputIndex = 2;\n for (const [i, window] of windows.entries()) {\n const startS = (window.startMs / MS_PER_SECOND).toFixed(\n FFMPEG_TIME_DECIMAL_PLACES,\n );\n const endS = (window.endMs / MS_PER_SECOND).toFixed(\n FFMPEG_TIME_DECIMAL_PLACES,\n );\n const nextLabel = `v${String(i + 1)}`;\n filterGraph += `;[${lastLabel}][${String(inputIndex)}:v]overlay=0:${String(style.headerHeightPx + viewport.height)}:enable='between(t,${startS},${endS})'[${nextLabel}]`;\n lastLabel = nextLabel;\n inputIndex++;\n }\n\n return { inputCount: inputIndex, filterGraph, finalLabel: lastLabel };\n}\n","import { execFile } from \"node:child_process\";\n\n// A hand-written wrapper rather than util.promisify(execFile): execFile's own overloaded callback signature (its non-callback form returns a live ChildProcess, distinct from the callback form promisify actually targets) trips @typescript-eslint/strict-void-return on the promisified result. This sidesteps the overload ambiguity entirely rather than suppressing the lint rule.\nasync function runCommand(\n command: string,\n args: readonly string[],\n label: string,\n): Promise<string> {\n return await new Promise<string>((resolve, reject) => {\n execFile(command, args, (error, stdout) => {\n // ExecFileException is a mapped/Omit-derived type over NodeJS.ErrnoException, not a literal `extends Error` class chain: wrapping it as the `cause` of a real Error satisfies the rejection-must-be-an-Error rule and preserves the original message/stack.\n if (error) {\n // ENOENT gets its own message because \"spawn ffmpeg ENOENT\" names the wrong thing to fix: the missing piece is the documented external binary, not the spawn call.\n const reason =\n error.code === \"ENOENT\"\n ? `${label} not found on PATH. talktrack drives the external ${label} binary for video post-processing; install it (for example \"brew install ffmpeg\" or \"apt-get install ffmpeg\") and retry.`\n : `${label} failed: ${error.message}`;\n reject(new Error(reason, { cause: error }));\n } else {\n resolve(stdout);\n }\n });\n });\n}\n\n/**\n * Runs the external ffmpeg binary with the given arguments. Rejects with an install hint rather than a bare spawn error when ffmpeg is not on PATH.\n */\nexport async function runFfmpeg(args: readonly string[]): Promise<string> {\n return await runCommand(\"ffmpeg\", args, \"ffmpeg\");\n}\n\n/** The dimensions and duration ffprobe reports for a video file. */\nexport interface VideoFacts {\n durationMs: number;\n width: number;\n height: number;\n}\n\n/**\n * Throws unless the recorded video's pixel size equals the viewport the bands are laid out against. Playwright records a smaller video than the viewport unless `video.size` is set to match (its default scales the recording to fit inside 800x800), and the framing pass would then pad that smaller picture onto a canvas sized for the full viewport: a silently mis-laid-out result that still looks like a successful run.\n */\nexport function assertVideoMatchesViewport(\n facts: Readonly<VideoFacts>,\n viewport: Readonly<{ width: number; height: number }>,\n videoPath: string,\n): void {\n if (facts.width === viewport.width && facts.height === viewport.height)\n return;\n throw new Error(\n `talktrack cannot frame ${videoPath}: the recording is ${String(facts.width)}x${String(facts.height)} but the viewport is ${String(viewport.width)}x${String(viewport.height)}. Set the recording size to the viewport size (Playwright Test: video: { mode: \"on\", size }; recordDemo does this itself) so the bands line up with the picture.`,\n );\n}\n\n/**\n * Narrows a parsed JSON value to a plain object, per the house unknown-handling rule: never an assertion, always a guard.\n */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * Probes a video file with ffprobe for its pixel dimensions and duration in milliseconds. Any output shape other than the documented stream+format JSON object rejects with the path and what was wrong.\n */\nexport async function probeVideo(videoPath: string): Promise<VideoFacts> {\n const stdout = await runCommand(\n \"ffprobe\",\n [\n \"-v\",\n \"error\",\n \"-select_streams\",\n \"v:0\",\n \"-show_entries\",\n \"stream=width,height:format=duration\",\n \"-of\",\n \"json\",\n videoPath,\n ],\n \"ffprobe\",\n );\n // ffprobe -of json prints { streams: [{ width, height }], format: { duration } } with the duration as a decimal-seconds string.\n const parsed: unknown = JSON.parse(stdout);\n if (!isRecord(parsed)) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: not a JSON object`,\n );\n }\n const streams = parsed.streams;\n const format = parsed.format;\n if (!Array.isArray(streams) || !isRecord(format)) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: streams is not an array or format is not an object`,\n );\n }\n const firstStream: unknown = streams[0];\n if (!isRecord(firstStream)) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: first stream is not an object`,\n );\n }\n const width = firstStream.width;\n const height = firstStream.height;\n const durationRaw = format.duration;\n if (\n typeof width !== \"number\" ||\n typeof height !== \"number\" ||\n typeof durationRaw !== \"string\"\n ) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: stream width/height or format.duration has the wrong type`,\n );\n }\n const MS_PER_SECOND = 1000;\n\n return {\n durationMs: Math.round(Number.parseFloat(durationRaw) * MS_PER_SECOND),\n width,\n height,\n };\n}\n","import { execFile } from \"node:child_process\";\nimport { mkdtemp, rm, writeFile } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { type Browser, type Page } from \"@playwright/test\";\nimport { renderTextBand, resolveBandStyle, type BandStyle } from \"./bands\";\nimport { assertVideoMatchesViewport, probeVideo, runFfmpeg } from \"./ffmpeg\";\nimport { buildFramePlan, captionWindows } from \"./filter-graph\";\nimport { getRecordingTimeline } from \"./timeline\";\n\n// The framing pass. Playwright's video recorder captures exactly the browser viewport's own pixels, so an in-page \"narrate below the app\" or \"commit/date above the app\" banner is a contradiction in terms: anything the page itself renders is, by definition, inside the recorded viewport. This module produces that layout as a genuinely separate post-processing pass instead: the raw recording stays exactly the app's own window, and frameRecording pads a taller canvas around it (a static header band and a footer band that shows each narration line only for the real span of time it was actually on screen during the run). ffmpeg's own pad+overlay filters do the compositing.\n\n/**\n * Everything the framing pass needs. Both adapters (the Playwright Test one and the standalone runner) reduce to this: a flushed raw recording, the viewport it was recorded at, a live browser to render band PNGs through, and where to write the framed copy.\n */\nexport interface FrameRecordingInput {\n // Timeline source. Read after the context has closed is fine: the state is keyed by the Page object itself, which outlives its context.\n page: Page;\n // Path to the raw recording, already flushed to disk (Playwright Test flushes it before afterEach; the standalone runner closes the context first).\n videoPath: string;\n // The recorded viewport, captured before the context closed (page.viewportSize() throws on a closed page).\n viewport: { width: number; height: number };\n // Band PNGs are rendered through a throwaway context on this browser (see renderTextBand).\n browser: Browser;\n headerText: string | Promise<string>;\n framedPath: string;\n bands?: Partial<BandStyle>;\n}\n\n/**\n * The default header band: the word \"commit\", the short sha, a middot, and the ISO recording-start timestamp, or just the timestamp when not inside a git working tree (demos run from anywhere, not only repos).\n */\nexport async function defaultHeaderText(startedAtMs: number): Promise<string> {\n const createdAtIso = new Date(startedAtMs).toISOString();\n try {\n const sha = await new Promise<string>((resolve, reject) => {\n execFile(\"git\", [\"rev-parse\", \"--short\", \"HEAD\"], (error, stdout) => {\n if (error) reject(new Error(error.message, { cause: error }));\n else resolve(stdout.trim());\n });\n });\n\n return `commit ${sha} · ${createdAtIso}`;\n } catch {\n return createdAtIso;\n }\n}\n\n/**\n * Frames one raw recording: renders the header and caption band PNGs, probes the raw video's duration, and composites everything into {@link FrameRecordingInput.framedPath} with ffmpeg. Returns that path.\n */\nexport async function frameRecording(\n input: Readonly<FrameRecordingInput>,\n): Promise<string> {\n const style = resolveBandStyle(input.bands);\n const timeline = getRecordingTimeline(input.page);\n const facts = await probeVideo(input.videoPath);\n assertVideoMatchesViewport(facts, input.viewport, input.videoPath);\n const windows = captionWindows(timeline.events, facts.durationMs);\n const plan = buildFramePlan(input.viewport, style, windows);\n\n // Deliberately separate from the demo's own recording context (see renderTextBand's own comment for why).\n const bandContext = await input.browser.newContext();\n const workDir = await mkdtemp(join(tmpdir(), \"talktrack-frame-\"));\n try {\n const inputArgs: string[] = [\"-i\", input.videoPath];\n const headerPngPath = join(workDir, \"header.png\");\n await writeFile(\n headerPngPath,\n await renderTextBand(bandContext, style, {\n width: input.viewport.width,\n height: style.headerHeightPx,\n fontSizePx: style.headerFontSizePx,\n text: await input.headerText,\n }),\n );\n inputArgs.push(\"-i\", headerPngPath);\n for (const [i, window] of windows.entries()) {\n const captionPngPath = join(workDir, `caption-${String(i)}.png`);\n await writeFile(\n captionPngPath,\n await renderTextBand(bandContext, style, {\n width: input.viewport.width,\n height: style.footerHeightPx,\n fontSizePx: style.footerFontSizePx,\n text: window.text,\n }),\n );\n inputArgs.push(\"-i\", captionPngPath);\n }\n\n await runFfmpeg([\n \"-y\",\n ...inputArgs,\n \"-filter_complex\",\n plan.filterGraph,\n \"-map\",\n `[${plan.finalLabel}]`,\n \"-c:v\",\n \"libvpx-vp9\",\n \"-crf\",\n \"32\",\n \"-b:v\",\n \"0\",\n \"-deadline\",\n \"good\",\n \"-cpu-used\",\n \"3\",\n input.framedPath,\n ]);\n\n return input.framedPath;\n } finally {\n await bandContext.close();\n await rm(workDir, { recursive: true, force: true });\n }\n}\n","import { mkdtemp, rm } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { type Page, type TestInfo } from \"@playwright/test\";\nimport { type BandStyle } from \"./bands\";\nimport { defaultHeaderText, frameRecording } from \"./frame\";\nimport { getRecordingTimeline } from \"./timeline\";\n\n// The @playwright/test adapter. Call from afterEach: it closes the spec's page so Playwright finalises the video, frames the recording, and attaches the framed copy next to the raw one as a test attachment.\n\n/**\n * Optional overrides for {@link finalizeFramedRecording}: the header band's text, partial band styling, and where the framed copy is written (default: next to the raw video, with a -framed suffix).\n */\nexport interface FinalizeOptions {\n headerText?: string | Promise<string>;\n bands?: Partial<BandStyle>;\n framedPath?: string;\n}\n\n/**\n * Produces a taller, framed copy of the just-recorded spec video: the app window exactly as recorded, a static header above it, and a footer below it showing each narration line only while it was actually shown during the run. A no-op returning undefined (not a failure) when there's no video to frame at all (a spec run without video enabled) or no viewport to size bands against.\n *\n * Closes the page. Playwright only finalises a page's video once the page is closed, and an afterEach hook runs while the test's page is still open, so the recording on disk is incomplete until then: the adapter closes the page itself and waits for the finalised video (video.saveAs waits for exactly that) before reading it. The page is unusable afterwards, which is why this belongs in afterEach and not mid-test.\n */\nexport async function finalizeFramedRecording(\n page: Page,\n testInfo: TestInfo,\n opts?: FinalizeOptions,\n): Promise<string | undefined> {\n const video = page.video();\n if (!video) return undefined;\n const viewport = page.viewportSize();\n if (!viewport) return undefined;\n const browser = page.context().browser();\n if (!browser)\n throw new Error(\n \"talktrack cannot frame this recording: a persistent browser context has no separate Browser handle to render caption bands through\",\n );\n\n // Only used for naming the framed copy: path() reports where the video is being written without waiting for it to finish.\n const rawPath = await video.path();\n const framedPath =\n opts?.framedPath ?? rawPath.replace(/\\.webm$/, \"-framed.webm\");\n const headerText =\n opts?.headerText ?? defaultHeaderText(getRecordingTimeline(page).startedAt);\n\n const scratchDir = await mkdtemp(join(tmpdir(), \"talktrack-raw-\"));\n try {\n await page.close();\n const finalisedRawPath = join(scratchDir, \"raw.webm\");\n await video.saveAs(finalisedRawPath);\n\n await frameRecording({\n page,\n videoPath: finalisedRawPath,\n viewport,\n browser,\n headerText,\n framedPath,\n ...(opts?.bands ? { bands: opts.bands } : {}),\n });\n } finally {\n await rm(scratchDir, { recursive: true, force: true });\n }\n await testInfo.attach(\"framed-recording\", {\n path: framedPath,\n contentType: \"video/webm\",\n });\n\n return framedPath;\n}\n","import { mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport {\n type Browser,\n type BrowserContext,\n type BrowserContextOptions,\n type BrowserType,\n type LaunchOptions,\n type Locator,\n chromium,\n type Page,\n} from \"@playwright/test\";\nimport { type BandStyle } from \"./bands\";\nimport {\n beat,\n fillVisibly,\n gotoWithCursor,\n hideCaption,\n installCursor,\n moveAndClick,\n narrateScene,\n naturalScrollIntoView,\n reloadWithCursor,\n showCaption,\n} from \"./cursor\";\nimport { defaultHeaderText, frameRecording } from \"./frame\";\nimport { getRecordingTimeline } from \"./timeline\";\n\n// The standalone runner: everything installCursor/narrateScene give a @playwright/test spec, but from a plain script with no test runner involved. recordDemo launches (or borrows) a browser, opens a video-recording context, installs the cursor, and returns the same helper surface bound to that page; finish() closes the context (which flushes Playwright's webm), runs the framing pass, and returns the framed recording's path.\n\n/**\n * Everything recordDemo needs: the recorded viewport, where to start, where outputs go, and optional overrides for the browser, context, header band, and band styling.\n */\nexport interface RecordDemoOptions {\n // Recorded viewport. Playwright records exactly this rectangle; 16:9 ratios frame cleanly.\n viewport: { width: number; height: number };\n // Initial navigation; omit to drive the page entirely by hand.\n url?: string;\n // Where Playwright writes the raw recording and talktrack writes the framed one. Default: talktrack-output under the working directory.\n outputDir?: string;\n // Bring your own browser (shared across demos); by default one is launched per recordDemo call and closed by finish().\n browser?: Browser;\n // Which browser to launch when `browser` is not supplied. Default: chromium.\n browserType?: BrowserType<Browser>;\n launchOptions?: LaunchOptions;\n // Extra context options (locale, colorScheme, ...). recordVideo and viewport are owned by the runner.\n contextOptions?: Omit<BrowserContextOptions, \"recordVideo\" | \"viewport\">;\n headerText?: string | Promise<string>;\n bands?: Partial<BandStyle>;\n}\n\n/**\n * The bound helper surface recordDemo returns: the cursor/narration helpers a spec gets, wrapped around one recording page, plus {@link TalktrackSession.finish} to flush and frame the video.\n */\nexport interface TalktrackSession {\n readonly page: Page;\n readonly context: BrowserContext;\n moveAndClick: (locator: Readonly<Locator>, pauseMs?: number) => Promise<void>;\n fillVisibly: (\n locator: Readonly<Locator>,\n text: string,\n pauseMs?: number,\n ) => Promise<void>;\n naturalScrollIntoView: (locator: Readonly<Locator>) => Promise<void>;\n goto: (url: string) => Promise<void>;\n reload: () => Promise<void>;\n caption: (text: string) => void;\n hideCaption: () => void;\n narrateScene: <T>(\n text: string,\n settle: () => Promise<T>,\n opts?: { leadInMs?: number; dwellMs?: number },\n ) => Promise<T>;\n beat: (ms?: number) => Promise<void>;\n // Closes the recording context, frames the raw video, closes the browser if this session launched it, and returns the framed recording's path. Call exactly once, at the end.\n finish: () => Promise<string>;\n}\n\n/**\n * Starts one standalone demo recording: launches (or borrows) a browser, opens a video-recording context at the given viewport, installs the cursor, optionally navigates to `url`, and returns the session. Drive the page through the session's helpers, then `await session.finish()` once; it returns the framed recording's path.\n */\nexport async function recordDemo(\n name: string,\n opts: Readonly<RecordDemoOptions>,\n): Promise<TalktrackSession> {\n const outputDir = opts.outputDir ?? join(process.cwd(), \"talktrack-output\");\n await mkdir(outputDir, { recursive: true });\n\n const ownsBrowser = opts.browser === undefined;\n const browser =\n opts.browser ??\n (await (opts.browserType ?? chromium).launch(opts.launchOptions ?? {}));\n const context = await browser.newContext({\n ...opts.contextOptions,\n viewport: opts.viewport,\n recordVideo: { dir: outputDir, size: opts.viewport },\n });\n const page = await context.newPage();\n await installCursor(page);\n if (opts.url !== undefined) await gotoWithCursor(page, opts.url);\n\n const finish = async (): Promise<string> => {\n // Captured before close: both throw on a closed context/page, and the framing pass needs them after the video is flushed.\n const viewport = page.viewportSize();\n if (!viewport)\n throw new Error(\n \"talktrack could not read the viewport before closing the demo's context\",\n );\n const video = page.video();\n if (!video)\n throw new Error(\"talktrack's recording context produced no video handle\");\n\n const videoPathPromise = video.path();\n await context.close();\n const videoPath = await videoPathPromise;\n\n const framedPath = join(outputDir, `${name}-framed.webm`);\n const headerText =\n opts.headerText ??\n defaultHeaderText(getRecordingTimeline(page).startedAt);\n const framed = await frameRecording({\n page,\n videoPath,\n viewport,\n browser,\n headerText,\n framedPath,\n ...(opts.bands ? { bands: opts.bands } : {}),\n });\n if (ownsBrowser) await browser.close();\n\n return framed;\n };\n\n return {\n page,\n context,\n moveAndClick: async (locator, pauseMs) =>\n moveAndClick(page, locator, pauseMs),\n fillVisibly: async (locator, text, pauseMs) =>\n fillVisibly(page, locator, text, pauseMs),\n naturalScrollIntoView: async (locator) =>\n naturalScrollIntoView(page, locator),\n goto: async (url) => gotoWithCursor(page, url),\n reload: async () => reloadWithCursor(page),\n caption: (text) => {\n showCaption(page, text);\n },\n hideCaption: () => {\n hideCaption(page);\n },\n narrateScene: async (text, settle, narrateOpts) =>\n narrateScene(page, text, settle, narrateOpts),\n beat: async (ms) => beat(page, ms),\n finish,\n };\n}\n"],"mappings":"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"}
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/human-path.ts","../src/timeline.ts","../src/cursor.ts","../src/bands.ts","../src/filter-graph.ts","../src/ffmpeg.ts","../src/frame.ts","../src/test-adapter.ts","../src/standalone.ts"],"sourcesContent":["// The pure geometry behind the cursor's motion, split out from cursor.ts so it is unit-testable with no browser involved: a human moving a mouse doesn't teleport in a straight line at constant speed. Motion eases in and out, tends to bow slightly off the direct line, sometimes overshoots a touch on longer moves before settling, and has small continuous noise throughout.\n\n/** A point in viewport pixel coordinates. */\nexport interface Point {\n x: number;\n y: number;\n}\n\n/** The fewest points a glide is ever broken into: a short nudge is never reduced to a single frame. */\nexport const MIN_PATH_STEPS = 8;\n\n/** The most points a glide is ever broken into: a cross-viewport move shouldn't take forever. */\nexport const MAX_PATH_STEPS = 34;\n\n/**\n * Uniform noise in -spread..spread, used for micro-jitter along the path.\n */\nexport function jitter(spread: number): number {\n // Centres Math.random()'s 0..1 output on zero before scaling to -spread..spread.\n const RANDOM_UNIT_CENTER = 0.5;\n\n return (Math.random() - RANDOM_UNIT_CENTER) * 2 * spread;\n}\n\n/**\n * Builds the human-feeling path between two points as a list of intermediate points; the caller paces them out over real wall-clock time so a screen recording actually shows the glide. The last point is always exactly `to`, regardless of jitter or overshoot.\n */\nexport function buildHumanPath(\n from: Readonly<Point>,\n to: Readonly<Point>,\n): Point[] {\n const dx = to.x - from.x;\n const dy = to.y - from.y;\n const distance = Math.hypot(dx, dy);\n if (distance < 2) return [to];\n\n // More steps for longer distances, bounded by MIN_PATH_STEPS and MAX_PATH_STEPS above. Roughly one path point per this many pixels of travel.\n const PATH_STEP_DISTANCE_PX = 14;\n const steps = Math.max(\n MIN_PATH_STEPS,\n Math.min(MAX_PATH_STEPS, Math.round(distance / PATH_STEP_DISTANCE_PX)),\n );\n // Perpendicular unit vector, for the mid-path bow.\n const perpX = -dy / distance;\n const perpY = dx / distance;\n const MAX_BOW_MAGNITUDE_PX = 40;\n // The bow scales with distance, up to the cap above.\n const BOW_MAGNITUDE_RATIO = 0.12;\n // Coin flip: bow left or right of the direct line.\n const BOW_DIRECTION_PROBABILITY = 0.5;\n const bowMagnitude =\n Math.min(MAX_BOW_MAGNITUDE_PX, distance * BOW_MAGNITUDE_RATIO) *\n (Math.random() < BOW_DIRECTION_PROBABILITY ? 1 : -1);\n // Only longer moves are eligible to overshoot.\n const OVERSHOOT_MIN_DISTANCE_PX = 180;\n const OVERSHOOT_PROBABILITY = 0.6;\n const overshoot =\n distance > OVERSHOOT_MIN_DISTANCE_PX &&\n Math.random() < OVERSHOOT_PROBABILITY;\n const OVERSHOOT_MIN_PX = 6;\n const OVERSHOOT_RANDOM_RANGE_PX = 10;\n const overshootPx = overshoot\n ? OVERSHOOT_MIN_PX + Math.random() * OVERSHOOT_RANDOM_RANGE_PX\n : 0;\n // Fraction of the path where the overshoot begins, and where it has fully settled.\n const OVERSHOOT_WINDOW_START_T = 0.8;\n const OVERSHOOT_WINDOW_END_T = 0.97;\n // OVERSHOOT_WINDOW_END_T minus OVERSHOOT_WINDOW_START_T, used to normalise t across the window.\n const OVERSHOOT_WINDOW_WIDTH_T = 0.17;\n const PATH_POINT_JITTER_PX = 0.6;\n\n const path: Point[] = [];\n for (let i = 1; i <= steps; i++) {\n const t = i / steps;\n // Accelerate away from the start, decelerate into the target; the curve itself is easeInOutCubic below.\n const eased = easeInOutCubic(t);\n // The bow peaks at the path's midpoint and fades to zero at both ends.\n const bow = Math.sin(t * Math.PI) * bowMagnitude;\n let px = from.x + dx * eased + perpX * bow;\n let py = from.y + dy * eased + perpY * bow;\n if (\n overshoot &&\n t > OVERSHOOT_WINDOW_START_T &&\n t < OVERSHOOT_WINDOW_END_T\n ) {\n // 0 -> 1 across the overshoot window.\n const overshootT =\n (t - OVERSHOOT_WINDOW_START_T) / OVERSHOOT_WINDOW_WIDTH_T;\n // Out and back.\n const settle = Math.sin(overshootT * Math.PI);\n px += (dx / distance) * overshootPx * settle;\n py += (dy / distance) * overshootPx * settle;\n }\n path.push({\n x: px + jitter(PATH_POINT_JITTER_PX),\n y: py + jitter(PATH_POINT_JITTER_PX),\n });\n }\n // Land exactly on target regardless of jitter/overshoot.\n path[path.length - 1] = to;\n\n return path;\n}\n\n/**\n * Ease-in-out cubic, shared with naturalScrollIntoView's own identical curve: a real scroll (wheel or trackpad) accelerates away from rest and decelerates into rest, it doesn't move at constant speed.\n */\nexport function easeInOutCubic(t: number): number {\n // Fraction of progress where the curve switches from accelerating to decelerating.\n const EASE_MIDPOINT_T = 0.5;\n // Scales the accelerating half's t^3 term.\n const EASE_IN_CUBIC_SCALE = 4;\n // Coefficient of t inside the decelerating half's inner expression, and the exponent applied to that inner expression.\n const EASE_OUT_LINEAR_COEFFICIENT = -2;\n const EASE_OUT_CUBIC_EXPONENT = 3;\n\n return t < EASE_MIDPOINT_T\n ? EASE_IN_CUBIC_SCALE * t * t * t\n : 1 -\n Math.pow(EASE_OUT_LINEAR_COEFFICIENT * t + 2, EASE_OUT_CUBIC_EXPONENT) /\n 2;\n}\n","import { type Page } from \"@playwright/test\";\n\n// The narration timeline behind the footer band: each showCaption/hideCaption call is timestamped against the recording's own start, and the framing pass later turns that event list into \"this text was on screen from here to here\" overlay windows. Keeping it as per-page WeakMap state (rather than threading it through every helper signature) is what lets both the test-runner adapter and the standalone runner share one helper surface.\n\nexport interface CaptionEvent {\n atMs: number;\n text: string | null;\n}\n\nexport interface RecordingTimeline {\n startedAt: number;\n events: CaptionEvent[];\n}\n\n// Per-page recording start time (t0) and narration timeline, keyed the way cursor positions are (see cursor.ts): per-page state that must survive across many helper calls without being threaded through every function signature.\nconst recordingStartedAt = new WeakMap<Page, number>();\nconst captionTimeline = new WeakMap<Page, CaptionEvent[]>();\n\n/**\n * Called once, as early as possible (installCursor's own first line). First call wins, so a demo that navigates before installing the overlay doesn't shift t0 later than the recording's own real start.\n */\nexport function markRecordingStart(page: Page): void {\n if (!recordingStartedAt.has(page)) recordingStartedAt.set(page, Date.now());\n}\n\n/**\n * Appends one narration event for the page: a string shows that caption from now, null hides whatever is showing. Called by showCaption/hideCaption; direct use is only for advanced driving.\n */\nexport function recordCaptionEvent(page: Page, text: string | null): void {\n const startedAt = recordingStartedAt.get(page) ?? Date.now();\n const events = captionTimeline.get(page) ?? [];\n events.push({ atMs: Date.now() - startedAt, text });\n captionTimeline.set(page, events);\n}\n\n/**\n * Snapshot for the framing pass. Read after the context has closed is fine: the map is keyed by the Page object itself, which outlives its browser context.\n */\nexport function getRecordingTimeline(page: Page): RecordingTimeline {\n return {\n startedAt: recordingStartedAt.get(page) ?? Date.now(),\n events: captionTimeline.get(page) ?? [],\n };\n}\n","import { type Locator, type Page } from \"@playwright/test\";\nimport { buildHumanPath, easeInOutCubic, type Point } from \"./human-path\";\nimport { markRecordingStart, recordCaptionEvent } from \"./timeline\";\n\n// Playwright drives the DOM directly and never moves a visible pointer, so a screen recording of an automated run shows silent jump-cuts with no indication of what was clicked or how. This installs a synthetic cursor overlay (a dot that tracks real `mousemove` events, animates on click, and persists its position across navigations instead of resetting to the corner), glides the cursor along a slightly curved, eased, jittered path rather than a robotic straight-line teleport, and exposes moveAndClick/fillVisibly/gotoWithCursor/showCaption/hideCaption helpers built on top, so recordings made with them read as a person driving the UI with someone narrating over their shoulder, not a silent script. Narration itself is not an in-page overlay: showCaption/hideCaption below timestamp the narration text against the recording's own start time (see ./timeline), and the actual banner is composited into a dedicated band below the recorded app window during post-processing (see ./frame for why an in-page overlay can't produce that layout on its own).\n\n// TALKTRACK_OVERLAY_INIT runs inside the page (via page.evaluate/addInitScript), so it attaches its handles to the real browser `window` rather than returning them. This augmentation is what lets moveAndClick below call window.__talktrackPress directly, with no `as unknown as` cast.\ndeclare global {\n interface Window {\n __talktrackPress?: (x: number, y: number) => void;\n __talktrackRelease?: () => void;\n }\n}\n\nconst TALKTRACK_OVERLAY_INIT = () => {\n if (document.getElementById(\"__talktrack_cursor__\")) return;\n const style = document.createElement(\"style\");\n style.textContent = `\n #__talktrack_cursor__ {\n position: fixed; top: 0; left: 0; width: 22px; height: 22px;\n border-radius: 50%; background: rgba(220,38,38,0.88);\n border: 2px solid white; box-shadow: 0 1px 5px rgba(0,0,0,0.6);\n pointer-events: none; z-index: 2147483647; transform: translate(-50%, -50%) scale(1);\n transition: transform 0.12s ease-out, background-color 0.12s ease-out;\n }\n #__talktrack_cursor__.__talktrack_pressed__ {\n transform: translate(-50%, -50%) scale(0.55);\n background: rgba(153,27,27,0.95);\n }\n .__talktrack_ripple__ {\n position: fixed; width: 14px; height: 14px; border-radius: 50%;\n border: 3px solid rgba(220,38,38,0.9); pointer-events: none; z-index: 2147483647;\n transform: translate(-50%, -50%); animation: __talktrack_ripple_anim__ 0.55s ease-out forwards;\n }\n @keyframes __talktrack_ripple_anim__ {\n from { width: 14px; height: 14px; opacity: 1; }\n to { width: 58px; height: 58px; opacity: 0; }\n }\n `;\n document.head.appendChild(style);\n const cursor = document.createElement(\"div\");\n cursor.id = \"__talktrack_cursor__\";\n document.body.appendChild(cursor);\n window.addEventListener(\n \"mousemove\",\n (e) => {\n cursor.style.left = `${String(e.clientX)}px`;\n cursor.style.top = `${String(e.clientY)}px`;\n },\n true,\n );\n window.__talktrackPress = (x, y) => {\n cursor.classList.add(\"__talktrack_pressed__\");\n const ripple = document.createElement(\"div\");\n ripple.className = \"__talktrack_ripple__\";\n ripple.style.left = `${String(x)}px`;\n ripple.style.top = `${String(y)}px`;\n document.body.appendChild(ripple);\n const RIPPLE_REMOVE_DELAY_MS = 600;\n setTimeout(() => {\n ripple.remove();\n }, RIPPLE_REMOVE_DELAY_MS);\n };\n window.__talktrackRelease = () => {\n cursor.classList.remove(\"__talktrack_pressed__\");\n };\n};\n\n// Per-page last-known cursor position, so a navigation can restore it on the fresh document instead of the overlay resetting to the top-left corner, and so the next glide starts from where the cursor actually is rather than an arbitrary origin.\nconst currentPosition = new WeakMap<Page, Point>();\n\nasync function glideTo(page: Page, to: Readonly<Point>): Promise<void> {\n const from = currentPosition.get(page) ?? { x: to.x, y: to.y };\n const path = buildHumanPath(from, to);\n const MOVE_STEP_MIN_DELAY_MS = 12;\n const MOVE_STEP_DELAY_RANGE_MS = 10;\n for (const point of path) {\n await page.mouse.move(point.x, point.y);\n // ~12-22ms per step: roughly natural mouse-sampling pace, with enough jitter that consecutive glides don't all take an identical, obviously-scripted duration.\n await page.waitForTimeout(\n MOVE_STEP_MIN_DELAY_MS + Math.random() * MOVE_STEP_DELAY_RANGE_MS,\n );\n }\n currentPosition.set(page, to);\n}\n\n// page.evaluate right after a navigation races the document it targets (a redirect or a late client transition can swap the page out from under it), which throws \"Execution context was destroyed\". That's expected, not a real failure of this cosmetic helper, so it's tolerated here rather than aborting the whole recording over it.\nasync function tolerateNavigationRace<T>(\n promise: Readonly<Promise<T>>,\n): Promise<T | undefined> {\n return promise.catch(() => undefined);\n}\n\n/**\n * Playwright's own locator.scrollIntoViewIfNeeded() is a synchronous DOM scrollIntoView: an instant jump-cut in a recording, not something a person watching would recognise as scrolling at all. This drives a real sequence of wheel events instead, eased and paced like glideTo's own mouse path, so a recording shows the page actually gliding to the target rather than teleporting to it. Exported (not just used internally by moveAndClick below) so a demo can bring a target into view on its own, with no click attached (for example before narrating over a tall diagram whose own heading satisfies a \"content rendered\" assertion long before the diagram's body, the actual thing being narrated, has scrolled into the visible viewport).\n */\nexport async function naturalScrollIntoView(\n page: Page,\n locator: Readonly<Locator>,\n): Promise<void> {\n const viewport = page.viewportSize();\n if (!viewport) return;\n\n // Real wheel events hit-test against whatever scrollable element is under the CURRENT mouse position, not the target: moving the mouse toward the viewport's own centre first (rather than wherever the previous click left it) makes that hit-test land on the main scrollable content area rather than, say, a fixed sidebar nav the cursor happens to still be resting over.\n const hoverPoint = { x: viewport.width / 2, y: viewport.height / 2 };\n await tolerateNavigationRace(page.mouse.move(hoverPoint.x, hoverPoint.y));\n\n const box = await tolerateNavigationRace(locator.boundingBox());\n // Let the caller's own click flow surface a clear \"no bounding box\" error instead of failing silently here.\n if (!box) return;\n\n // A comfortable margin from the viewport edge, not the literal edge: a person scrolls until they can see the thing clearly, not until it's pixel-perfect at the boundary.\n const margin = 96;\n // Already comfortably in view: nothing to scroll.\n if (box.y >= margin && box.y + box.height <= viewport.height - margin) return;\n\n // Scroll enough to land the target roughly centred, not pinned to the very top, matching how a person actually scrolls toward something they want to interact with next.\n const totalDelta = box.y + box.height / 2 - viewport.height / 2;\n const MIN_SCROLL_DELTA_PX = 12;\n if (Math.abs(totalDelta) < MIN_SCROLL_DELTA_PX) return;\n\n const MIN_SCROLL_STEPS = 7;\n const MAX_SCROLL_STEPS = 26;\n const PIXELS_PER_SCROLL_STEP = 55;\n const steps = Math.max(\n MIN_SCROLL_STEPS,\n Math.min(\n MAX_SCROLL_STEPS,\n Math.round(Math.abs(totalDelta) / PIXELS_PER_SCROLL_STEP),\n ),\n );\n let deliveredSoFar = 0;\n const SCROLL_STEP_MIN_DELAY_MS = 14;\n const SCROLL_STEP_DELAY_RANGE_MS = 12;\n for (let i = 1; i <= steps; i++) {\n const eased = easeInOutCubic(i / steps);\n const target = totalDelta * eased;\n const stepDelta = target - deliveredSoFar;\n deliveredSoFar = target;\n await page.mouse.wheel(0, stepDelta);\n // ~14-26ms per step: a real scroll-wheel/trackpad delivers a burst of small deltas at roughly this cadence, with jitter so consecutive scrolls in the same recording don't all take an identical, obviously-scripted duration.\n await page.waitForTimeout(\n SCROLL_STEP_MIN_DELAY_MS + Math.random() * SCROLL_STEP_DELAY_RANGE_MS,\n );\n }\n // A brief settle beat after the scroll stops, the way a person pauses a moment before their next action rather than clicking mid-scroll.\n const SCROLL_SETTLE_MIN_DELAY_MS = 140;\n const SCROLL_SETTLE_DELAY_RANGE_MS = 120;\n await page.waitForTimeout(\n SCROLL_SETTLE_MIN_DELAY_MS + Math.random() * SCROLL_SETTLE_DELAY_RANGE_MS,\n );\n}\n\nasync function applyCursor(page: Page): Promise<void> {\n await tolerateNavigationRace(page.evaluate(TALKTRACK_OVERLAY_INIT));\n const pos = currentPosition.get(page);\n if (!pos) return;\n await tolerateNavigationRace(\n page.evaluate(\n ([x, y]) => {\n const cursor = document.getElementById(\"__talktrack_cursor__\");\n if (cursor) {\n cursor.style.left = `${String(x)}px`;\n cursor.style.top = `${String(y)}px`;\n }\n },\n [pos.x, pos.y] as const,\n ),\n );\n // Keeps Playwright's own virtual mouse position in sync with the overlay: otherwise the next glide would start from (0,0), not from where the cursor visually is.\n await tolerateNavigationRace(page.mouse.move(pos.x, pos.y, { steps: 1 }));\n}\n\n/**\n * Re-injects on every navigation (addInitScript) and once immediately for the current document, since addInitScript only applies to documents created after it's registered. Also marks the recording's own t0 (see ./timeline): every demo calls this as its very first action, which is the closest available proxy for \"when the video recording actually began\" without needing frame-exact precision a demo narration track doesn't need anyway.\n */\nexport async function installCursor(page: Page): Promise<void> {\n markRecordingStart(page);\n await page.addInitScript(TALKTRACK_OVERLAY_INIT);\n await applyCursor(page);\n}\n\n/**\n * Use in place of page.goto() for any navigation after installCursor(): restores the cursor to its last known state on the new document instead of letting it reset, so navigating doesn't read as a visual glitch in the recording.\n */\nexport async function gotoWithCursor(page: Page, url: string): Promise<void> {\n await page.goto(url);\n await applyCursor(page);\n}\n\n/**\n * Use in place of page.reload() for any navigation after installCursor(): restores the cursor to its last known state on the new document the same way gotoWithCursor does for page.goto(). A bare page.reload() in a demo that has already called installCursor() is the one navigation path the overlay can't cover on its own: addInitScript recreates the cursor DOM element on the fresh document (so it doesn't vanish outright), but nothing repositions it, so a reload leaves the cursor visibly pinned to the corner until the next moveAndClick's glide happens to move it (confirmed live as the cause of a cursor going missing mid-recording during a demo's own periodic safety-net reloads).\n */\nexport async function reloadWithCursor(page: Page): Promise<void> {\n await page.reload();\n await applyCursor(page);\n}\n\n/**\n * Sets (or updates) the narration shown in the footer band below the recorded app window; see ./timeline for how this timestamped event actually becomes an on-screen banner during post-processing. Pass null (or call hideCaption) to clear it between unrelated steps rather than leaving a stale caption shown throughout the gap.\n */\nexport function showCaption(page: Page, text: string): void {\n recordCaptionEvent(page, text);\n}\n\n/**\n * Clears the narration band (closes the window the last showCaption opened). Hide between unrelated steps rather than leaving a stale caption shown throughout the gap.\n */\nexport function hideCaption(page: Page): void {\n recordCaptionEvent(page, null);\n}\n\n// +/- ~25% jitter around a base duration, so repeated pauses in a recording don't all read as the exact same, obviously-scripted length.\nfunction humanPause(baseMs: number): number {\n const PAUSE_JITTER_MIN_MULTIPLIER = 0.75;\n const PAUSE_JITTER_RANGE_MULTIPLIER = 0.5;\n\n return Math.round(\n baseMs *\n (PAUSE_JITTER_MIN_MULTIPLIER +\n Math.random() * PAUSE_JITTER_RANGE_MULTIPLIER),\n );\n}\n\n/**\n * Moves the cursor humanly to the locator (scrolling it into view first), clicks it, and holds a jittered pause afterwards. Use in place of locator.click() in any recording.\n */\nexport async function moveAndClick(\n page: Page,\n locator: Readonly<Locator>,\n pauseMs = 250,\n): Promise<void> {\n await naturalScrollIntoView(page, locator);\n const box = await locator.boundingBox();\n if (!box)\n throw new Error(\"Could not resolve a bounding box for the click target\");\n // Aim slightly off dead-centre, the way a person rarely clicks a target's exact geometric middle, while staying comfortably inside the element's own bounds.\n const CLICK_TARGET_MIN_OFFSET_RATIO = 0.4;\n const CLICK_TARGET_OFFSET_RANGE_RATIO = 0.2;\n const target = {\n x:\n box.x +\n box.width *\n (CLICK_TARGET_MIN_OFFSET_RATIO +\n Math.random() * CLICK_TARGET_OFFSET_RANGE_RATIO),\n y:\n box.y +\n box.height *\n (CLICK_TARGET_MIN_OFFSET_RATIO +\n Math.random() * CLICK_TARGET_OFFSET_RANGE_RATIO),\n };\n await glideTo(page, target);\n const HOVER_BEFORE_CLICK_MS = 180;\n // A brief hover before committing to the click.\n await page.waitForTimeout(humanPause(HOVER_BEFORE_CLICK_MS));\n await tolerateNavigationRace(\n page.evaluate(([px, py]) => window.__talktrackPress?.(px, py), [\n target.x,\n target.y,\n ] as const),\n );\n // The actual click goes through locator.click(), not raw page.mouse.down()/up() at the coordinates captured above. Those coordinates are a snapshot from before the glide animation and hover pause, which together take several hundred milliseconds; long enough for an async-populated dropdown (options that load after mount) to re-render and shift the option out from under a stale coordinate. Raw mouse events have no actionability check the way locator.click() does, so a stale target silently clicks empty space (or the wrong element) instead of failing loudly or retargeting (confirmed live: this silently produced an unselected field with no error anywhere in the run). locator.click() re-resolves the element and waits for it to be stable/attached/visible immediately before dispatching, which is exactly the robustness raw coordinates can't provide; { force: true } is not used, so a genuinely non-actionable target still fails loudly rather than clicking through.\n await locator.click();\n const PAUSE_AFTER_CLICK_MS = 100;\n await page.waitForTimeout(humanPause(PAUSE_AFTER_CLICK_MS));\n await tolerateNavigationRace(\n page.evaluate(() => window.__talktrackRelease?.()),\n );\n // The click itself may have triggered a hard navigation (a plain <a> link, a form submit's redirect): that swaps in a fresh document, and addInitScript's TALKTRACK_OVERLAY_INIT creates a new cursor at its unset default (pinned to the top-left corner) until something restores it. A single check right after mouse.up() isn't reliable, because the click can take a beat to actually start navigating, so that one check can land before the new document even exists, restoring onto the page that's about to be torn down anyway rather than the one that replaces it. Reapplying a few times over the following stretch means whichever check lands after the real swap picks it back up almost immediately, instead of leaving it missing until some later action reveals it. For a soft navigation or no navigation at all, every extra check is a harmless no-op: the cursor already exists at the right position.\n const CURSOR_REAPPLY_ATTEMPTS = 4;\n const CURSOR_REAPPLY_INTERVAL_MS = 90;\n for (let i = 0; i < CURSOR_REAPPLY_ATTEMPTS; i++) {\n // currentPosition already holds `target`, set by glideTo above.\n await applyCursor(page);\n await page.waitForTimeout(CURSOR_REAPPLY_INTERVAL_MS);\n }\n await page.waitForTimeout(humanPause(pauseMs));\n}\n\n// Per-character delay for typeCharByChar, centred around a real, fast-but-plausible typing speed (~45ms/char average is roughly 210 characters/minute, comfortably within normal touch-typing range), with real per-character variance rather than a single fixed delay, plus a slightly longer beat after a space (the natural word-boundary pause) and an occasional, longer \"thinking\" pause mid-string, the way someone typing a value they're actively composing (a path, a flag list) rather than reciting from memory does.\nasync function typeCharByChar(page: Page, text: string): Promise<void> {\n const CHAR_TYPE_MIN_DELAY_MS = 28;\n const CHAR_TYPE_DELAY_RANGE_MS = 55;\n const SPACE_PAUSE_MIN_DELAY_MS = 40;\n const SPACE_PAUSE_DELAY_RANGE_MS = 60;\n const THINKING_PAUSE_PROBABILITY = 0.04;\n const THINKING_PAUSE_MIN_DELAY_MS = 200;\n const THINKING_PAUSE_DELAY_RANGE_MS = 400;\n for (const ch of text) {\n await page.keyboard.type(ch);\n let delay =\n CHAR_TYPE_MIN_DELAY_MS + Math.random() * CHAR_TYPE_DELAY_RANGE_MS;\n if (ch === \" \")\n delay +=\n SPACE_PAUSE_MIN_DELAY_MS + Math.random() * SPACE_PAUSE_DELAY_RANGE_MS;\n // An occasional longer \"thinking\" pause.\n if (Math.random() < THINKING_PAUSE_PROBABILITY)\n delay +=\n THINKING_PAUSE_MIN_DELAY_MS +\n Math.random() * THINKING_PAUSE_DELAY_RANGE_MS;\n await page.waitForTimeout(delay);\n }\n}\n\n/**\n * Clicks into the locator and types the text character by character at human speed, so a recording shows the value appearing as it's typed rather than arriving at once.\n */\nexport async function fillVisibly(\n page: Page,\n locator: Readonly<Locator>,\n text: string,\n pauseMs = 300,\n): Promise<void> {\n const PRE_FILL_CLICK_PAUSE_MS = 150;\n await moveAndClick(page, locator, humanPause(PRE_FILL_CLICK_PAUSE_MS));\n // Clears any existing value in one step: clearing isn't the part worth watching character by character, typing the new value is.\n await locator.fill(\"\");\n await typeCharByChar(page, text);\n await page.waitForTimeout(humanPause(pauseMs));\n}\n\n/**\n * Exported so demos can pace their own non-interaction \"reading\" pauses (for example holding on a state that just changed) with the same jittered, human-feeling timing.\n */\nexport function humanBeatMs(baseMs: number): number {\n return humanPause(baseMs);\n}\n\n/**\n * Waits a jittered pause of roughly `ms` milliseconds on the page, for \"reading\" beats between interactions.\n */\nexport async function beat(page: Page, ms = 600): Promise<void> {\n await page.waitForTimeout(humanBeatMs(ms));\n}\n\n// Replaces the two shapes a demo would otherwise hand-roll around showCaption/hideCaption: caption-then-fixed-sleep-then-action (the sleep is unrelated to any page state, so the action can start before or long after the caption's own hold ends) and assert-fully-then-caption (no lead-in at all, since the caption only ever appears once its claim is already true). Neither ties a caption's own on-screen duration to genuine page-state readiness.\n//\n// showCaption fires immediately, so the caption is up before `settle` (whatever assertion, poll, or action already proves the caption's own claim) has resolved: a real lead-in, not a guess. `settle` and a minimum `leadInMs` floor run concurrently, so a `settle` that resolves in under `leadInMs` still gets a caption that's been up for at least that long, and a `settle` that takes minutes (a capacity poll, say) keeps the caption up for the whole genuine wait rather than a short fixed hold that's already elapsed by the time anything real happens. Once `settle` resolves, the caption holds for `dwellMs` (a real \"look at this\" pause) before hiding: exactly the digest window a fixed-hold constant was always trying to give, just now added on top of true settle time rather than instead of it.\nconst NARRATE_SCENE_DEFAULT_LEAD_IN_MS = 800;\nconst NARRATE_SCENE_DEFAULT_DWELL_MS = 1200;\n/**\n * Shows a caption, waits for a real page-state condition to settle while the caption stays up, holds a digest dwell, then hides it. The caption's on-screen duration is tied to genuine readiness (the `settle` promise) rather than a fixed sleep: `leadInMs` is a minimum floor running concurrently with `settle`, and `dwellMs` is the \"look at this\" hold after it resolves.\n */\nexport async function narrateScene<T>(\n page: Page,\n text: string,\n settle: () => Promise<T>,\n opts?: { leadInMs?: number; dwellMs?: number },\n): Promise<T> {\n const leadInMs = opts?.leadInMs ?? NARRATE_SCENE_DEFAULT_LEAD_IN_MS;\n const dwellMs = opts?.dwellMs ?? NARRATE_SCENE_DEFAULT_DWELL_MS;\n showCaption(page, text);\n const [result] = await Promise.all([settle(), beat(page, leadInMs)]);\n await beat(page, dwellMs);\n hideCaption(page);\n\n return result;\n}\n","import { type BrowserContext } from \"@playwright/test\";\n\n/**\n * Styling of the header and footer bands composited around the recorded app window. The defaults are deliberately unopinionated (a dark neutral band with the system font stack); override any subset through the `bands` option on finalizeFramedRecording/recordDemo to match the application being recorded.\n */\nexport interface BandStyle {\n headerHeightPx: number;\n footerHeightPx: number;\n background: string;\n textColor: string;\n fontStack: string;\n headerFontSizePx: number;\n footerFontSizePx: number;\n}\n\n/** The band look talktrack frames recordings with when no `bands` override is given. */\nexport const defaultBandStyle: BandStyle = {\n headerHeightPx: 48,\n footerHeightPx: 96,\n background: \"#111827\",\n textColor: \"#f8fafc\",\n fontStack:\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif',\n headerFontSizePx: 18,\n footerFontSizePx: 20,\n};\n\n/**\n * Merges a caller's partial band overrides over {@link defaultBandStyle}; an absent overrides object yields the defaults unchanged.\n */\nexport function resolveBandStyle(overrides?: Partial<BandStyle>): BandStyle {\n return { ...defaultBandStyle, ...overrides };\n}\n\n/**\n * Escapes the five characters HTML treats as markup, so arbitrary narration text can be embedded in the band page's template safely.\n */\nexport function escapeHtml(text: string): string {\n return text\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\n/** Everything one composited band needs: its pixel size, its text, and the font size to render that text at. */\nexport interface TextBandSpec {\n width: number;\n height: number;\n fontSizePx: number;\n text: string;\n}\n\n/**\n * Renders one band as a PNG. Takes a dedicated throwaway BrowserContext, not the demo's own page/context: a context recording video (recordVideo or Playwright Test's video option) records every page opened within it, so rendering the bands through the demo's own context would leave junk video files alongside the real recording. A fresh context with no video option set (off by default) avoids that entirely.\n *\n * Text is rendered as an ordinary PNG screenshot of that throwaway page rather than via ffmpeg's drawtext filter, since drawtext needs libfreetype/libfontconfig support that common ffmpeg builds (a plain Homebrew install, for instance) don't ship. Rendering text via the same browser already driving the recording sidesteps that dependency and gives exact font parity with the recorded application for free.\n */\nexport async function renderTextBand(\n bandContext: BrowserContext,\n style: Readonly<BandStyle>,\n band: Readonly<TextBandSpec>,\n): Promise<Buffer> {\n const bandPage = await bandContext.newPage();\n try {\n await bandPage.setViewportSize({\n width: band.width,\n height: band.height,\n });\n await bandPage.setContent(`<!doctype html>\n<html><head><style>\n html, body { margin: 0; width: ${String(band.width)}px; height: ${String(band.height)}px; background: ${style.background}; display: flex; align-items: center; justify-content: center; }\n p { margin: 0; padding: 0 28px; max-width: 100%; box-sizing: border-box; color: ${style.textColor}; font: 500 ${String(band.fontSizePx)}px/1.35 ${style.fontStack}; text-align: center; overflow-wrap: break-word; }\n</style></head>\n<body><p>${escapeHtml(band.text)}</p></body></html>`);\n\n return await bandPage.screenshot({ type: \"png\" });\n } finally {\n await bandPage.close();\n }\n}\n","import type { BandStyle } from \"./bands\";\nimport type { CaptionEvent } from \"./timeline\";\n\n// The pure half of the framing pass: turning a caption timeline into (a) concrete on-screen windows and (b) an ffmpeg filter graph, with no browser, no filesystem and no ffmpeg involved. Everything here is unit-testable; everything that touches the outside world lives in frame.ts.\n\nexport interface CaptionWindow {\n text: string;\n startMs: number;\n endMs: number;\n}\n\n/**\n * A window opens at each show event and closes at whatever event follows it (an explicit hide, or a subsequent show replacing the line), or at the video's own end if nothing follows. A hide event only ever closes the preceding show's window: it opens no window of its own.\n */\nexport function captionWindows(\n events: readonly CaptionEvent[],\n videoDurationMs: number,\n): CaptionWindow[] {\n const windows: CaptionWindow[] = [];\n for (const [i, event] of events.entries()) {\n if (event.text === null) continue;\n const nextEvent = events[i + 1];\n windows.push({\n text: event.text,\n startMs: event.atMs,\n endMs: nextEvent === undefined ? videoDurationMs : nextEvent.atMs,\n });\n }\n\n return windows;\n}\n\nconst MS_PER_SECOND = 1000;\n// ffmpeg's between(t,start,end) expression accepts fractional seconds; millisecond precision is more than enough for a narration cue.\nconst FFMPEG_TIME_DECIMAL_PLACES = 3;\n\n/** The ffmpeg inputs and filter graph one framing pass drives: how many -i inputs the command takes, the graph string itself, and the label its final overlay writes. */\nexport interface FramePlan {\n inputCount: number;\n filterGraph: string;\n finalLabel: string;\n}\n\n/**\n * Input 0 is the raw recording, input 1 the header band PNG, inputs 2.. the caption band PNGs in window order. The graph pads input 0 onto a taller canvas of band colour (header band above, footer band below), overlays the header at the top, then chains one time-gated overlay per caption window onto the footer region.\n */\nexport function buildFramePlan(\n viewport: Readonly<{ width: number; height: number }>,\n style: Readonly<BandStyle>,\n windows: readonly CaptionWindow[],\n): FramePlan {\n const padHeight =\n viewport.height + style.headerHeightPx + style.footerHeightPx;\n let filterGraph = `[0:v]pad=${String(viewport.width)}:${String(padHeight)}:0:${String(style.headerHeightPx)}:color=${style.background}[bg];[bg][1:v]overlay=0:0[v0]`;\n let lastLabel = \"v0\";\n\n let inputIndex = 2;\n for (const [i, window] of windows.entries()) {\n const startS = (window.startMs / MS_PER_SECOND).toFixed(\n FFMPEG_TIME_DECIMAL_PLACES,\n );\n const endS = (window.endMs / MS_PER_SECOND).toFixed(\n FFMPEG_TIME_DECIMAL_PLACES,\n );\n const nextLabel = `v${String(i + 1)}`;\n filterGraph += `;[${lastLabel}][${String(inputIndex)}:v]overlay=0:${String(style.headerHeightPx + viewport.height)}:enable='between(t,${startS},${endS})'[${nextLabel}]`;\n lastLabel = nextLabel;\n inputIndex++;\n }\n\n return { inputCount: inputIndex, filterGraph, finalLabel: lastLabel };\n}\n","import { execFile } from \"node:child_process\";\n\n// A hand-written wrapper rather than util.promisify(execFile): execFile's own overloaded callback signature (its non-callback form returns a live ChildProcess, distinct from the callback form promisify actually targets) trips @typescript-eslint/strict-void-return on the promisified result. This sidesteps the overload ambiguity entirely rather than suppressing the lint rule.\nasync function runCommand(\n command: string,\n args: readonly string[],\n label: string,\n): Promise<string> {\n return await new Promise<string>((resolve, reject) => {\n execFile(command, args, (error, stdout) => {\n // ExecFileException is a mapped/Omit-derived type over NodeJS.ErrnoException, not a literal `extends Error` class chain: wrapping it as the `cause` of a real Error satisfies the rejection-must-be-an-Error rule and preserves the original message/stack.\n if (error) {\n // ENOENT gets its own message because \"spawn ffmpeg ENOENT\" names the wrong thing to fix: the missing piece is the documented external binary, not the spawn call.\n const reason =\n error.code === \"ENOENT\"\n ? `${label} not found on PATH. talktrack drives the external ${label} binary for video post-processing; install it (for example \"brew install ffmpeg\" or \"apt-get install ffmpeg\") and retry.`\n : `${label} failed: ${error.message}`;\n reject(new Error(reason, { cause: error }));\n } else {\n resolve(stdout);\n }\n });\n });\n}\n\n/**\n * Runs the external ffmpeg binary with the given arguments. Rejects with an install hint rather than a bare spawn error when ffmpeg is not on PATH.\n */\nexport async function runFfmpeg(args: readonly string[]): Promise<string> {\n return await runCommand(\"ffmpeg\", args, \"ffmpeg\");\n}\n\n/** The dimensions and duration ffprobe reports for a video file. */\nexport interface VideoFacts {\n durationMs: number;\n width: number;\n height: number;\n}\n\n/**\n * Throws unless the recorded video's pixel size equals the viewport the bands are laid out against. Playwright records a smaller video than the viewport unless `video.size` is set to match (its default scales the recording to fit inside 800x800), and the framing pass would then pad that smaller picture onto a canvas sized for the full viewport: a silently mis-laid-out result that still looks like a successful run.\n */\nexport function assertVideoMatchesViewport(\n facts: Readonly<VideoFacts>,\n viewport: Readonly<{ width: number; height: number }>,\n videoPath: string,\n): void {\n if (facts.width === viewport.width && facts.height === viewport.height)\n return;\n throw new Error(\n `talktrack cannot frame ${videoPath}: the recording is ${String(facts.width)}x${String(facts.height)} but the viewport is ${String(viewport.width)}x${String(viewport.height)}. Set the recording size to the viewport size (Playwright Test: video: { mode: \"on\", size }; recordDemo does this itself) so the bands line up with the picture.`,\n );\n}\n\n/**\n * Narrows a parsed JSON value to a plain object, per the house unknown-handling rule: never an assertion, always a guard.\n */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * Probes a video file with ffprobe for its pixel dimensions and duration in milliseconds. Any output shape other than the documented stream+format JSON object rejects with the path and what was wrong.\n */\nexport async function probeVideo(videoPath: string): Promise<VideoFacts> {\n const stdout = await runCommand(\n \"ffprobe\",\n [\n \"-v\",\n \"error\",\n \"-select_streams\",\n \"v:0\",\n \"-show_entries\",\n \"stream=width,height:format=duration\",\n \"-of\",\n \"json\",\n videoPath,\n ],\n \"ffprobe\",\n );\n // ffprobe -of json prints { streams: [{ width, height }], format: { duration } } with the duration as a decimal-seconds string.\n const parsed: unknown = JSON.parse(stdout);\n if (!isRecord(parsed)) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: not a JSON object`,\n );\n }\n const streams = parsed.streams;\n const format = parsed.format;\n if (!Array.isArray(streams) || !isRecord(format)) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: streams is not an array or format is not an object`,\n );\n }\n const firstStream: unknown = streams[0];\n if (!isRecord(firstStream)) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: first stream is not an object`,\n );\n }\n const width = firstStream.width;\n const height = firstStream.height;\n const durationRaw = format.duration;\n if (\n typeof width !== \"number\" ||\n typeof height !== \"number\" ||\n typeof durationRaw !== \"string\"\n ) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: stream width/height or format.duration has the wrong type`,\n );\n }\n const MS_PER_SECOND = 1000;\n\n return {\n durationMs: Math.round(Number.parseFloat(durationRaw) * MS_PER_SECOND),\n width,\n height,\n };\n}\n","import { execFile } from \"node:child_process\";\nimport { mkdtemp, rm, writeFile } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { type Browser, type Page } from \"@playwright/test\";\nimport { renderTextBand, resolveBandStyle, type BandStyle } from \"./bands\";\nimport { assertVideoMatchesViewport, probeVideo, runFfmpeg } from \"./ffmpeg\";\nimport { buildFramePlan, captionWindows } from \"./filter-graph\";\nimport { getRecordingTimeline } from \"./timeline\";\n\n// The framing pass. Playwright's video recorder captures exactly the browser viewport's own pixels, so an in-page \"narrate below the app\" or \"commit/date above the app\" banner is a contradiction in terms: anything the page itself renders is, by definition, inside the recorded viewport. This module produces that layout as a genuinely separate post-processing pass instead: the raw recording stays exactly the app's own window, and frameRecording pads a taller canvas around it (a static header band and a footer band that shows each narration line only for the real span of time it was actually on screen during the run). ffmpeg's own pad+overlay filters do the compositing.\n\n/**\n * Everything the framing pass needs. Both adapters (the Playwright Test one and the standalone runner) reduce to this: a flushed raw recording, the viewport it was recorded at, a live browser to render band PNGs through, and where to write the framed copy.\n */\nexport interface FrameRecordingInput {\n // Timeline source. Read after the context has closed is fine: the state is keyed by the Page object itself, which outlives its context.\n page: Page;\n // Path to the raw recording, already flushed to disk (Playwright Test flushes it before afterEach; the standalone runner closes the context first).\n videoPath: string;\n // The recorded viewport, captured before the context closed (page.viewportSize() throws on a closed page).\n viewport: { width: number; height: number };\n // Band PNGs are rendered through a throwaway context on this browser (see renderTextBand).\n browser: Browser;\n headerText: string | Promise<string>;\n framedPath: string;\n bands?: Partial<BandStyle>;\n}\n\n/**\n * The default header band: the word \"commit\", the short sha, a middot, and the ISO recording-start timestamp, or just the timestamp when not inside a git working tree (demos run from anywhere, not only repos).\n */\nexport async function defaultHeaderText(startedAtMs: number): Promise<string> {\n const createdAtIso = new Date(startedAtMs).toISOString();\n try {\n const sha = await new Promise<string>((resolve, reject) => {\n execFile(\"git\", [\"rev-parse\", \"--short\", \"HEAD\"], (error, stdout) => {\n if (error) reject(new Error(error.message, { cause: error }));\n else resolve(stdout.trim());\n });\n });\n\n return `commit ${sha} · ${createdAtIso}`;\n } catch {\n return createdAtIso;\n }\n}\n\n/**\n * Frames one raw recording: renders the header and caption band PNGs, probes the raw video's duration, and composites everything into {@link FrameRecordingInput.framedPath} with ffmpeg. Returns that path.\n */\nexport async function frameRecording(\n input: Readonly<FrameRecordingInput>,\n): Promise<string> {\n const style = resolveBandStyle(input.bands);\n const timeline = getRecordingTimeline(input.page);\n const facts = await probeVideo(input.videoPath);\n assertVideoMatchesViewport(facts, input.viewport, input.videoPath);\n const windows = captionWindows(timeline.events, facts.durationMs);\n const plan = buildFramePlan(input.viewport, style, windows);\n\n // Deliberately separate from the demo's own recording context (see renderTextBand's own comment for why).\n const bandContext = await input.browser.newContext();\n const workDir = await mkdtemp(join(tmpdir(), \"talktrack-frame-\"));\n try {\n const inputArgs: string[] = [\"-i\", input.videoPath];\n const headerPngPath = join(workDir, \"header.png\");\n await writeFile(\n headerPngPath,\n await renderTextBand(bandContext, style, {\n width: input.viewport.width,\n height: style.headerHeightPx,\n fontSizePx: style.headerFontSizePx,\n text: await input.headerText,\n }),\n );\n inputArgs.push(\"-i\", headerPngPath);\n for (const [i, window] of windows.entries()) {\n const captionPngPath = join(workDir, `caption-${String(i)}.png`);\n await writeFile(\n captionPngPath,\n await renderTextBand(bandContext, style, {\n width: input.viewport.width,\n height: style.footerHeightPx,\n fontSizePx: style.footerFontSizePx,\n text: window.text,\n }),\n );\n inputArgs.push(\"-i\", captionPngPath);\n }\n\n await runFfmpeg([\n \"-y\",\n ...inputArgs,\n \"-filter_complex\",\n plan.filterGraph,\n \"-map\",\n `[${plan.finalLabel}]`,\n \"-c:v\",\n \"libvpx-vp9\",\n \"-crf\",\n \"32\",\n \"-b:v\",\n \"0\",\n \"-deadline\",\n \"good\",\n \"-cpu-used\",\n \"3\",\n input.framedPath,\n ]);\n\n return input.framedPath;\n } finally {\n await bandContext.close();\n await rm(workDir, { recursive: true, force: true });\n }\n}\n","import { mkdtemp, rm } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { type Page, type TestInfo } from \"@playwright/test\";\nimport { type BandStyle } from \"./bands\";\nimport { defaultHeaderText, frameRecording } from \"./frame\";\nimport { getRecordingTimeline } from \"./timeline\";\n\n// The @playwright/test adapter. Call from afterEach: it closes the spec's page so Playwright finalises the video, frames the recording, and attaches the framed copy next to the raw one as a test attachment.\n\n/**\n * Optional overrides for {@link finalizeFramedRecording}: the header band's text, partial band styling, and where the framed copy is written (default: next to the raw video, with a -framed suffix).\n */\nexport interface FinalizeOptions {\n headerText?: string | Promise<string>;\n bands?: Partial<BandStyle>;\n framedPath?: string;\n}\n\n/**\n * Produces a taller, framed copy of the just-recorded spec video: the app window exactly as recorded, a static header above it, and a footer below it showing each narration line only while it was actually shown during the run. A no-op returning undefined (not a failure) when there's no video to frame at all (a spec run without video enabled) or no viewport to size bands against.\n *\n * Closes the page. Playwright only finalises a page's video once the page is closed, and an afterEach hook runs while the test's page is still open, so the recording on disk is incomplete until then: the adapter closes the page itself and waits for the finalised video (video.saveAs waits for exactly that) before reading it. The page is unusable afterwards, which is why this belongs in afterEach and not mid-test.\n */\nexport async function finalizeFramedRecording(\n page: Page,\n testInfo: TestInfo,\n opts?: FinalizeOptions,\n): Promise<string | undefined> {\n const video = page.video();\n if (!video) return undefined;\n const viewport = page.viewportSize();\n if (!viewport) return undefined;\n const browser = page.context().browser();\n if (!browser)\n throw new Error(\n \"talktrack cannot frame this recording: a persistent browser context has no separate Browser handle to render caption bands through\",\n );\n\n // Only used for naming the framed copy: path() reports where the video is being written without waiting for it to finish.\n const rawPath = await video.path();\n const framedPath =\n opts?.framedPath ?? rawPath.replace(/\\.webm$/, \"-framed.webm\");\n const headerText =\n opts?.headerText ?? defaultHeaderText(getRecordingTimeline(page).startedAt);\n\n const scratchDir = await mkdtemp(join(tmpdir(), \"talktrack-raw-\"));\n try {\n await page.close();\n const finalisedRawPath = join(scratchDir, \"raw.webm\");\n await video.saveAs(finalisedRawPath);\n\n await frameRecording({\n page,\n videoPath: finalisedRawPath,\n viewport,\n browser,\n headerText,\n framedPath,\n ...(opts?.bands ? { bands: opts.bands } : {}),\n });\n } finally {\n await rm(scratchDir, { recursive: true, force: true });\n }\n await testInfo.attach(\"framed-recording\", {\n path: framedPath,\n contentType: \"video/webm\",\n });\n\n return framedPath;\n}\n","import { mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport {\n type Browser,\n type BrowserContext,\n type BrowserContextOptions,\n type BrowserType,\n type LaunchOptions,\n type Locator,\n chromium,\n type Page,\n} from \"@playwright/test\";\nimport { type BandStyle } from \"./bands\";\nimport {\n beat,\n fillVisibly,\n gotoWithCursor,\n hideCaption,\n installCursor,\n moveAndClick,\n narrateScene,\n naturalScrollIntoView,\n reloadWithCursor,\n showCaption,\n} from \"./cursor\";\nimport { defaultHeaderText, frameRecording } from \"./frame\";\nimport { getRecordingTimeline } from \"./timeline\";\n\n// The standalone runner: everything installCursor/narrateScene give a @playwright/test spec, but from a plain script with no test runner involved. recordDemo launches (or borrows) a browser, opens a video-recording context, installs the cursor, and returns the same helper surface bound to that page; finish() closes the context (which flushes Playwright's webm), runs the framing pass, and returns the framed recording's path.\n\n/**\n * Everything recordDemo needs: the recorded viewport, where to start, where outputs go, and optional overrides for the browser, context, header band, and band styling.\n */\nexport interface RecordDemoOptions {\n // Recorded viewport. Playwright records exactly this rectangle; 16:9 ratios frame cleanly.\n viewport: { width: number; height: number };\n // Initial navigation; omit to drive the page entirely by hand.\n url?: string;\n // Where Playwright writes the raw recording and talktrack writes the framed one. Default: talktrack-output under the working directory.\n outputDir?: string;\n // Bring your own browser (shared across demos); by default one is launched per recordDemo call and closed by finish().\n browser?: Browser;\n // Which browser to launch when `browser` is not supplied. Default: chromium.\n browserType?: BrowserType<Browser>;\n launchOptions?: LaunchOptions;\n // Extra context options (locale, colorScheme, ...). recordVideo and viewport are owned by the runner.\n contextOptions?: Omit<BrowserContextOptions, \"recordVideo\" | \"viewport\">;\n headerText?: string | Promise<string>;\n bands?: Partial<BandStyle>;\n}\n\n/**\n * The bound helper surface recordDemo returns: the cursor/narration helpers a spec gets, wrapped around one recording page, plus {@link TalktrackSession.finish} to flush and frame the video.\n */\nexport interface TalktrackSession {\n readonly page: Page;\n readonly context: BrowserContext;\n moveAndClick: (locator: Readonly<Locator>, pauseMs?: number) => Promise<void>;\n fillVisibly: (\n locator: Readonly<Locator>,\n text: string,\n pauseMs?: number,\n ) => Promise<void>;\n naturalScrollIntoView: (locator: Readonly<Locator>) => Promise<void>;\n goto: (url: string) => Promise<void>;\n reload: () => Promise<void>;\n caption: (text: string) => void;\n hideCaption: () => void;\n narrateScene: <T>(\n text: string,\n settle: () => Promise<T>,\n opts?: { leadInMs?: number; dwellMs?: number },\n ) => Promise<T>;\n beat: (ms?: number) => Promise<void>;\n // Closes the recording context, frames the raw video, closes the browser if this session launched it, and returns the framed recording's path. Call exactly once, at the end.\n finish: () => Promise<string>;\n}\n\n/**\n * Starts one standalone demo recording: launches (or borrows) a browser, opens a video-recording context at the given viewport, installs the cursor, optionally navigates to `url`, and returns the session. Drive the page through the session's helpers, then `await session.finish()` once; it returns the framed recording's path.\n */\nexport async function recordDemo(\n name: string,\n opts: Readonly<RecordDemoOptions>,\n): Promise<TalktrackSession> {\n const outputDir = opts.outputDir ?? join(process.cwd(), \"talktrack-output\");\n await mkdir(outputDir, { recursive: true });\n\n const ownsBrowser = opts.browser === undefined;\n const browser =\n opts.browser ??\n (await (opts.browserType ?? chromium).launch(opts.launchOptions ?? {}));\n const context = await browser.newContext({\n ...opts.contextOptions,\n viewport: opts.viewport,\n recordVideo: { dir: outputDir, size: opts.viewport },\n });\n const page = await context.newPage();\n await installCursor(page);\n if (opts.url !== undefined) await gotoWithCursor(page, opts.url);\n\n const finish = async (): Promise<string> => {\n // Captured before close: both throw on a closed context/page, and the framing pass needs them after the video is flushed.\n const viewport = page.viewportSize();\n if (!viewport)\n throw new Error(\n \"talktrack could not read the viewport before closing the demo's context\",\n );\n const video = page.video();\n if (!video)\n throw new Error(\"talktrack's recording context produced no video handle\");\n\n const videoPathPromise = video.path();\n await context.close();\n const videoPath = await videoPathPromise;\n\n const framedPath = join(outputDir, `${name}-framed.webm`);\n const headerText =\n opts.headerText ??\n defaultHeaderText(getRecordingTimeline(page).startedAt);\n const framed = await frameRecording({\n page,\n videoPath,\n viewport,\n browser,\n headerText,\n framedPath,\n ...(opts.bands ? { bands: opts.bands } : {}),\n });\n if (ownsBrowser) await browser.close();\n\n return framed;\n };\n\n return {\n page,\n context,\n moveAndClick: async (locator, pauseMs) =>\n moveAndClick(page, locator, pauseMs),\n fillVisibly: async (locator, text, pauseMs) =>\n fillVisibly(page, locator, text, pauseMs),\n naturalScrollIntoView: async (locator) =>\n naturalScrollIntoView(page, locator),\n goto: async (url) => gotoWithCursor(page, url),\n reload: async () => reloadWithCursor(page),\n caption: (text) => {\n showCaption(page, text);\n },\n hideCaption: () => {\n hideCaption(page);\n },\n narrateScene: async (text, settle, narrateOpts) =>\n narrateScene(page, text, settle, narrateOpts),\n beat: async (ms) => beat(page, ms),\n finish,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/human-path.ts","../src/timeline.ts","../src/cursor.ts","../src/bands.ts","../src/filter-graph.ts","../src/ffmpeg.ts","../src/frame.ts","../src/test-adapter.ts","../src/standalone.ts"],"sourcesContent":["// The pure geometry behind the cursor's motion, split out from cursor.ts so it is unit-testable with no browser involved: a human moving a mouse doesn't teleport in a straight line at constant speed. Motion eases in and out, tends to bow slightly off the direct line, sometimes overshoots a touch on longer moves before settling, and has small continuous noise throughout.\n\n/** A point in viewport pixel coordinates. */\nexport interface Point {\n x: number;\n y: number;\n}\n\n/** The fewest points a glide is ever broken into: a short nudge is never reduced to a single frame. */\nexport const MIN_PATH_STEPS = 8;\n\n/** The most points a glide is ever broken into: a cross-viewport move shouldn't take forever. */\nexport const MAX_PATH_STEPS = 34;\n\n/**\n * Uniform noise in -spread..spread, used for micro-jitter along the path.\n */\nexport function jitter(spread: number): number {\n // Centres Math.random()'s 0..1 output on zero before scaling to -spread..spread.\n const RANDOM_UNIT_CENTER = 0.5;\n\n return (Math.random() - RANDOM_UNIT_CENTER) * 2 * spread;\n}\n\n/**\n * Builds the human-feeling path between two points as a list of intermediate points; the caller paces them out over real wall-clock time so a screen recording actually shows the glide. The last point is always exactly `to`, regardless of jitter or overshoot.\n */\nexport function buildHumanPath(\n from: Readonly<Point>,\n to: Readonly<Point>,\n): Point[] {\n const dx = to.x - from.x;\n const dy = to.y - from.y;\n const distance = Math.hypot(dx, dy);\n if (distance < 2) return [to];\n\n // More steps for longer distances, bounded by MIN_PATH_STEPS and MAX_PATH_STEPS above. Roughly one path point per this many pixels of travel.\n const PATH_STEP_DISTANCE_PX = 14;\n const steps = Math.max(\n MIN_PATH_STEPS,\n Math.min(MAX_PATH_STEPS, Math.round(distance / PATH_STEP_DISTANCE_PX)),\n );\n // Perpendicular unit vector, for the mid-path bow.\n const perpX = -dy / distance;\n const perpY = dx / distance;\n const MAX_BOW_MAGNITUDE_PX = 40;\n // The bow scales with distance, up to the cap above.\n const BOW_MAGNITUDE_RATIO = 0.12;\n // Coin flip: bow left or right of the direct line.\n const BOW_DIRECTION_PROBABILITY = 0.5;\n const bowMagnitude =\n Math.min(MAX_BOW_MAGNITUDE_PX, distance * BOW_MAGNITUDE_RATIO) *\n (Math.random() < BOW_DIRECTION_PROBABILITY ? 1 : -1);\n // Only longer moves are eligible to overshoot.\n const OVERSHOOT_MIN_DISTANCE_PX = 180;\n const OVERSHOOT_PROBABILITY = 0.6;\n const overshoot =\n distance > OVERSHOOT_MIN_DISTANCE_PX &&\n Math.random() < OVERSHOOT_PROBABILITY;\n const OVERSHOOT_MIN_PX = 6;\n const OVERSHOOT_RANDOM_RANGE_PX = 10;\n const overshootPx = overshoot\n ? OVERSHOOT_MIN_PX + Math.random() * OVERSHOOT_RANDOM_RANGE_PX\n : 0;\n // Fraction of the path where the overshoot begins, and where it has fully settled.\n const OVERSHOOT_WINDOW_START_T = 0.8;\n const OVERSHOOT_WINDOW_END_T = 0.97;\n // OVERSHOOT_WINDOW_END_T minus OVERSHOOT_WINDOW_START_T, used to normalise t across the window.\n const OVERSHOOT_WINDOW_WIDTH_T = 0.17;\n const PATH_POINT_JITTER_PX = 0.6;\n\n const path: Point[] = [];\n for (let i = 1; i <= steps; i++) {\n const t = i / steps;\n // Accelerate away from the start, decelerate into the target; the curve itself is easeInOutCubic below.\n const eased = easeInOutCubic(t);\n // The bow peaks at the path's midpoint and fades to zero at both ends.\n const bow = Math.sin(t * Math.PI) * bowMagnitude;\n let px = from.x + dx * eased + perpX * bow;\n let py = from.y + dy * eased + perpY * bow;\n if (\n overshoot &&\n t > OVERSHOOT_WINDOW_START_T &&\n t < OVERSHOOT_WINDOW_END_T\n ) {\n // 0 -> 1 across the overshoot window.\n const overshootT =\n (t - OVERSHOOT_WINDOW_START_T) / OVERSHOOT_WINDOW_WIDTH_T;\n // Out and back.\n const settle = Math.sin(overshootT * Math.PI);\n px += (dx / distance) * overshootPx * settle;\n py += (dy / distance) * overshootPx * settle;\n }\n path.push({\n x: px + jitter(PATH_POINT_JITTER_PX),\n y: py + jitter(PATH_POINT_JITTER_PX),\n });\n }\n // Land exactly on target regardless of jitter/overshoot.\n path[path.length - 1] = to;\n\n return path;\n}\n\n/**\n * Ease-in-out cubic, shared with naturalScrollIntoView's own identical curve: a real scroll (wheel or trackpad) accelerates away from rest and decelerates into rest, it doesn't move at constant speed.\n */\nexport function easeInOutCubic(t: number): number {\n // Fraction of progress where the curve switches from accelerating to decelerating.\n const EASE_MIDPOINT_T = 0.5;\n // Scales the accelerating half's t^3 term.\n const EASE_IN_CUBIC_SCALE = 4;\n // Coefficient of t inside the decelerating half's inner expression, and the exponent applied to that inner expression.\n const EASE_OUT_LINEAR_COEFFICIENT = -2;\n const EASE_OUT_CUBIC_EXPONENT = 3;\n\n return t < EASE_MIDPOINT_T\n ? EASE_IN_CUBIC_SCALE * t * t * t\n : 1 -\n Math.pow(EASE_OUT_LINEAR_COEFFICIENT * t + 2, EASE_OUT_CUBIC_EXPONENT) /\n 2;\n}\n","import { type Page } from \"@playwright/test\";\n\n// The narration timeline behind the footer band: each showCaption/hideCaption call is timestamped against the recording's own start, and the framing pass later turns that event list into \"this text was on screen from here to here\" overlay windows. Keeping it as per-page WeakMap state (rather than threading it through every helper signature) is what lets both the test-runner adapter and the standalone runner share one helper surface.\n\nexport interface CaptionEvent {\n atMs: number;\n text: string | null;\n}\n\nexport interface RecordingTimeline {\n startedAt: number;\n events: CaptionEvent[];\n}\n\n// Per-page recording start time (t0) and narration timeline, keyed the way cursor positions are (see cursor.ts): per-page state that must survive across many helper calls without being threaded through every function signature.\nconst recordingStartedAt = new WeakMap<Page, number>();\nconst captionTimeline = new WeakMap<Page, CaptionEvent[]>();\n\n/**\n * Called once, as early as possible (installCursor's own first line). First call wins, so a demo that navigates before installing the overlay doesn't shift t0 later than the recording's own real start.\n */\nexport function markRecordingStart(page: Page): void {\n if (!recordingStartedAt.has(page)) recordingStartedAt.set(page, Date.now());\n}\n\n/**\n * Appends one narration event for the page: a string shows that caption from now, null hides whatever is showing. Called by showCaption/hideCaption; direct use is only for advanced driving.\n */\nexport function recordCaptionEvent(page: Page, text: string | null): void {\n const startedAt = recordingStartedAt.get(page) ?? Date.now();\n const events = captionTimeline.get(page) ?? [];\n events.push({ atMs: Date.now() - startedAt, text });\n captionTimeline.set(page, events);\n}\n\n/**\n * Snapshot for the framing pass. Read after the context has closed is fine: the map is keyed by the Page object itself, which outlives its browser context.\n */\nexport function getRecordingTimeline(page: Page): RecordingTimeline {\n return {\n startedAt: recordingStartedAt.get(page) ?? Date.now(),\n events: captionTimeline.get(page) ?? [],\n };\n}\n","import { type Locator, type Page } from \"@playwright/test\";\nimport { buildHumanPath, easeInOutCubic, type Point } from \"./human-path\";\nimport { markRecordingStart, recordCaptionEvent } from \"./timeline\";\n\n// Playwright drives the DOM directly and never moves a visible pointer, so a screen recording of an automated run shows silent jump-cuts with no indication of what was clicked or how. This installs a synthetic cursor overlay (a dot that tracks real `mousemove` events, animates on click, and persists its position across navigations instead of resetting to the corner), glides the cursor along a slightly curved, eased, jittered path rather than a robotic straight-line teleport, and exposes moveAndClick/fillVisibly/gotoWithCursor/showCaption/hideCaption helpers built on top, so recordings made with them read as a person driving the UI with someone narrating over their shoulder, not a silent script. Narration itself is not an in-page overlay: showCaption/hideCaption below timestamp the narration text against the recording's own start time (see ./timeline), and the actual banner is composited into a dedicated band below the recorded app window during post-processing (see ./frame for why an in-page overlay can't produce that layout on its own).\n\n// TALKTRACK_OVERLAY_INIT runs inside the page (via page.evaluate/addInitScript), so it attaches its handles to the real browser `window` rather than returning them. This augmentation is what lets moveAndClick below call window.__talktrackPress directly, with no `as unknown as` cast.\ndeclare global {\n interface Window {\n __talktrackPress?: (x: number, y: number) => void;\n __talktrackRelease?: () => void;\n }\n}\n\nconst TALKTRACK_OVERLAY_INIT = () => {\n if (document.getElementById(\"__talktrack_cursor__\")) return;\n const style = document.createElement(\"style\");\n style.textContent = `\n #__talktrack_cursor__ {\n position: fixed; top: 0; left: 0; width: 22px; height: 22px;\n border-radius: 50%; background: rgba(220,38,38,0.88);\n border: 2px solid white; box-shadow: 0 1px 5px rgba(0,0,0,0.6);\n pointer-events: none; z-index: 2147483647; transform: translate(-50%, -50%) scale(1);\n transition: transform 0.12s ease-out, background-color 0.12s ease-out;\n }\n #__talktrack_cursor__.__talktrack_pressed__ {\n transform: translate(-50%, -50%) scale(0.55);\n background: rgba(153,27,27,0.95);\n }\n .__talktrack_ripple__ {\n position: fixed; width: 14px; height: 14px; border-radius: 50%;\n border: 3px solid rgba(220,38,38,0.9); pointer-events: none; z-index: 2147483647;\n transform: translate(-50%, -50%); animation: __talktrack_ripple_anim__ 0.55s ease-out forwards;\n }\n @keyframes __talktrack_ripple_anim__ {\n from { width: 14px; height: 14px; opacity: 1; }\n to { width: 58px; height: 58px; opacity: 0; }\n }\n `;\n document.head.appendChild(style);\n const cursor = document.createElement(\"div\");\n cursor.id = \"__talktrack_cursor__\";\n document.body.appendChild(cursor);\n window.addEventListener(\n \"mousemove\",\n (e) => {\n cursor.style.left = `${String(e.clientX)}px`;\n cursor.style.top = `${String(e.clientY)}px`;\n },\n true,\n );\n window.__talktrackPress = (x, y) => {\n cursor.classList.add(\"__talktrack_pressed__\");\n const ripple = document.createElement(\"div\");\n ripple.className = \"__talktrack_ripple__\";\n ripple.style.left = `${String(x)}px`;\n ripple.style.top = `${String(y)}px`;\n document.body.appendChild(ripple);\n const RIPPLE_REMOVE_DELAY_MS = 600;\n setTimeout(() => {\n ripple.remove();\n }, RIPPLE_REMOVE_DELAY_MS);\n };\n window.__talktrackRelease = () => {\n cursor.classList.remove(\"__talktrack_pressed__\");\n };\n};\n\n// Per-page last-known cursor position, so a navigation can restore it on the fresh document instead of the overlay resetting to the top-left corner, and so the next glide starts from where the cursor actually is rather than an arbitrary origin.\nconst currentPosition = new WeakMap<Page, Point>();\n\nasync function glideTo(page: Page, to: Readonly<Point>): Promise<void> {\n const from = currentPosition.get(page) ?? { x: to.x, y: to.y };\n const path = buildHumanPath(from, to);\n const MOVE_STEP_MIN_DELAY_MS = 12;\n const MOVE_STEP_DELAY_RANGE_MS = 10;\n for (const point of path) {\n await page.mouse.move(point.x, point.y);\n // ~12-22ms per step: roughly natural mouse-sampling pace, with enough jitter that consecutive glides don't all take an identical, obviously-scripted duration.\n await page.waitForTimeout(\n MOVE_STEP_MIN_DELAY_MS + Math.random() * MOVE_STEP_DELAY_RANGE_MS,\n );\n }\n currentPosition.set(page, to);\n}\n\n// page.evaluate right after a navigation races the document it targets (a redirect or a late client transition can swap the page out from under it), which throws \"Execution context was destroyed\". That's expected, not a real failure of this cosmetic helper, so it's tolerated here rather than aborting the whole recording over it.\nasync function tolerateNavigationRace<T>(\n promise: Readonly<Promise<T>>,\n): Promise<T | undefined> {\n return promise.catch(() => undefined);\n}\n\n/**\n * Playwright's own locator.scrollIntoViewIfNeeded() is a synchronous DOM scrollIntoView: an instant jump-cut in a recording, not something a person watching would recognise as scrolling at all. This drives a real sequence of wheel events instead, eased and paced like glideTo's own mouse path, so a recording shows the page actually gliding to the target rather than teleporting to it. Exported (not just used internally by moveAndClick below) so a demo can bring a target into view on its own, with no click attached (for example before narrating over a tall diagram whose own heading satisfies a \"content rendered\" assertion long before the diagram's body, the actual thing being narrated, has scrolled into the visible viewport).\n */\nexport async function naturalScrollIntoView(\n page: Page,\n locator: Readonly<Locator>,\n): Promise<void> {\n const viewport = page.viewportSize();\n if (!viewport) return;\n\n // Real wheel events hit-test against whatever scrollable element is under the CURRENT mouse position, not the target: moving the mouse toward the viewport's own centre first (rather than wherever the previous click left it) makes that hit-test land on the main scrollable content area rather than, say, a fixed sidebar nav the cursor happens to still be resting over.\n const hoverPoint = { x: viewport.width / 2, y: viewport.height / 2 };\n await tolerateNavigationRace(page.mouse.move(hoverPoint.x, hoverPoint.y));\n\n const box = await tolerateNavigationRace(locator.boundingBox());\n // Let the caller's own click flow surface a clear \"no bounding box\" error instead of failing silently here.\n if (!box) return;\n\n // A comfortable margin from the viewport edge, not the literal edge: a person scrolls until they can see the thing clearly, not until it's pixel-perfect at the boundary.\n const margin = 96;\n // Already comfortably in view: nothing to scroll.\n if (box.y >= margin && box.y + box.height <= viewport.height - margin) return;\n\n // Scroll enough to land the target roughly centred, not pinned to the very top, matching how a person actually scrolls toward something they want to interact with next.\n const totalDelta = box.y + box.height / 2 - viewport.height / 2;\n const MIN_SCROLL_DELTA_PX = 12;\n if (Math.abs(totalDelta) < MIN_SCROLL_DELTA_PX) return;\n\n const MIN_SCROLL_STEPS = 7;\n const MAX_SCROLL_STEPS = 26;\n const PIXELS_PER_SCROLL_STEP = 55;\n const steps = Math.max(\n MIN_SCROLL_STEPS,\n Math.min(\n MAX_SCROLL_STEPS,\n Math.round(Math.abs(totalDelta) / PIXELS_PER_SCROLL_STEP),\n ),\n );\n let deliveredSoFar = 0;\n const SCROLL_STEP_MIN_DELAY_MS = 14;\n const SCROLL_STEP_DELAY_RANGE_MS = 12;\n for (let i = 1; i <= steps; i++) {\n const eased = easeInOutCubic(i / steps);\n const target = totalDelta * eased;\n const stepDelta = target - deliveredSoFar;\n deliveredSoFar = target;\n await page.mouse.wheel(0, stepDelta);\n // ~14-26ms per step: a real scroll-wheel/trackpad delivers a burst of small deltas at roughly this cadence, with jitter so consecutive scrolls in the same recording don't all take an identical, obviously-scripted duration.\n await page.waitForTimeout(\n SCROLL_STEP_MIN_DELAY_MS + Math.random() * SCROLL_STEP_DELAY_RANGE_MS,\n );\n }\n // A brief settle beat after the scroll stops, the way a person pauses a moment before their next action rather than clicking mid-scroll.\n const SCROLL_SETTLE_MIN_DELAY_MS = 140;\n const SCROLL_SETTLE_DELAY_RANGE_MS = 120;\n await page.waitForTimeout(\n SCROLL_SETTLE_MIN_DELAY_MS + Math.random() * SCROLL_SETTLE_DELAY_RANGE_MS,\n );\n}\n\nasync function applyCursor(page: Page): Promise<void> {\n await tolerateNavigationRace(page.evaluate(TALKTRACK_OVERLAY_INIT));\n const pos = currentPosition.get(page);\n if (!pos) return;\n await tolerateNavigationRace(\n page.evaluate(\n ([x, y]) => {\n const cursor = document.getElementById(\"__talktrack_cursor__\");\n if (cursor) {\n cursor.style.left = `${String(x)}px`;\n cursor.style.top = `${String(y)}px`;\n }\n },\n [pos.x, pos.y] as const,\n ),\n );\n // Keeps Playwright's own virtual mouse position in sync with the overlay: otherwise the next glide would start from (0,0), not from where the cursor visually is.\n await tolerateNavigationRace(page.mouse.move(pos.x, pos.y, { steps: 1 }));\n}\n\n/**\n * Re-injects on every navigation (addInitScript) and once immediately for the current document, since addInitScript only applies to documents created after it's registered. Also marks the recording's own t0 (see ./timeline): every demo calls this as its very first action, which is the closest available proxy for \"when the video recording actually began\" without needing frame-exact precision a demo narration track doesn't need anyway.\n */\nexport async function installCursor(page: Page): Promise<void> {\n markRecordingStart(page);\n await page.addInitScript(TALKTRACK_OVERLAY_INIT);\n await applyCursor(page);\n}\n\n/**\n * Use in place of page.goto() for any navigation after installCursor(): restores the cursor to its last known state on the new document instead of letting it reset, so navigating doesn't read as a visual glitch in the recording.\n */\nexport async function gotoWithCursor(page: Page, url: string): Promise<void> {\n await page.goto(url);\n await applyCursor(page);\n}\n\n/**\n * Use in place of page.reload() for any navigation after installCursor(): restores the cursor to its last known state on the new document the same way gotoWithCursor does for page.goto(). A bare page.reload() in a demo that has already called installCursor() is the one navigation path the overlay can't cover on its own: addInitScript recreates the cursor DOM element on the fresh document (so it doesn't vanish outright), but nothing repositions it, so a reload leaves the cursor visibly pinned to the corner until the next moveAndClick's glide happens to move it (confirmed live as the cause of a cursor going missing mid-recording during a demo's own periodic safety-net reloads).\n */\nexport async function reloadWithCursor(page: Page): Promise<void> {\n await page.reload();\n await applyCursor(page);\n}\n\n/**\n * Sets (or updates) the narration shown in the footer band below the recorded app window; see ./timeline for how this timestamped event actually becomes an on-screen banner during post-processing. Pass null (or call hideCaption) to clear it between unrelated steps rather than leaving a stale caption shown throughout the gap.\n */\nexport function showCaption(page: Page, text: string): void {\n recordCaptionEvent(page, text);\n}\n\n/**\n * Clears the narration band (closes the window the last showCaption opened). Hide between unrelated steps rather than leaving a stale caption shown throughout the gap.\n */\nexport function hideCaption(page: Page): void {\n recordCaptionEvent(page, null);\n}\n\n// +/- ~25% jitter around a base duration, so repeated pauses in a recording don't all read as the exact same, obviously-scripted length.\nfunction humanPause(baseMs: number): number {\n const PAUSE_JITTER_MIN_MULTIPLIER = 0.75;\n const PAUSE_JITTER_RANGE_MULTIPLIER = 0.5;\n\n return Math.round(\n baseMs *\n (PAUSE_JITTER_MIN_MULTIPLIER +\n Math.random() * PAUSE_JITTER_RANGE_MULTIPLIER),\n );\n}\n\n/**\n * Moves the cursor humanly to the locator (scrolling it into view first), clicks it, and holds a jittered pause afterwards. Use in place of locator.click() in any recording.\n */\nexport async function moveAndClick(\n page: Page,\n locator: Readonly<Locator>,\n pauseMs = 250,\n): Promise<void> {\n await naturalScrollIntoView(page, locator);\n const box = await locator.boundingBox();\n if (!box)\n throw new Error(\"Could not resolve a bounding box for the click target\");\n // Aim slightly off dead-centre, the way a person rarely clicks a target's exact geometric middle, while staying comfortably inside the element's own bounds.\n const CLICK_TARGET_MIN_OFFSET_RATIO = 0.4;\n const CLICK_TARGET_OFFSET_RANGE_RATIO = 0.2;\n const target = {\n x:\n box.x +\n box.width *\n (CLICK_TARGET_MIN_OFFSET_RATIO +\n Math.random() * CLICK_TARGET_OFFSET_RANGE_RATIO),\n y:\n box.y +\n box.height *\n (CLICK_TARGET_MIN_OFFSET_RATIO +\n Math.random() * CLICK_TARGET_OFFSET_RANGE_RATIO),\n };\n await glideTo(page, target);\n const HOVER_BEFORE_CLICK_MS = 180;\n // A brief hover before committing to the click.\n await page.waitForTimeout(humanPause(HOVER_BEFORE_CLICK_MS));\n await tolerateNavigationRace(\n page.evaluate(([px, py]) => window.__talktrackPress?.(px, py), [\n target.x,\n target.y,\n ] as const),\n );\n // The actual click goes through locator.click(), not raw page.mouse.down()/up() at the coordinates captured above. Those coordinates are a snapshot from before the glide animation and hover pause, which together take several hundred milliseconds; long enough for an async-populated dropdown (options that load after mount) to re-render and shift the option out from under a stale coordinate. Raw mouse events have no actionability check the way locator.click() does, so a stale target silently clicks empty space (or the wrong element) instead of failing loudly or retargeting (confirmed live: this silently produced an unselected field with no error anywhere in the run). locator.click() re-resolves the element and waits for it to be stable/attached/visible immediately before dispatching, which is exactly the robustness raw coordinates can't provide; { force: true } is not used, so a genuinely non-actionable target still fails loudly rather than clicking through.\n await locator.click();\n const PAUSE_AFTER_CLICK_MS = 100;\n await page.waitForTimeout(humanPause(PAUSE_AFTER_CLICK_MS));\n await tolerateNavigationRace(\n page.evaluate(() => window.__talktrackRelease?.()),\n );\n // The click itself may have triggered a hard navigation (a plain <a> link, a form submit's redirect): that swaps in a fresh document, and addInitScript's TALKTRACK_OVERLAY_INIT creates a new cursor at its unset default (pinned to the top-left corner) until something restores it. A single check right after mouse.up() isn't reliable, because the click can take a beat to actually start navigating, so that one check can land before the new document even exists, restoring onto the page that's about to be torn down anyway rather than the one that replaces it. Reapplying a few times over the following stretch means whichever check lands after the real swap picks it back up almost immediately, instead of leaving it missing until some later action reveals it. For a soft navigation or no navigation at all, every extra check is a harmless no-op: the cursor already exists at the right position.\n const CURSOR_REAPPLY_ATTEMPTS = 4;\n const CURSOR_REAPPLY_INTERVAL_MS = 90;\n for (let i = 0; i < CURSOR_REAPPLY_ATTEMPTS; i++) {\n // currentPosition already holds `target`, set by glideTo above.\n await applyCursor(page);\n await page.waitForTimeout(CURSOR_REAPPLY_INTERVAL_MS);\n }\n await page.waitForTimeout(humanPause(pauseMs));\n}\n\n// Per-character delay for typeCharByChar, centred around a real, fast-but-plausible typing speed (~45ms/char average is roughly 210 characters/minute, comfortably within normal touch-typing range), with real per-character variance rather than a single fixed delay, plus a slightly longer beat after a space (the natural word-boundary pause) and an occasional, longer \"thinking\" pause mid-string, the way someone typing a value they're actively composing (a path, a flag list) rather than reciting from memory does.\nasync function typeCharByChar(page: Page, text: string): Promise<void> {\n const CHAR_TYPE_MIN_DELAY_MS = 28;\n const CHAR_TYPE_DELAY_RANGE_MS = 55;\n const SPACE_PAUSE_MIN_DELAY_MS = 40;\n const SPACE_PAUSE_DELAY_RANGE_MS = 60;\n const THINKING_PAUSE_PROBABILITY = 0.04;\n const THINKING_PAUSE_MIN_DELAY_MS = 200;\n const THINKING_PAUSE_DELAY_RANGE_MS = 400;\n for (const ch of text) {\n await page.keyboard.type(ch);\n let delay =\n CHAR_TYPE_MIN_DELAY_MS + Math.random() * CHAR_TYPE_DELAY_RANGE_MS;\n if (ch === \" \")\n delay +=\n SPACE_PAUSE_MIN_DELAY_MS + Math.random() * SPACE_PAUSE_DELAY_RANGE_MS;\n // An occasional longer \"thinking\" pause.\n if (Math.random() < THINKING_PAUSE_PROBABILITY)\n delay +=\n THINKING_PAUSE_MIN_DELAY_MS +\n Math.random() * THINKING_PAUSE_DELAY_RANGE_MS;\n await page.waitForTimeout(delay);\n }\n}\n\n/**\n * Clicks into the locator and types the text character by character at human speed, so a recording shows the value appearing as it's typed rather than arriving at once.\n */\nexport async function fillVisibly(\n page: Page,\n locator: Readonly<Locator>,\n text: string,\n pauseMs = 300,\n): Promise<void> {\n const PRE_FILL_CLICK_PAUSE_MS = 150;\n await moveAndClick(page, locator, humanPause(PRE_FILL_CLICK_PAUSE_MS));\n // Clears any existing value in one step: clearing isn't the part worth watching character by character, typing the new value is.\n await locator.fill(\"\");\n await typeCharByChar(page, text);\n await page.waitForTimeout(humanPause(pauseMs));\n}\n\n/**\n * Exported so demos can pace their own non-interaction \"reading\" pauses (for example holding on a state that just changed) with the same jittered, human-feeling timing.\n */\nexport function humanBeatMs(baseMs: number): number {\n return humanPause(baseMs);\n}\n\n/**\n * Waits a jittered pause of roughly `ms` milliseconds on the page, for \"reading\" beats between interactions.\n */\nexport async function beat(page: Page, ms = 600): Promise<void> {\n await page.waitForTimeout(humanBeatMs(ms));\n}\n\n/* Replaces the two shapes a demo would otherwise hand-roll around showCaption/hideCaption: caption-then-fixed-sleep-then-action (the sleep is unrelated to any page state, so the action can start before or long after the caption's own hold ends) and assert-fully-then-caption (no lead-in at all, since the caption only ever appears once its claim is already true). Neither ties a caption's own on-screen duration to genuine page-state readiness.\n\n showCaption fires immediately, so the caption is up before `settle` (whatever assertion, poll, or action already proves the caption's own claim) has resolved: a real lead-in, not a guess. `settle` and a minimum `leadInMs` floor run concurrently, so a `settle` that resolves in under `leadInMs` still gets a caption that's been up for at least that long, and a `settle` that takes minutes (a capacity poll, say) keeps the caption up for the whole genuine wait rather than a short fixed hold that's already elapsed by the time anything real happens. Once `settle` resolves, the caption holds for `dwellMs` (a real \"look at this\" pause) before hiding: exactly the digest window a fixed-hold constant was always trying to give, just now added on top of true settle time rather than instead of it. */\nconst NARRATE_SCENE_DEFAULT_LEAD_IN_MS = 800;\nconst NARRATE_SCENE_DEFAULT_DWELL_MS = 1200;\n/**\n * Shows a caption, waits for a real page-state condition to settle while the caption stays up, holds a digest dwell, then hides it. The caption's on-screen duration is tied to genuine readiness (the `settle` promise) rather than a fixed sleep: `leadInMs` is a minimum floor running concurrently with `settle`, and `dwellMs` is the \"look at this\" hold after it resolves.\n */\nexport async function narrateScene<T>(\n page: Page,\n text: string,\n settle: () => Promise<T>,\n opts?: { leadInMs?: number; dwellMs?: number },\n): Promise<T> {\n const leadInMs = opts?.leadInMs ?? NARRATE_SCENE_DEFAULT_LEAD_IN_MS;\n const dwellMs = opts?.dwellMs ?? NARRATE_SCENE_DEFAULT_DWELL_MS;\n showCaption(page, text);\n const [result] = await Promise.all([settle(), beat(page, leadInMs)]);\n await beat(page, dwellMs);\n hideCaption(page);\n\n return result;\n}\n","import { type BrowserContext } from \"@playwright/test\";\n\n/**\n * Styling of the header and footer bands composited around the recorded app window. The defaults are deliberately unopinionated (a dark neutral band with the system font stack); override any subset through the `bands` option on finalizeFramedRecording/recordDemo to match the application being recorded.\n */\nexport interface BandStyle {\n headerHeightPx: number;\n footerHeightPx: number;\n background: string;\n textColor: string;\n fontStack: string;\n headerFontSizePx: number;\n footerFontSizePx: number;\n}\n\n/** The band look talktrack frames recordings with when no `bands` override is given. */\nexport const defaultBandStyle: BandStyle = {\n headerHeightPx: 48,\n footerHeightPx: 96,\n background: \"#111827\",\n textColor: \"#f8fafc\",\n fontStack:\n '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif',\n headerFontSizePx: 18,\n footerFontSizePx: 20,\n};\n\n/**\n * Merges a caller's partial band overrides over {@link defaultBandStyle}; an absent overrides object yields the defaults unchanged.\n */\nexport function resolveBandStyle(overrides?: Partial<BandStyle>): BandStyle {\n return { ...defaultBandStyle, ...overrides };\n}\n\n/**\n * Escapes the five characters HTML treats as markup, so arbitrary narration text can be embedded in the band page's template safely.\n */\nexport function escapeHtml(text: string): string {\n return text\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\n/** Everything one composited band needs: its pixel size, its text, and the font size to render that text at. */\nexport interface TextBandSpec {\n width: number;\n height: number;\n fontSizePx: number;\n text: string;\n}\n\n/**\n * Renders one band as a PNG. Takes a dedicated throwaway BrowserContext, not the demo's own page/context: a context recording video (recordVideo or Playwright Test's video option) records every page opened within it, so rendering the bands through the demo's own context would leave junk video files alongside the real recording. A fresh context with no video option set (off by default) avoids that entirely.\n *\n * Text is rendered as an ordinary PNG screenshot of that throwaway page rather than via ffmpeg's drawtext filter, since drawtext needs libfreetype/libfontconfig support that common ffmpeg builds (a plain Homebrew install, for instance) don't ship. Rendering text via the same browser already driving the recording sidesteps that dependency and gives exact font parity with the recorded application for free.\n */\nexport async function renderTextBand(\n bandContext: BrowserContext,\n style: Readonly<BandStyle>,\n band: Readonly<TextBandSpec>,\n): Promise<Buffer> {\n const bandPage = await bandContext.newPage();\n try {\n await bandPage.setViewportSize({\n width: band.width,\n height: band.height,\n });\n await bandPage.setContent(`<!doctype html>\n<html><head><style>\n html, body { margin: 0; width: ${String(band.width)}px; height: ${String(band.height)}px; background: ${style.background}; display: flex; align-items: center; justify-content: center; }\n p { margin: 0; padding: 0 28px; max-width: 100%; box-sizing: border-box; color: ${style.textColor}; font: 500 ${String(band.fontSizePx)}px/1.35 ${style.fontStack}; text-align: center; overflow-wrap: break-word; }\n</style></head>\n<body><p>${escapeHtml(band.text)}</p></body></html>`);\n\n return await bandPage.screenshot({ type: \"png\" });\n } finally {\n await bandPage.close();\n }\n}\n","import type { BandStyle } from \"./bands\";\nimport type { CaptionEvent } from \"./timeline\";\n\n// The pure half of the framing pass: turning a caption timeline into (a) concrete on-screen windows and (b) an ffmpeg filter graph, with no browser, no filesystem and no ffmpeg involved. Everything here is unit-testable; everything that touches the outside world lives in frame.ts.\n\nexport interface CaptionWindow {\n text: string;\n startMs: number;\n endMs: number;\n}\n\n/**\n * A window opens at each show event and closes at whatever event follows it (an explicit hide, or a subsequent show replacing the line), or at the video's own end if nothing follows. A hide event only ever closes the preceding show's window: it opens no window of its own.\n */\nexport function captionWindows(\n events: readonly CaptionEvent[],\n videoDurationMs: number,\n): CaptionWindow[] {\n const windows: CaptionWindow[] = [];\n for (const [i, event] of events.entries()) {\n if (event.text === null) continue;\n const nextEvent = events[i + 1];\n windows.push({\n text: event.text,\n startMs: event.atMs,\n endMs: nextEvent === undefined ? videoDurationMs : nextEvent.atMs,\n });\n }\n\n return windows;\n}\n\nconst MS_PER_SECOND = 1000;\n// ffmpeg's between(t,start,end) expression accepts fractional seconds; millisecond precision is more than enough for a narration cue.\nconst FFMPEG_TIME_DECIMAL_PLACES = 3;\n\n/** The ffmpeg inputs and filter graph one framing pass drives: how many -i inputs the command takes, the graph string itself, and the label its final overlay writes. */\nexport interface FramePlan {\n inputCount: number;\n filterGraph: string;\n finalLabel: string;\n}\n\n/**\n * Input 0 is the raw recording, input 1 the header band PNG, inputs 2.. the caption band PNGs in window order. The graph pads input 0 onto a taller canvas of band colour (header band above, footer band below), overlays the header at the top, then chains one time-gated overlay per caption window onto the footer region.\n */\nexport function buildFramePlan(\n viewport: Readonly<{ width: number; height: number }>,\n style: Readonly<BandStyle>,\n windows: readonly CaptionWindow[],\n): FramePlan {\n const padHeight =\n viewport.height + style.headerHeightPx + style.footerHeightPx;\n let filterGraph = `[0:v]pad=${String(viewport.width)}:${String(padHeight)}:0:${String(style.headerHeightPx)}:color=${style.background}[bg];[bg][1:v]overlay=0:0[v0]`;\n let lastLabel = \"v0\";\n\n let inputIndex = 2;\n for (const [i, window] of windows.entries()) {\n const startS = (window.startMs / MS_PER_SECOND).toFixed(\n FFMPEG_TIME_DECIMAL_PLACES,\n );\n const endS = (window.endMs / MS_PER_SECOND).toFixed(\n FFMPEG_TIME_DECIMAL_PLACES,\n );\n const nextLabel = `v${String(i + 1)}`;\n filterGraph += `;[${lastLabel}][${String(inputIndex)}:v]overlay=0:${String(style.headerHeightPx + viewport.height)}:enable='between(t,${startS},${endS})'[${nextLabel}]`;\n lastLabel = nextLabel;\n inputIndex++;\n }\n\n return { inputCount: inputIndex, filterGraph, finalLabel: lastLabel };\n}\n","import { execFile } from \"node:child_process\";\n\n// A hand-written wrapper rather than util.promisify(execFile): execFile's own overloaded callback signature (its non-callback form returns a live ChildProcess, distinct from the callback form promisify actually targets) trips @typescript-eslint/strict-void-return on the promisified result. This sidesteps the overload ambiguity entirely rather than suppressing the lint rule.\nasync function runCommand(\n command: string,\n args: readonly string[],\n label: string,\n): Promise<string> {\n return await new Promise<string>((resolve, reject) => {\n execFile(command, args, (error, stdout) => {\n // ExecFileException is a mapped/Omit-derived type over NodeJS.ErrnoException, not a literal `extends Error` class chain: wrapping it as the `cause` of a real Error satisfies the rejection-must-be-an-Error rule and preserves the original message/stack.\n if (error) {\n // ENOENT gets its own message because \"spawn ffmpeg ENOENT\" names the wrong thing to fix: the missing piece is the documented external binary, not the spawn call.\n const reason =\n error.code === \"ENOENT\"\n ? `${label} not found on PATH. talktrack drives the external ${label} binary for video post-processing; install it (for example \"brew install ffmpeg\" or \"apt-get install ffmpeg\") and retry.`\n : `${label} failed: ${error.message}`;\n reject(new Error(reason, { cause: error }));\n } else {\n resolve(stdout);\n }\n });\n });\n}\n\n/**\n * Runs the external ffmpeg binary with the given arguments. Rejects with an install hint rather than a bare spawn error when ffmpeg is not on PATH.\n */\nexport async function runFfmpeg(args: readonly string[]): Promise<string> {\n return await runCommand(\"ffmpeg\", args, \"ffmpeg\");\n}\n\n/** The dimensions and duration ffprobe reports for a video file. */\nexport interface VideoFacts {\n durationMs: number;\n width: number;\n height: number;\n}\n\n/**\n * Throws unless the recorded video's pixel size equals the viewport the bands are laid out against. Playwright records a smaller video than the viewport unless `video.size` is set to match (its default scales the recording to fit inside 800x800), and the framing pass would then pad that smaller picture onto a canvas sized for the full viewport: a silently mis-laid-out result that still looks like a successful run.\n */\nexport function assertVideoMatchesViewport(\n facts: Readonly<VideoFacts>,\n viewport: Readonly<{ width: number; height: number }>,\n videoPath: string,\n): void {\n if (facts.width === viewport.width && facts.height === viewport.height)\n return;\n throw new Error(\n `talktrack cannot frame ${videoPath}: the recording is ${String(facts.width)}x${String(facts.height)} but the viewport is ${String(viewport.width)}x${String(viewport.height)}. Set the recording size to the viewport size (Playwright Test: video: { mode: \"on\", size }; recordDemo does this itself) so the bands line up with the picture.`,\n );\n}\n\n/**\n * Narrows a parsed JSON value to a plain object, per the house unknown-handling rule: never an assertion, always a guard.\n */\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * Probes a video file with ffprobe for its pixel dimensions and duration in milliseconds. Any output shape other than the documented stream+format JSON object rejects with the path and what was wrong.\n */\nexport async function probeVideo(videoPath: string): Promise<VideoFacts> {\n const stdout = await runCommand(\n \"ffprobe\",\n [\n \"-v\",\n \"error\",\n \"-select_streams\",\n \"v:0\",\n \"-show_entries\",\n \"stream=width,height:format=duration\",\n \"-of\",\n \"json\",\n videoPath,\n ],\n \"ffprobe\",\n );\n // ffprobe -of json prints { streams: [{ width, height }], format: { duration } } with the duration as a decimal-seconds string.\n const parsed: unknown = JSON.parse(stdout);\n if (!isRecord(parsed)) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: not a JSON object`,\n );\n }\n const streams = parsed.streams;\n const format = parsed.format;\n if (!Array.isArray(streams) || !isRecord(format)) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: streams is not an array or format is not an object`,\n );\n }\n const firstStream: unknown = streams[0];\n if (!isRecord(firstStream)) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: first stream is not an object`,\n );\n }\n const width = firstStream.width;\n const height = firstStream.height;\n const durationRaw = format.duration;\n if (\n typeof width !== \"number\" ||\n typeof height !== \"number\" ||\n typeof durationRaw !== \"string\"\n ) {\n throw new Error(\n `Unexpected ffprobe output for ${videoPath}: stream width/height or format.duration has the wrong type`,\n );\n }\n const MS_PER_SECOND = 1000;\n\n return {\n durationMs: Math.round(Number.parseFloat(durationRaw) * MS_PER_SECOND),\n width,\n height,\n };\n}\n","import { execFile } from \"node:child_process\";\nimport { mkdtemp, rm, writeFile } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { type Browser, type Page } from \"@playwright/test\";\nimport { renderTextBand, resolveBandStyle, type BandStyle } from \"./bands\";\nimport { assertVideoMatchesViewport, probeVideo, runFfmpeg } from \"./ffmpeg\";\nimport { buildFramePlan, captionWindows } from \"./filter-graph\";\nimport { getRecordingTimeline } from \"./timeline\";\n\n// The framing pass. Playwright's video recorder captures exactly the browser viewport's own pixels, so an in-page \"narrate below the app\" or \"commit/date above the app\" banner is a contradiction in terms: anything the page itself renders is, by definition, inside the recorded viewport. This module produces that layout as a genuinely separate post-processing pass instead: the raw recording stays exactly the app's own window, and frameRecording pads a taller canvas around it (a static header band and a footer band that shows each narration line only for the real span of time it was actually on screen during the run). ffmpeg's own pad+overlay filters do the compositing.\n\n/**\n * Everything the framing pass needs. Both adapters (the Playwright Test one and the standalone runner) reduce to this: a flushed raw recording, the viewport it was recorded at, a live browser to render band PNGs through, and where to write the framed copy.\n */\nexport interface FrameRecordingInput {\n // Timeline source. Read after the context has closed is fine: the state is keyed by the Page object itself, which outlives its context.\n page: Page;\n // Path to the raw recording, already flushed to disk (Playwright Test flushes it before afterEach; the standalone runner closes the context first).\n videoPath: string;\n // The recorded viewport, captured before the context closed (page.viewportSize() throws on a closed page).\n viewport: { width: number; height: number };\n // Band PNGs are rendered through a throwaway context on this browser (see renderTextBand).\n browser: Browser;\n headerText: string | Promise<string>;\n framedPath: string;\n bands?: Partial<BandStyle>;\n}\n\n/**\n * The default header band: the word \"commit\", the short sha, a middot, and the ISO recording-start timestamp, or just the timestamp when not inside a git working tree (demos run from anywhere, not only repos).\n */\nexport async function defaultHeaderText(startedAtMs: number): Promise<string> {\n const createdAtIso = new Date(startedAtMs).toISOString();\n try {\n const sha = await new Promise<string>((resolve, reject) => {\n execFile(\"git\", [\"rev-parse\", \"--short\", \"HEAD\"], (error, stdout) => {\n if (error) reject(new Error(error.message, { cause: error }));\n else resolve(stdout.trim());\n });\n });\n\n return `commit ${sha} · ${createdAtIso}`;\n } catch {\n return createdAtIso;\n }\n}\n\n/**\n * Frames one raw recording: renders the header and caption band PNGs, probes the raw video's duration, and composites everything into {@link FrameRecordingInput.framedPath} with ffmpeg. Returns that path.\n */\nexport async function frameRecording(\n input: Readonly<FrameRecordingInput>,\n): Promise<string> {\n const style = resolveBandStyle(input.bands);\n const timeline = getRecordingTimeline(input.page);\n const facts = await probeVideo(input.videoPath);\n assertVideoMatchesViewport(facts, input.viewport, input.videoPath);\n const windows = captionWindows(timeline.events, facts.durationMs);\n const plan = buildFramePlan(input.viewport, style, windows);\n\n // Deliberately separate from the demo's own recording context (see renderTextBand's own comment for why).\n const bandContext = await input.browser.newContext();\n const workDir = await mkdtemp(join(tmpdir(), \"talktrack-frame-\"));\n try {\n const inputArgs: string[] = [\"-i\", input.videoPath];\n const headerPngPath = join(workDir, \"header.png\");\n await writeFile(\n headerPngPath,\n await renderTextBand(bandContext, style, {\n width: input.viewport.width,\n height: style.headerHeightPx,\n fontSizePx: style.headerFontSizePx,\n text: await input.headerText,\n }),\n );\n inputArgs.push(\"-i\", headerPngPath);\n for (const [i, window] of windows.entries()) {\n const captionPngPath = join(workDir, `caption-${String(i)}.png`);\n await writeFile(\n captionPngPath,\n await renderTextBand(bandContext, style, {\n width: input.viewport.width,\n height: style.footerHeightPx,\n fontSizePx: style.footerFontSizePx,\n text: window.text,\n }),\n );\n inputArgs.push(\"-i\", captionPngPath);\n }\n\n await runFfmpeg([\n \"-y\",\n ...inputArgs,\n \"-filter_complex\",\n plan.filterGraph,\n \"-map\",\n `[${plan.finalLabel}]`,\n \"-c:v\",\n \"libvpx-vp9\",\n \"-crf\",\n \"32\",\n \"-b:v\",\n \"0\",\n \"-deadline\",\n \"good\",\n \"-cpu-used\",\n \"3\",\n input.framedPath,\n ]);\n\n return input.framedPath;\n } finally {\n await bandContext.close();\n await rm(workDir, { recursive: true, force: true });\n }\n}\n","import { mkdtemp, rm } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { type Page, type TestInfo } from \"@playwright/test\";\nimport { type BandStyle } from \"./bands\";\nimport { defaultHeaderText, frameRecording } from \"./frame\";\nimport { getRecordingTimeline } from \"./timeline\";\n\n// The @playwright/test adapter. Call from afterEach: it closes the spec's page so Playwright finalises the video, frames the recording, and attaches the framed copy next to the raw one as a test attachment.\n\n/**\n * Optional overrides for {@link finalizeFramedRecording}: the header band's text, partial band styling, and where the framed copy is written (default: next to the raw video, with a -framed suffix).\n */\nexport interface FinalizeOptions {\n headerText?: string | Promise<string>;\n bands?: Partial<BandStyle>;\n framedPath?: string;\n}\n\n/**\n * Produces a taller, framed copy of the just-recorded spec video: the app window exactly as recorded, a static header above it, and a footer below it showing each narration line only while it was actually shown during the run. A no-op returning undefined (not a failure) when there's no video to frame at all (a spec run without video enabled) or no viewport to size bands against.\n *\n * Closes the page. Playwright only finalises a page's video once the page is closed, and an afterEach hook runs while the test's page is still open, so the recording on disk is incomplete until then: the adapter closes the page itself and waits for the finalised video (video.saveAs waits for exactly that) before reading it. The page is unusable afterwards, which is why this belongs in afterEach and not mid-test.\n */\nexport async function finalizeFramedRecording(\n page: Page,\n testInfo: TestInfo,\n opts?: FinalizeOptions,\n): Promise<string | undefined> {\n const video = page.video();\n if (!video) return undefined;\n const viewport = page.viewportSize();\n if (!viewport) return undefined;\n const browser = page.context().browser();\n if (!browser)\n throw new Error(\n \"talktrack cannot frame this recording: a persistent browser context has no separate Browser handle to render caption bands through\",\n );\n\n // Only used for naming the framed copy: path() reports where the video is being written without waiting for it to finish.\n const rawPath = await video.path();\n const framedPath =\n opts?.framedPath ?? rawPath.replace(/\\.webm$/, \"-framed.webm\");\n const headerText =\n opts?.headerText ?? defaultHeaderText(getRecordingTimeline(page).startedAt);\n\n const scratchDir = await mkdtemp(join(tmpdir(), \"talktrack-raw-\"));\n try {\n await page.close();\n const finalisedRawPath = join(scratchDir, \"raw.webm\");\n await video.saveAs(finalisedRawPath);\n\n await frameRecording({\n page,\n videoPath: finalisedRawPath,\n viewport,\n browser,\n headerText,\n framedPath,\n ...(opts?.bands ? { bands: opts.bands } : {}),\n });\n } finally {\n await rm(scratchDir, { recursive: true, force: true });\n }\n await testInfo.attach(\"framed-recording\", {\n path: framedPath,\n contentType: \"video/webm\",\n });\n\n return framedPath;\n}\n","import { mkdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport {\n type Browser,\n type BrowserContext,\n type BrowserContextOptions,\n type BrowserType,\n type LaunchOptions,\n type Locator,\n chromium,\n type Page,\n} from \"@playwright/test\";\nimport { type BandStyle } from \"./bands\";\nimport {\n beat,\n fillVisibly,\n gotoWithCursor,\n hideCaption,\n installCursor,\n moveAndClick,\n narrateScene,\n naturalScrollIntoView,\n reloadWithCursor,\n showCaption,\n} from \"./cursor\";\nimport { defaultHeaderText, frameRecording } from \"./frame\";\nimport { getRecordingTimeline } from \"./timeline\";\n\n// The standalone runner: everything installCursor/narrateScene give a @playwright/test spec, but from a plain script with no test runner involved. recordDemo launches (or borrows) a browser, opens a video-recording context, installs the cursor, and returns the same helper surface bound to that page; finish() closes the context (which flushes Playwright's webm), runs the framing pass, and returns the framed recording's path.\n\n/**\n * Everything recordDemo needs: the recorded viewport, where to start, where outputs go, and optional overrides for the browser, context, header band, and band styling.\n */\nexport interface RecordDemoOptions {\n // Recorded viewport. Playwright records exactly this rectangle; 16:9 ratios frame cleanly.\n viewport: { width: number; height: number };\n // Initial navigation; omit to drive the page entirely by hand.\n url?: string;\n // Where Playwright writes the raw recording and talktrack writes the framed one. Default: talktrack-output under the working directory.\n outputDir?: string;\n // Bring your own browser (shared across demos); by default one is launched per recordDemo call and closed by finish().\n browser?: Browser;\n // Which browser to launch when `browser` is not supplied. Default: chromium.\n browserType?: BrowserType<Browser>;\n launchOptions?: LaunchOptions;\n // Extra context options (locale, colorScheme, ...). recordVideo and viewport are owned by the runner.\n contextOptions?: Omit<BrowserContextOptions, \"recordVideo\" | \"viewport\">;\n headerText?: string | Promise<string>;\n bands?: Partial<BandStyle>;\n}\n\n/**\n * The bound helper surface recordDemo returns: the cursor/narration helpers a spec gets, wrapped around one recording page, plus {@link TalktrackSession.finish} to flush and frame the video.\n */\nexport interface TalktrackSession {\n readonly page: Page;\n readonly context: BrowserContext;\n moveAndClick: (locator: Readonly<Locator>, pauseMs?: number) => Promise<void>;\n fillVisibly: (\n locator: Readonly<Locator>,\n text: string,\n pauseMs?: number,\n ) => Promise<void>;\n naturalScrollIntoView: (locator: Readonly<Locator>) => Promise<void>;\n goto: (url: string) => Promise<void>;\n reload: () => Promise<void>;\n caption: (text: string) => void;\n hideCaption: () => void;\n narrateScene: <T>(\n text: string,\n settle: () => Promise<T>,\n opts?: { leadInMs?: number; dwellMs?: number },\n ) => Promise<T>;\n beat: (ms?: number) => Promise<void>;\n // Closes the recording context, frames the raw video, closes the browser if this session launched it, and returns the framed recording's path. Call exactly once, at the end.\n finish: () => Promise<string>;\n}\n\n/**\n * Starts one standalone demo recording: launches (or borrows) a browser, opens a video-recording context at the given viewport, installs the cursor, optionally navigates to `url`, and returns the session. Drive the page through the session's helpers, then `await session.finish()` once; it returns the framed recording's path.\n */\nexport async function recordDemo(\n name: string,\n opts: Readonly<RecordDemoOptions>,\n): Promise<TalktrackSession> {\n const outputDir = opts.outputDir ?? join(process.cwd(), \"talktrack-output\");\n await mkdir(outputDir, { recursive: true });\n\n const ownsBrowser = opts.browser === undefined;\n const browser =\n opts.browser ??\n (await (opts.browserType ?? chromium).launch(opts.launchOptions ?? {}));\n const context = await browser.newContext({\n ...opts.contextOptions,\n viewport: opts.viewport,\n recordVideo: { dir: outputDir, size: opts.viewport },\n });\n const page = await context.newPage();\n await installCursor(page);\n if (opts.url !== undefined) await gotoWithCursor(page, opts.url);\n\n const finish = async (): Promise<string> => {\n // Captured before close: both throw on a closed context/page, and the framing pass needs them after the video is flushed.\n const viewport = page.viewportSize();\n if (!viewport)\n throw new Error(\n \"talktrack could not read the viewport before closing the demo's context\",\n );\n const video = page.video();\n if (!video)\n throw new Error(\"talktrack's recording context produced no video handle\");\n\n const videoPathPromise = video.path();\n await context.close();\n const videoPath = await videoPathPromise;\n\n const framedPath = join(outputDir, `${name}-framed.webm`);\n const headerText =\n opts.headerText ??\n defaultHeaderText(getRecordingTimeline(page).startedAt);\n const framed = await frameRecording({\n page,\n videoPath,\n viewport,\n browser,\n headerText,\n framedPath,\n ...(opts.bands ? { bands: opts.bands } : {}),\n });\n if (ownsBrowser) await browser.close();\n\n return framed;\n };\n\n return {\n page,\n context,\n moveAndClick: async (locator, pauseMs) =>\n moveAndClick(page, locator, pauseMs),\n fillVisibly: async (locator, text, pauseMs) =>\n fillVisibly(page, locator, text, pauseMs),\n naturalScrollIntoView: async (locator) =>\n naturalScrollIntoView(page, locator),\n goto: async (url) => gotoWithCursor(page, url),\n reload: async () => reloadWithCursor(page),\n caption: (text) => {\n showCaption(page, text);\n },\n hideCaption: () => {\n hideCaption(page);\n },\n narrateScene: async (text, settle, narrateOpts) =>\n narrateScene(page, text, settle, narrateOpts),\n beat: async (ms) => beat(page, ms),\n finish,\n };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talktrack",
|
|
3
3
|
"description": "Narrated, cursor-driven demo recordings from Playwright: a synthetic human-feeling cursor, caption bands timed to real page events, and ffmpeg-framed output, usable inside @playwright/test specs or from standalone scripts.",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.6",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/ExaDev/talktrack/issues"
|
|
7
7
|
},
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"@commitlint/cli": "^21.2.2",
|
|
11
11
|
"@commitlint/config-conventional": "^21.2.2",
|
|
12
12
|
"@eslint/js": "^10.0.1",
|
|
13
|
-
"@exadev/eslint-config": "
|
|
13
|
+
"@exadev/eslint-config": "3.0.0",
|
|
14
14
|
"@playwright/test": "^1.63.0",
|
|
15
15
|
"@semantic-release/changelog": "^7.0.0",
|
|
16
16
|
"@semantic-release/commit-analyzer": "^13.0.1",
|