@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 +21 -0
- package/README.md +123 -2
- package/package.json +43 -4
- package/src/assets/click-pulse-1x.png +0 -0
- package/src/assets/click-pulse-2x.png +0 -0
- package/src/assets/click-pulse.svg +4 -0
- package/src/assets/cursor-1x.png +0 -0
- package/src/assets/cursor-2x.png +0 -0
- package/src/assets/cursor.svg +3 -0
- package/src/capture.mjs +111 -0
- package/src/cli.mjs +109 -0
- package/src/config.mjs +57 -0
- package/src/errors.mjs +7 -0
- package/src/ffmpeg.mjs +54 -0
- package/src/flow.mjs +197 -0
- package/src/index.mjs +2 -0
- package/src/record.mjs +39 -0
- package/src/render.mjs +93 -0
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
|
-
#
|
|
1
|
+
# Traceframe
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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>
|
package/src/capture.mjs
ADDED
|
@@ -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
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
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
|
+
}
|