@milkstraw/traceframe 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Traceframe contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,124 @@
1
- # Temporary Holding Version
1
+ # Traceframe
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Traceframe records a scripted Chromium flow and renders an MP4 with a smooth cursor and click pulse. Everything runs locally: pages, flows, videos, and authentication state stay on your machine.
4
+
5
+ https://github.com/user-attachments/assets/38e02cd5-a9ba-4f3c-938c-159955353141
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @milkstraw/traceframe
11
+ ```
12
+
13
+ Traceframe requires Node.js 22+, FFmpeg 5+ with `libx264` and `ffprobe` (for example, `brew install ffmpeg` or `sudo apt install ffmpeg`), and Playwright's Chromium:
14
+
15
+ ```sh
16
+ npx playwright@1.64.1 install --only-shell chromium
17
+ ```
18
+
19
+ To use a system Chrome instead, pass `--browser` or set `CHROME_PATH`. Playwright still needs its own FFmpeg build to record the page, so install it with `npx playwright@1.64.1 install ffmpeg`.
20
+
21
+ To record pages behind a login, pass a Playwright storage-state file with `--storage-state`. Keep it in `.auth/` or another ignored location.
22
+
23
+ ## Record a video
24
+
25
+ The included fixture is available in a repository clone. It is not included in the installed package.
26
+
27
+ ```sh
28
+ git clone https://github.com/milkstrawai/traceframe.git
29
+ cd traceframe
30
+ npm ci
31
+ npx traceframe record \
32
+ --url "file://$PWD/examples/fixture.html" \
33
+ --flow ./examples/fixture-flow.mjs \
34
+ --out ./sample.mp4 \
35
+ --preset draft
36
+ ```
37
+
38
+ For an installed package, pass your application URL and your own flow file:
39
+
40
+ ```sh
41
+ npx traceframe record \
42
+ --url http://localhost:3000 \
43
+ --flow ./flow.mjs \
44
+ --out ./recording.mp4
45
+ ```
46
+
47
+ The `draft` preset uses a 1280×800 CSS viewport and produces 1280×800 at 30 FPS. The `standard` preset uses the same CSS viewport and encodes it at 2560×1600 at 60 FPS. The cursor and click pulses are always drawn at the full frame rate, but Chromium may capture page changes, such as scrolling and animations, at a lower rate at 2560×1600, depending on the machine. When that happens, the video repeats the last page frame until the next one arrives. The video is always the viewport size times `--scale`, so `--preset draft --scale 2` gives 2560×1600 at 30 FPS. The command prints a JSON summary when it finishes.
48
+
49
+ Use `--run-dir .traceframe/runs/name` (`runDirectory` in the API) to keep the raw capture and the FFmpeg filter file.
50
+
51
+ ## Have an agent create the flow
52
+
53
+ For a real application, ask your coding agent to inspect the running UI and create the flow from the user journey. This keeps selectors tied to the product and avoids manually guessing coordinates. For example:
54
+
55
+ ```text
56
+ Create a Traceframe flow for this user journey: open Settings, change the theme, and save it.
57
+ Inspect the running application, use stable role or test-id locators, add a ready hook,
58
+ and use Traceframe's click, hover, move, and wait helpers for visible interactions.
59
+ Write the result to flow.mjs and explain how to run it.
60
+ ```
61
+
62
+ The flow module exports a default function. Timing values are seconds:
63
+
64
+ ```js
65
+ export async function ready({ page }) {
66
+ await page.getByRole('heading', { name: 'Example workspace' }).waitFor()
67
+ }
68
+
69
+ export default async function ({ page, click, wait }) {
70
+ await click(page.getByRole('button', { name: 'Open settings' }), { duration: 0.8 })
71
+ await wait(1)
72
+ }
73
+ ```
74
+
75
+ `ready({ page, signal })` runs before recording starts. `--ready-timeout` (30 seconds by default) is Playwright's timeout for the page load and for each Playwright call in `ready`; it does not limit other work in the hook. A flow can also export `initialPointer = { x, y }` to choose where the cursor starts. By default it starts near the lower left.
76
+
77
+ The default function receives `page`, `signal`, and four helpers: `move({ x, y }, { duration })`, `hover(locator, { duration })`, `click(locator, { duration, dwell })`, and `wait(seconds)`. A move takes 0.5 seconds unless you pass `duration`, and a click rests on the target for 0.25 seconds (`dwell`) before it presses. If the target is disabled, the click waits for it to become enabled. A flow may contain at most 100 moves, 100 clicks, and 120 seconds of work, and each `duration`, `dwell`, and `wait` is at most 30 seconds. Error messages name the locator; give it a clearer name with Playwright's `locator.describe('Save settings')`.
78
+
79
+ `click` and `hover` use real Playwright mouse events and add their movement to the overlay timeline. If the pointer is already inside the target, it stays where it is, so a hover effect that moves or resizes the target does not pull the cursor along. A click holds the mouse button for about 100 ms, as a person would, so press styles such as `:active` show in the video. Headless Chromium draws pages without a GPU, so on heavy pages, such as ones with blurred backgrounds, hover effects can appear a little after the cursor passes. A longer `duration`, or a `move` around hover-reactive elements, keeps this from showing. Typing, scrolling, dragging, multiple tabs, and direct `page.mouse` or `locator.click()` calls are not currently represented in that timeline. A flow receives an `AbortSignal`; helper calls stop promptly when the duration limit is reached.
80
+
81
+ ## Programmatic usage
82
+
83
+ ```js
84
+ import { record } from '@milkstraw/traceframe'
85
+
86
+ await record({
87
+ url: 'http://localhost:3000',
88
+ flow: './flow.mjs',
89
+ output: './recording.mp4',
90
+ preset: 'standard',
91
+ })
92
+ ```
93
+
94
+ Traceframe's own errors are `TraceframeError` instances (exported by the package) with a `code`, such as `READY_FAILED` or `FLOW_TARGET_OBSCURED`.
95
+
96
+ Pass `signal` to stop a recording early. Traceframe closes the browser, stops FFmpeg, and removes its temporary files before rejecting with the signal's reason. When you pass a signal, Traceframe leaves Ctrl-C and other termination signals to you. The CLI does this for you: the first Ctrl-C stops and cleans up, and a second one exits at once.
97
+
98
+ ## Sample output
99
+
100
+ The committed sample is generated from the included fixture flow with the `standard` preset (2560×1600 at 60 FPS) and is about 12 seconds long:
101
+
102
+ [Download the sample video](https://github.com/milkstrawai/traceframe/blob/main/examples/sample-output.mp4)
103
+
104
+ The cursor and click-pulse artwork and the example page's icons were made for Traceframe and are distributed under this repository's MIT license.
105
+
106
+ The example page uses the [Inter](https://github.com/rsms/inter) font, included under the SIL Open Font License in `examples/fonts/OFL.txt`.
107
+
108
+ ## Development
109
+
110
+ ```sh
111
+ npm ci
112
+ npm test
113
+ npm run test:smoke
114
+ TRACEFRAME_PRESET=standard npm run test:smoke
115
+ npm run test:package
116
+ ```
117
+
118
+ After editing an SVG in `src/assets`, run `npm run build:assets` to rebuild its 1× and 2× PNGs.
119
+
120
+ To re-record the committed sample:
121
+
122
+ ```sh
123
+ npx traceframe record --url "file://$PWD/examples/fixture.html" --flow ./examples/fixture-flow.mjs --out ./examples/sample-output.mp4
124
+ ```
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
2
  "name": "@milkstraw/traceframe",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Record scripted browser flows and render smooth cursor overlays into video.",
5
+ "keywords": [
6
+ "playwright",
7
+ "screen-recording",
8
+ "video",
9
+ "demo",
10
+ "cursor",
11
+ "browser-automation"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/milkstrawai/traceframe.git"
16
+ },
17
+ "homepage": "https://github.com/milkstrawai/traceframe#readme",
18
+ "bugs": "https://github.com/milkstrawai/traceframe/issues",
19
+ "type": "module",
20
+ "bin": {
21
+ "traceframe": "src/cli.mjs"
22
+ },
23
+ "exports": {
24
+ ".": "./src/index.mjs"
25
+ },
26
+ "files": [
27
+ "src"
28
+ ],
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "scripts": {
33
+ "test": "node --test",
34
+ "test:smoke": "node scripts/smoke.mjs",
35
+ "test:package": "node scripts/package-smoke.mjs",
36
+ "build:assets": "node scripts/build-assets.mjs"
37
+ },
38
+ "dependencies": {
39
+ "playwright": "1.64.1"
40
+ },
41
+ "license": "MIT",
42
+ "publishConfig": {
43
+ "access": "public"
44
+ }
45
+ }
Binary file
Binary file
@@ -0,0 +1,4 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30">
2
+ <circle cx="15" cy="15" r="8" fill="none" stroke="#fff" stroke-width="5" opacity=".82"/>
3
+ <circle cx="15" cy="15" r="8" fill="none" stroke="#14191e" stroke-width="3" opacity=".78"/>
4
+ </svg>
Binary file
Binary file
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
2
+ <path d="M6.6 1.6Q6.6 1.05 7.05 1.42L21.2 12.55Q22.15 13.3 21.7 14.05Q21.45 14.4 20.95 14.4H15.35L18.25 19.95Q18.65 20.72 17.9 21.1L16.05 22.05Q15.3 22.45 14.9 21.7L11.85 16L8.35 19.45Q7.85 19.95 7.35 19.65Q6.9 19.4 6.9 18.8Z" fill="#000000" stroke="#FFFFFF" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke" transform="translate(6.6 1.6) scale(0.78 0.84) translate(-6.6 -1.6)"/>
3
+ </svg>
@@ -0,0 +1,111 @@
1
+ import { addAbortListener } from 'node:events'
2
+ import { join } from 'node:path'
3
+ import { setTimeout as delay } from 'node:timers/promises'
4
+ import { chromium } from 'playwright'
5
+
6
+ import { TraceframeError } from './errors.mjs'
7
+ import { createFlowApi, LIMITS } from './flow.mjs'
8
+
9
+ const PLAYWRIGHT_DEFAULT_TIMEOUT = 30_000
10
+ const FIRST_FRAME_TIMEOUT = 10_000
11
+
12
+ export async function captureFlow({ config, flow, directory, signal: callerSignal }) {
13
+ const signal = callerSignal ?? new AbortController().signal
14
+ const rawPath = join(directory, 'capture.webm')
15
+ const trace = { initialPointer: flow.initialPointer, moves: [], clicks: [] }
16
+
17
+ signal.throwIfAborted()
18
+ const browser = await launchBrowser(config, { handleSignals: !callerSignal })
19
+ let page
20
+ try {
21
+ page = await browser.newPage({ viewport: config.viewport, deviceScaleFactor: config.scale, storageState: config.storageState })
22
+ await raceAbort(waitForReady(page, flow, config, signal), signal)
23
+ await page.mouse.move(trace.initialPointer.x, trace.initialPointer.y)
24
+
25
+ const firstFrame = Promise.withResolvers()
26
+ await startScreencast(page, rawPath, config, ({ timestamp }) => firstFrame.resolve(timestamp))
27
+ const clock = await withDeadline(
28
+ () => firstFrame.promise,
29
+ FIRST_FRAME_TIMEOUT,
30
+ new TraceframeError(`No screencast frame arrived within ${FIRST_FRAME_TIMEOUT / 1000} seconds.`, { code: 'FIRST_FRAME_TIMEOUT' }),
31
+ signal,
32
+ )
33
+ await withDeadline(
34
+ (flowSignal) => flow.run({ page, signal: flowSignal, ...createFlowApi({ page, trace, clock, viewport: config.viewport, signal: flowSignal }) }),
35
+ LIMITS.maxDuration * 1000,
36
+ new TraceframeError(`Flow exceeded the ${LIMITS.maxDuration}-second duration limit.`, { code: 'FLOW_TOO_LONG' }),
37
+ signal,
38
+ )
39
+ trace.end = (Date.now() - clock) / 1000
40
+ await delay(config.postRoll * 1000, undefined, { signal })
41
+ await page.screencast.stop()
42
+ return { rawPath, trace }
43
+ } catch (error) {
44
+ // Closing the browser alone discards the screencast. Stop it so a run directory keeps what was recorded.
45
+ await page?.screencast.stop().catch(() => {})
46
+ throw error
47
+ } finally {
48
+ await browser.close()
49
+ }
50
+ }
51
+
52
+ async function launchBrowser(config, { handleSignals }) {
53
+ try {
54
+ return await chromium.launch({
55
+ executablePath: config.browser,
56
+ args: config.scale === 2 ? ['--force-device-scale-factor=2'] : [],
57
+ handleSIGINT: handleSignals,
58
+ handleSIGTERM: handleSignals,
59
+ handleSIGHUP: handleSignals,
60
+ })
61
+ } catch (error) {
62
+ if (!/Executable doesn't exist/i.test(error.message)) throw error
63
+ const message = config.browser
64
+ ? `Configured browser executable is unavailable: ${config.browser}`
65
+ : 'Playwright Chromium is not installed. Run `npx playwright@1.64.1 install --only-shell chromium` or pass --browser to a Chrome executable.'
66
+ throw new TraceframeError(message, { code: 'PLAYWRIGHT_RUNTIME_MISSING', cause: error })
67
+ }
68
+ }
69
+
70
+ async function waitForReady(page, flow, config, signal) {
71
+ try {
72
+ page.setDefaultTimeout(config.readyTimeout * 1000)
73
+ await page.goto(config.url)
74
+ await page.evaluate(() => document.fonts.ready)
75
+ await flow.ready?.({ page, signal })
76
+ page.setDefaultTimeout(PLAYWRIGHT_DEFAULT_TIMEOUT)
77
+ } catch (error) {
78
+ throw new TraceframeError(`Ready check failed: ${error instanceof Error ? error.message : error}`, { code: 'READY_FAILED', cause: error })
79
+ }
80
+ }
81
+
82
+ async function startScreencast(page, path, config, onFrame) {
83
+ try {
84
+ await page.screencast.start({ path, size: { width: config.video.width, height: config.video.height }, fps: config.video.fps, quality: 95, onFrame })
85
+ } catch (error) {
86
+ if (!/Executable doesn't exist/i.test(error.message)) throw error
87
+ throw new TraceframeError("Playwright's FFmpeg is not installed. It is needed to record, even with --browser. Run `npx playwright@1.64.1 install ffmpeg`.", { code: 'PLAYWRIGHT_RUNTIME_MISSING', cause: error })
88
+ }
89
+ }
90
+
91
+ // Settles with the task, or rejects at the deadline or on abort, even if the task ignores its signal.
92
+ async function withDeadline(task, milliseconds, error, signal) {
93
+ const controller = new AbortController()
94
+ const timer = setTimeout(() => controller.abort(error), milliseconds)
95
+ const deadline = AbortSignal.any([signal, controller.signal])
96
+ try {
97
+ return await raceAbort(task(deadline), deadline)
98
+ } finally {
99
+ clearTimeout(timer)
100
+ }
101
+ }
102
+
103
+ async function raceAbort(promise, signal) {
104
+ const aborted = Promise.withResolvers()
105
+ const listener = addAbortListener(signal, () => aborted.reject(signal.reason))
106
+ try {
107
+ return await Promise.race([promise, aborted.promise])
108
+ } finally {
109
+ listener[Symbol.dispose]()
110
+ }
111
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createRequire } from 'node:module'
4
+ import { parseArgs } from 'node:util'
5
+
6
+ import { LIMITS } from './flow.mjs'
7
+ import { record } from './record.mjs'
8
+
9
+ const { version: VERSION } = createRequire(import.meta.url)('../package.json')
10
+ const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }
11
+ const OPTION_KEYS = {
12
+ url: 'url',
13
+ flow: 'flow',
14
+ out: 'output',
15
+ preset: 'preset',
16
+ browser: 'browser',
17
+ 'storage-state': 'storageState',
18
+ 'viewport-width': 'viewportWidth',
19
+ 'viewport-height': 'viewportHeight',
20
+ scale: 'scale',
21
+ fps: 'fps',
22
+ 'post-roll': 'postRoll',
23
+ 'ready-timeout': 'readyTimeout',
24
+ 'run-dir': 'runDirectory',
25
+ }
26
+ const NUMBER_KEYS = new Set(['viewportWidth', 'viewportHeight', 'scale', 'fps', 'postRoll', 'readyTimeout'])
27
+ const HELP = `traceframe ${VERSION}
28
+
29
+ Record a scripted Chromium flow and render a smooth cursor and click pulse.
30
+
31
+ Usage:
32
+ traceframe record --url <url> --flow <flow.mjs> [--out <video.mp4>] [options]
33
+
34
+ Options:
35
+ --preset <standard|draft> standard: 1280x800 CSS viewport, 2560x1600 at 60 FPS (default)
36
+ draft: 1280x800 CSS viewport, 1280x800 at 30 FPS
37
+ --browser <path> Chromium executable (or set CHROME_PATH)
38
+ --storage-state <path> Playwright storage state JSON
39
+ --viewport-width <pixels> CSS viewport width
40
+ --viewport-height <pixels> CSS viewport height
41
+ --scale <1|2> Device scale factor; the video is the viewport times this
42
+ --fps <30|60> Encoded frame rate
43
+ --post-roll <seconds> Tail after the flow (default: 0.3)
44
+ --ready-timeout <seconds> Playwright timeout for the page load and calls in ready() (default: 30)
45
+ --run-dir <path> Keep the raw capture and FFmpeg filter in this directory
46
+ -h, --help Show this help
47
+ -v, --version Show the version
48
+
49
+ The flow module must export a default function. It may also export
50
+ ready({ page, signal }) to establish state before capture and initialPointer
51
+ { x, y } to set where the cursor starts.
52
+ The flow helpers are move(), hover(), click(), and wait(). A flow may contain
53
+ at most ${LIMITS.maxMoves} move events, ${LIMITS.maxClicks} click events, and ${LIMITS.maxDuration} seconds of work.
54
+ Each duration, dwell, and wait is at most ${LIMITS.maxActionDuration} seconds.
55
+ `
56
+
57
+ const interruption = new AbortController()
58
+ let interruptedBy
59
+ for (const [signal, exitCode] of Object.entries(SIGNAL_EXIT_CODES)) {
60
+ process.on(signal, () => {
61
+ if (interruption.signal.aborted) process.exit(exitCode)
62
+ interruptedBy = signal
63
+ process.stderr.write(`traceframe: Stopping and cleaning up.${signal === 'SIGINT' ? ' Press Ctrl-C again to exit now.' : ''}\n`)
64
+ interruption.abort()
65
+ })
66
+ }
67
+
68
+ try {
69
+ await main()
70
+ } catch (error) {
71
+ if (interruptedBy) {
72
+ process.stderr.write('traceframe: Recording was interrupted.\n')
73
+ process.exitCode = SIGNAL_EXIT_CODES[interruptedBy]
74
+ } else {
75
+ process.stderr.write(`traceframe: ${error instanceof Error ? error.message : String(error)}\n`)
76
+ process.exitCode = 1
77
+ }
78
+ }
79
+
80
+ async function main() {
81
+ const parsed = parseCommand(process.argv.slice(2))
82
+ if (parsed.help) return process.stdout.write(HELP)
83
+ if (parsed.version) return process.stdout.write(`${VERSION}\n`)
84
+ const summary = await record({ ...parsed.options, signal: interruption.signal })
85
+ process.stdout.write(`${JSON.stringify(summary)}\n`)
86
+ }
87
+
88
+ function parseCommand(args) {
89
+ const { values, positionals } = parseArgs({
90
+ args,
91
+ allowPositionals: true,
92
+ options: {
93
+ help: { type: 'boolean', short: 'h' },
94
+ version: { type: 'boolean', short: 'v' },
95
+ ...Object.fromEntries(Object.keys(OPTION_KEYS).map((name) => [name, { type: 'string' }])),
96
+ },
97
+ })
98
+ if (values.help || args.length === 0) return { help: true }
99
+ if (values.version) return { version: true }
100
+ const [command, ...rest] = positionals
101
+ if (command !== 'record') throw new Error('Expected the record command. Use "traceframe record --help".')
102
+ if (rest.length) throw new Error(`Unexpected argument: ${rest[0]}`)
103
+
104
+ const options = {}
105
+ for (const [name, key] of Object.entries(OPTION_KEYS)) {
106
+ if (values[name] !== undefined) options[key] = NUMBER_KEYS.has(key) ? Number(values[name]) : values[name]
107
+ }
108
+ return { options }
109
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,57 @@
1
+ import { resolve } from 'node:path'
2
+
3
+ import { TraceframeError } from './errors.mjs'
4
+
5
+ const PRESETS = {
6
+ standard: { viewport: { width: 1280, height: 800 }, scale: 2, fps: 60 },
7
+ draft: { viewport: { width: 1280, height: 800 }, scale: 1, fps: 30 },
8
+ }
9
+
10
+ export function createConfig(options = {}) {
11
+ const presetName = options.preset ?? 'standard'
12
+ check(Object.hasOwn(PRESETS, presetName), `Unknown preset: ${presetName}`)
13
+ const preset = PRESETS[presetName]
14
+
15
+ const viewport = {
16
+ width: options.viewportWidth ?? preset.viewport.width,
17
+ height: options.viewportHeight ?? preset.viewport.height,
18
+ }
19
+ check(Number.isInteger(viewport.width) && viewport.width > 0, 'Viewport width must be a positive integer.')
20
+ check(Number.isInteger(viewport.height) && viewport.height > 0, 'Viewport height must be a positive integer.')
21
+ const scale = options.scale ?? preset.scale
22
+ check(scale === 1 || scale === 2, 'Scale must be 1 or 2.')
23
+ const video = {
24
+ width: viewport.width * scale,
25
+ height: viewport.height * scale,
26
+ fps: options.fps ?? preset.fps,
27
+ }
28
+ check(video.fps === 30 || video.fps === 60, 'FPS must be 30 or 60.')
29
+ check(video.width % 2 === 0 && video.height % 2 === 0, 'At scale 1, the viewport width and height must be even.')
30
+
31
+ check(options.flow, 'A flow file is required (the flow option, or --flow).')
32
+ check(options.url, 'A URL is required (the url option, or --url).')
33
+ const output = resolve(options.output ?? 'traceframe.mp4')
34
+ check(output.toLowerCase().endsWith('.mp4'), 'Output path must use the .mp4 extension.')
35
+ const postRoll = options.postRoll ?? 0.3
36
+ check(Number.isFinite(postRoll) && postRoll >= 0, 'Post-roll must be 0 or more seconds.')
37
+ const readyTimeout = options.readyTimeout ?? 30
38
+ check(Number.isFinite(readyTimeout) && readyTimeout > 0, 'Ready timeout must be more than 0 seconds.')
39
+
40
+ return {
41
+ url: options.url,
42
+ flow: resolve(options.flow),
43
+ output,
44
+ storageState: options.storageState,
45
+ browser: options.browser ?? process.env.CHROME_PATH,
46
+ viewport,
47
+ video,
48
+ scale,
49
+ postRoll,
50
+ readyTimeout,
51
+ runDirectory: options.runDirectory ? resolve(options.runDirectory) : undefined,
52
+ }
53
+ }
54
+
55
+ function check(valid, message) {
56
+ if (!valid) throw new TraceframeError(message, { code: 'INVALID_CONFIG' })
57
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,7 @@
1
+ export class TraceframeError extends Error {
2
+ constructor(message, { code, ...options } = {}) {
3
+ super(message, options)
4
+ this.name = 'TraceframeError'
5
+ this.code = code
6
+ }
7
+ }
package/src/ffmpeg.mjs ADDED
@@ -0,0 +1,54 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+
4
+ import { TraceframeError } from './errors.mjs'
5
+
6
+ const execFileAsync = promisify(execFile)
7
+
8
+ export async function probeFfmpeg() {
9
+ const version = await runCommand('ffmpeg', ['-version'])
10
+ const major = parseFfmpegMajor(version.stdout)
11
+ if (!(major >= 5)) {
12
+ throw new TraceframeError(`FFmpeg 5 or newer is required. Found: ${version.stdout.split('\n')[0].trim()}`, { code: 'FFMPEG_UNSUPPORTED' })
13
+ }
14
+ const codecs = await runCommand('ffmpeg', ['-hide_banner', '-encoders'])
15
+ if (!codecs.stdout.includes('libx264')) {
16
+ throw new TraceframeError('System FFmpeg does not provide the libx264 encoder.', { code: 'FFMPEG_UNSUPPORTED' })
17
+ }
18
+ await runCommand('ffprobe', ['-version'])
19
+ return { filterComplexFileOption: filterComplexFileOption(major) }
20
+ }
21
+
22
+ export async function probeDuration(path, signal) {
23
+ const { stdout } = await runCommand('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path], { signal })
24
+ return Number(stdout)
25
+ }
26
+
27
+ export async function runCommand(command, args, options = {}) {
28
+ const run = execFileAsync(command, args, options)
29
+ const closed = new Promise((resolve) => run.child.once('close', resolve))
30
+ try {
31
+ return await run
32
+ } catch (error) {
33
+ // An abort rejects at once, but the process may take a moment to exit. Wait so cleanup does not race it.
34
+ if (options.signal?.aborted) await closed
35
+ if (error.code === 'ENOENT') {
36
+ throw new TraceframeError(`${command} was not found. Install FFmpeg 5 or newer with libx264, and make sure ffmpeg and ffprobe are on your PATH.`, { code: 'FFMPEG_MISSING' })
37
+ }
38
+ const rawDetail = error.stderr?.trim() || error.stdout?.trim() || error.message
39
+ const detail = rawDetail.length > 2_000 ? `${rawDetail.slice(0, 1_900)}\n… output truncated` : rawDetail
40
+ throw new TraceframeError(`${command} failed: ${detail}`, { code: 'COMMAND_FAILED', cause: error })
41
+ }
42
+ }
43
+
44
+ export function parseFfmpegMajor(output) {
45
+ const release = output.match(/ffmpeg version [nN]?(\d+)\./)
46
+ if (release) return Number(release[1])
47
+ // Snapshot builds print no release number. libavutil 57 shipped with FFmpeg 5, 58 with 6, 59 with 7, and so on.
48
+ const libavutil = output.match(/libavutil\s+(\d+)\./)
49
+ return libavutil ? Number(libavutil[1]) - 52 : undefined
50
+ }
51
+
52
+ export function filterComplexFileOption(major) {
53
+ return major >= 7 ? '-/filter_complex' : '-filter_complex_script'
54
+ }
package/src/flow.mjs ADDED
@@ -0,0 +1,197 @@
1
+ import { setTimeout as delay } from 'node:timers/promises'
2
+ import { pathToFileURL } from 'node:url'
3
+
4
+ import { TraceframeError } from './errors.mjs'
5
+
6
+ export const LIMITS = {
7
+ maxDuration: 120,
8
+ maxMoves: 100,
9
+ maxClicks: 100,
10
+ maxActionDuration: 30,
11
+ }
12
+ const MOVE_SECONDS = 0.5
13
+ const DWELL_SECONDS = 0.25
14
+ // People hold a mouse button for about 100 ms, long enough for press styles such as :active to show.
15
+ const PRESS_HOLD_MS = 100
16
+ const MOVE_SAMPLE_MS = 1000 / 30
17
+
18
+ export async function loadFlow(path, viewport) {
19
+ let module
20
+ try {
21
+ module = await import(pathToFileURL(path).href)
22
+ } catch (error) {
23
+ throw new TraceframeError(`Could not load flow: ${path}: ${error instanceof Error ? error.message : error}`, { code: 'INVALID_FLOW', cause: error })
24
+ }
25
+ if (typeof module.default !== 'function') throw new TraceframeError('Flow must export a default function.', { code: 'INVALID_FLOW' })
26
+ const { x, y } = module.initialPointer ?? { x: Math.round(viewport.width / 4), y: Math.max(0, viewport.height - 80) }
27
+ if (!insideViewport({ x, y }, viewport)) {
28
+ throw new TraceframeError('Flow initialPointer must be a point inside the viewport.', { code: 'INVALID_FLOW' })
29
+ }
30
+ return { run: module.default, ready: module.ready, initialPointer: { x, y } }
31
+ }
32
+
33
+ // Events are timed in seconds from `clock`, the epoch milliseconds of the first video frame.
34
+ export function createFlowApi({ page, trace, clock, viewport, signal }) {
35
+ let pointer = trace.initialPointer
36
+ const sleep = (milliseconds) => delay(milliseconds, undefined, { signal })
37
+
38
+ const movePointer = async (point, duration) => {
39
+ assertBelowLimit(trace.moves, LIMITS.maxMoves, 'move')
40
+ const from = pointer
41
+ const to = { x: point.x, y: point.y }
42
+ trace.moves.push({ at: secondsSince(clock), duration, from, to })
43
+
44
+ // Each sample follows the clock, so a late sample skips ahead instead of slowing the move down.
45
+ const total = duration * 1000
46
+ const startedAt = performance.now()
47
+ let elapsed = 0
48
+ while (elapsed < total) {
49
+ await sleep(Math.min(MOVE_SAMPLE_MS, total - elapsed))
50
+ elapsed = Math.min(total, performance.now() - startedAt)
51
+ const eased = easeOutCubic(elapsed / total)
52
+ await page.mouse.move(from.x + (to.x - from.x) * eased, from.y + (to.y - from.y) * eased)
53
+ }
54
+ pointer = to
55
+ }
56
+
57
+ const moveTo = async (locator, duration, name) => {
58
+ const seconds = durationSeconds(duration ?? MOVE_SECONDS, `${name} duration`)
59
+ await locator.waitFor()
60
+ const box = await locator.boundingBox()
61
+ if (!box) throw new TraceframeError(`Could not find ${locator}.`, { code: 'FLOW_TARGET_MISSING' })
62
+ // A person does not chase a target that shifts under a pointer already inside it.
63
+ if (pointInsideBox(pointer, box)) return pointer
64
+ const point = await aimPoint(locator, box)
65
+ if (!insideViewport(point, viewport)) {
66
+ throw new TraceframeError(`${locator} is outside the viewport. Scroll explicitly before moving to it.`, { code: 'FLOW_TARGET_OUTSIDE_VIEWPORT' })
67
+ }
68
+ await movePointer(point, seconds)
69
+ const after = await locator.boundingBox()
70
+ if (!after || !pointInsideBox(point, after)) {
71
+ throw new TraceframeError(`${locator} moved while the pointer was arriving.`, { code: 'FLOW_TARGET_MOVED' })
72
+ }
73
+ return point
74
+ }
75
+
76
+ return {
77
+ async hover(locator, options = {}) {
78
+ await moveTo(locator, options.duration, 'hover')
79
+ },
80
+ async click(locator, options = {}) {
81
+ assertBelowLimit(trace.clicks, LIMITS.maxClicks, 'click')
82
+ const dwell = durationSeconds(options.dwell ?? DWELL_SECONDS, 'click dwell', { allowZero: true })
83
+ const point = await moveTo(locator, options.duration, 'click')
84
+ await sleep(dwell * 1000)
85
+ // Hold on to this element: a control may rename or hide itself once it is pressed.
86
+ const element = await locator.elementHandle()
87
+ await element.waitForElementState('enabled').catch((error) => {
88
+ throw error.name === 'TimeoutError' ? new TraceframeError(`${locator} stayed disabled.`, { code: 'FLOW_TARGET_DISABLED', cause: error }) : error
89
+ })
90
+ await assertReceivesPointer(element, locator, point)
91
+ const at = secondsSince(clock)
92
+ await page.mouse.down()
93
+ try {
94
+ await sleep(PRESS_HOLD_MS)
95
+ // A press style such as :active { scale: 0.95 } can shrink the target out from under a pointer near its edge.
96
+ // A target that disappears when pressed, such as an option that closes its list, has already taken the press.
97
+ const pressed = await element.boundingBox()
98
+ if (pressed && !pointInsideBox(point, pressed)) {
99
+ throw new TraceframeError(`${locator} moved away from the pointer when pressed. Click nearer its center.`, { code: 'FLOW_TARGET_MOVED' })
100
+ }
101
+ } finally {
102
+ await page.mouse.up()
103
+ }
104
+ trace.clicks.push({ at, point })
105
+ },
106
+ async move(point, options = {}) {
107
+ if (!insideViewport(point, viewport)) {
108
+ throw new TraceframeError('move requires an { x, y } point inside the viewport.', { code: 'INVALID_FLOW' })
109
+ }
110
+ await movePointer(point, durationSeconds(options.duration ?? MOVE_SECONDS, 'move duration'))
111
+ },
112
+ async wait(seconds) {
113
+ await sleep(durationSeconds(seconds, 'wait', { allowZero: true }) * 1000)
114
+ },
115
+ }
116
+ }
117
+
118
+ function insideViewport(point, viewport) {
119
+ return point?.x >= 0 && point?.y >= 0 && point.x <= viewport.width && point.y <= viewport.height
120
+ }
121
+
122
+ function secondsSince(clock) {
123
+ return (Date.now() - clock) / 1000
124
+ }
125
+
126
+ // render.mjs draws the cursor along the same curve.
127
+ function easeOutCubic(progress) {
128
+ return 1 - (1 - progress) ** 3
129
+ }
130
+
131
+ function durationSeconds(value, name, { allowZero = false } = {}) {
132
+ if (!Number.isFinite(value) || (allowZero ? value < 0 : value <= 0) || value > LIMITS.maxActionDuration) {
133
+ const range = allowZero ? 'between 0 and' : 'more than 0 and at most'
134
+ throw new TraceframeError(`The ${name} must be ${range} ${LIMITS.maxActionDuration} seconds.`, { code: 'INVALID_FLOW' })
135
+ }
136
+ return value
137
+ }
138
+
139
+ function assertBelowLimit(events, limit, type) {
140
+ if (events.length >= limit) {
141
+ throw new TraceframeError(`Flow has more than ${limit} ${type} events.`, { code: 'FLOW_LIMIT' })
142
+ }
143
+ }
144
+
145
+ function pointInsideBox(point, box) {
146
+ return point.x >= box.x && point.x <= box.x + box.width && point.y >= box.y && point.y <= box.y + box.height
147
+ }
148
+
149
+ // Aim at the middle of the first line box, as Playwright does, so a link that wraps onto two lines is not missed.
150
+ async function aimPoint(locator, box) {
151
+ const offset = await locator.evaluate((element) => {
152
+ const bounds = element.getBoundingClientRect()
153
+ const first = element.getClientRects()[0] ?? bounds
154
+ // As fractions of the element's size, because a scaled iframe makes the page box a different size than the element's own.
155
+ return { x: (first.left + first.width / 2 - bounds.left) / bounds.width, y: (first.top + first.height / 2 - bounds.top) / bounds.height }
156
+ })
157
+ return { x: box.x + offset.x * box.width, y: box.y + offset.y * box.height }
158
+ }
159
+
160
+ async function assertReceivesPointer(element, locator, point) {
161
+ const box = await element.boundingBox()
162
+ if (!box || !pointInsideBox(point, box)) {
163
+ throw new TraceframeError(`${locator} moved before the click.`, { code: 'FLOW_TARGET_MOVED' })
164
+ }
165
+ const receivesPointer = await element.evaluate((target, { x, y }) => {
166
+ const rect = target.getBoundingClientRect()
167
+ let current = target.getRootNode().elementFromPoint(rect.left + x * rect.width, rect.top + y * rect.height)
168
+ while (current) {
169
+ if (current === target || target.contains(current)) return true
170
+ current = current.assignedSlot ?? current.parentElement ?? current.getRootNode().host ?? null
171
+ }
172
+ return false
173
+ }, relativePoint(point, box))
174
+ if (!receivesPointer || !(await framesReceivePointer(element, point))) {
175
+ throw new TraceframeError(`${locator} is covered at the click point.`, { code: 'FLOW_TARGET_OBSCURED' })
176
+ }
177
+ }
178
+
179
+ async function framesReceivePointer(element, point) {
180
+ let frame = await element.ownerFrame()
181
+ while (frame?.parentFrame()) {
182
+ const frameElement = await frame.frameElement()
183
+ const box = await frameElement.boundingBox()
184
+ if (!box) return false
185
+ const hit = await frameElement.evaluate((target, { x, y }) => {
186
+ const rect = target.getBoundingClientRect()
187
+ return target.getRootNode().elementFromPoint(rect.left + x * rect.width, rect.top + y * rect.height) === target
188
+ }, relativePoint(point, box))
189
+ if (!hit) return false
190
+ frame = frame.parentFrame()
191
+ }
192
+ return true
193
+ }
194
+
195
+ function relativePoint(point, box) {
196
+ return { x: (point.x - box.x) / box.width, y: (point.y - box.y) / box.height }
197
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ export { record } from './record.mjs'
2
+ export { TraceframeError } from './errors.mjs'
package/src/record.mjs ADDED
@@ -0,0 +1,39 @@
1
+ import { mkdir, mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ import { createConfig } from './config.mjs'
6
+ import { captureFlow } from './capture.mjs'
7
+ import { probeFfmpeg } from './ffmpeg.mjs'
8
+ import { loadFlow } from './flow.mjs'
9
+ import { renderVideo } from './render.mjs'
10
+
11
+ export async function record(options = {}) {
12
+ const { signal } = options
13
+ signal?.throwIfAborted()
14
+ const config = createConfig(options)
15
+ const ffmpeg = await probeFfmpeg()
16
+ const flow = await loadFlow(config.flow, config.viewport)
17
+ await mkdir(dirname(config.output), { recursive: true })
18
+ const directory = config.runDirectory ?? await mkdtemp(join(tmpdir(), 'traceframe-'))
19
+ await mkdir(directory, { recursive: true })
20
+
21
+ try {
22
+ const { rawPath, trace } = await captureFlow({ config, flow, directory, signal })
23
+ const duration = await renderVideo({ rawPath, trace, directory, config, ffmpeg, signal })
24
+ return {
25
+ output: config.output,
26
+ width: config.video.width,
27
+ height: config.video.height,
28
+ fps: config.video.fps,
29
+ duration,
30
+ moveCount: trace.moves.length,
31
+ clickCount: trace.clicks.length,
32
+ runDirectory: config.runDirectory,
33
+ }
34
+ } catch (error) {
35
+ throw signal?.aborted ? signal.reason : error
36
+ } finally {
37
+ if (!config.runDirectory) await rm(directory, { recursive: true, force: true })
38
+ }
39
+ }
package/src/render.mjs ADDED
@@ -0,0 +1,93 @@
1
+ import { rename, rm, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+
5
+ import { probeDuration, runCommand } from './ffmpeg.mjs'
6
+ import { TraceframeError } from './errors.mjs'
7
+
8
+ const ASSETS = join(import.meta.dirname, 'assets')
9
+ // The arrow tip in cursor.svg.
10
+ const CURSOR_HOTSPOT = { x: 6.6, y: 1.6 }
11
+ const CLICK = { duration: 0.24, scaleStart: 0.7, scaleEnd: 1.25 }
12
+ // eof_action=pass lets the main video drive each overlay instead of waiting on the looping image inputs; without it, rendering is many times slower.
13
+ // format=yuv444 places overlays on whole pixels. The default yuv420 snaps them to even pixels, and RGB costs more for the same result.
14
+ const OVERLAY = 'eof_action=pass:format=yuv444'
15
+
16
+ export async function renderVideo({ rawPath, trace, directory, config, ffmpeg, signal }) {
17
+ const { fps } = config.video
18
+ const outputDuration = Math.max(trace.end + config.postRoll, ...trace.clicks.map((click) => click.at + CLICK.duration))
19
+ const filterPath = join(directory, 'render.filter')
20
+ await writeFile(filterPath, buildFilterScript({ trace, scale: config.scale }))
21
+ const outputTemp = join(dirname(config.output), `.traceframe-${randomUUID()}.tmp.mp4`)
22
+ const args = [
23
+ '-y', '-hide_banner', '-loglevel', 'error',
24
+ '-i', rawPath,
25
+ '-loop', '1', '-framerate', String(fps), '-i', join(ASSETS, `cursor-${config.scale}x.png`),
26
+ ]
27
+ if (trace.clicks.length) args.push('-loop', '1', '-framerate', String(fps), '-t', String(CLICK.duration + 1 / fps), '-i', join(ASSETS, `click-pulse-${config.scale}x.png`))
28
+ args.push(
29
+ ffmpeg.filterComplexFileOption, filterPath,
30
+ '-map', '[final]',
31
+ '-t', String(number(outputDuration)),
32
+ '-r', String(fps),
33
+ '-c:v', 'libx264', '-preset', 'fast', '-crf', '18',
34
+ '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709', '-color_range', 'tv',
35
+ '-g', String(fps * 2),
36
+ '-movflags', '+faststart', outputTemp,
37
+ )
38
+ try {
39
+ await runCommand('ffmpeg', args, { signal })
40
+ const duration = await probeDuration(outputTemp, signal)
41
+ if (!(Math.abs(duration - outputDuration) <= 1.5 / fps)) {
42
+ throw new TraceframeError(`The rendered video is ${duration.toFixed(2)} seconds long, but the recording is ${outputDuration.toFixed(2)} seconds.`, { code: 'OUTPUT_MISMATCH' })
43
+ }
44
+ signal?.throwIfAborted()
45
+ await rename(outputTemp, config.output)
46
+ return duration
47
+ } catch (error) {
48
+ await rm(outputTemp, { force: true })
49
+ throw error
50
+ }
51
+ }
52
+
53
+ // Event times are seconds from the first frame of the raw video.
54
+ export function buildFilterScript({ trace, scale }) {
55
+ const { clicks, moves } = trace
56
+ // Playwright ends the raw video by the time it received the last frame, so a page that stays still can end it a little before the post-roll. The page did not change, so repeat its last frame.
57
+ const filters = [`[0:v]setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration=1[video0]`]
58
+ if (clicks.length) filters.push(`[2:v]format=rgba,split=${clicks.length}${clicks.map((_, index) => `[pulse${index}]`).join('')}`)
59
+ clicks.forEach((click, index) => filters.push(...clickFilter(click, index, scale)))
60
+
61
+ const cursorLabels = moves.map((_, index) => `[cursor${index}]`).join('')
62
+ filters.push(`[1:v]format=rgba,split=${moves.length + 1}${cursorLabels}[cursor${moves.length}]`)
63
+ const coordinate = (value, hotspot) => number(value * scale - hotspot * scale)
64
+ const initialEnable = moves.length ? `:enable='lt(t,${number(moves[0].at)})'` : ''
65
+ filters.push(`[video${clicks.length}][cursor0]overlay=x='${coordinate(trace.initialPointer.x, CURSOR_HOTSPOT.x)}':y='${coordinate(trace.initialPointer.y, CURSOR_HOTSPOT.y)}':${OVERLAY}${initialEnable}[cursorVideo0]`)
66
+ moves.forEach((move, index) => {
67
+ const start = number(move.at)
68
+ const end = index + 1 < moves.length ? number(moves[index + 1].at) : undefined
69
+ // Each window excludes its end, so the frame where one move hands over to the next does not draw the cursor twice.
70
+ const enable = end === undefined ? `gte(t,${start})` : `gte(t,${start})*lt(t,${end})`
71
+ // The same curve as easeOutCubic in flow.mjs, so the drawn cursor follows the real pointer.
72
+ const eased = `(1-pow(1-clip((t-${start})/${number(move.duration)},0,1),3))`
73
+ const x = `${coordinate(move.from.x, CURSOR_HOTSPOT.x)}+${number((move.to.x - move.from.x) * scale)}*${eased}`
74
+ const y = `${coordinate(move.from.y, CURSOR_HOTSPOT.y)}+${number((move.to.y - move.from.y) * scale)}*${eased}`
75
+ filters.push(`[cursorVideo${index}][cursor${index + 1}]overlay=x='${x}':y='${y}':${OVERLAY}:enable='${enable}'[cursorVideo${index + 1}]`)
76
+ })
77
+ filters.push(`[cursorVideo${moves.length}]colorspace=ispace=bt470bg:iprimaries=bt709:itrc=bt709:all=bt709:format=yuv420p[final]`)
78
+ return filters.join(';')
79
+ }
80
+
81
+ function clickFilter(click, index, scale) {
82
+ const start = number(click.at)
83
+ const end = number(start + CLICK.duration)
84
+ const growth = `(${CLICK.scaleStart}+${CLICK.scaleEnd - CLICK.scaleStart}*clip((t-${start})/${CLICK.duration},0,1))`
85
+ return [
86
+ `[pulse${index}]setpts=PTS-STARTPTS+${start}/TB,scale=w='iw*${growth}':h='ih*${growth}':eval=frame,fade=t=out:st=${start}:d=${CLICK.duration}:alpha=1[click${index}]`,
87
+ `[video${index}][click${index}]overlay=x='${number(click.point.x * scale)}-w/2':y='${number(click.point.y * scale)}-h/2':${OVERLAY}:enable='between(t,${start},${end})'[video${index + 1}]`,
88
+ ]
89
+ }
90
+
91
+ function number(value) {
92
+ return Number(value.toFixed(6))
93
+ }