@milkstraw/traceframe 0.3.0 → 0.5.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/README.md CHANGED
@@ -2,6 +2,8 @@
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
4
 
5
+ Browser capture is silent by default. Add an [external audio file](#add-audio) with explicit timing; tab audio and microphones are not captured automatically.
6
+
5
7
  https://github.com/user-attachments/assets/38e02cd5-a9ba-4f3c-938c-159955353141
6
8
 
7
9
  ## Prerequisites
@@ -16,7 +18,7 @@ To use system Chrome 150 or later, pass `--browser` or set `CHROME_PATH`, and in
16
18
 
17
19
  In a sandbox, such as a coding agent's, allow Playwright's browser download and let Chromium start.
18
20
 
19
- To record pages behind a login, pass a Playwright storage-state file with `--storage-state`. Keep it in `.auth/` or another ignored location.
21
+ To record pages behind a login, pass a Playwright storage-state file with `--storage-state`. See [creating saved login state and switching accounts](docs/recording-flows.md#save-and-switch-login-state), and keep state in `.auth/` or another ignored location.
20
22
 
21
23
  ## Record a video
22
24
 
@@ -51,7 +53,7 @@ npx @milkstraw/traceframe record \
51
53
  --preset draft
52
54
  ```
53
55
 
54
- The [recording guide](docs/recording-flows.md) includes a runnable example and recipes for form input, asynchronous results, browser settings, and editing or sharing a recording.
56
+ The [recording guide](docs/recording-flows.md) includes a runnable example and recipes for authentication, form input, asynchronous results, captions, browser settings, and inspecting, editing or sharing a recording.
55
57
 
56
58
  Both presets use a 1280×800 CSS viewport. `draft` produces 1280×800 at 30 FPS; `standard` (the default) produces 2560×1600 at 60 FPS. The output dimensions are the viewport times `--scale`, so `--preset draft --scale 2` gives 2560×1600 at 30 FPS. The command prints a JSON summary when it finishes.
57
59
 
@@ -145,20 +147,90 @@ await record({
145
147
  })
146
148
  ```
147
149
 
150
+ ### Record a page you already opened
151
+
152
+ Use `recordPage` when your script already owns a Playwright page, including a page in a persistent extension context. Prepare navigation, authentication, application readiness and fonts before calling it. With a prepared `page`:
153
+
154
+ ```js
155
+ import { recordPage } from '@milkstraw/traceframe'
156
+
157
+ const save = page.getByRole('button', { name: 'Save', exact: true })
158
+ await recordPage({
159
+ page,
160
+ output: './recording.mp4',
161
+ flow: async ({ click, wait, signal }) => {
162
+ await click(save)
163
+ await page.getByText('Saved', { exact: true }).waitFor({ signal })
164
+ await wait(3)
165
+ },
166
+ })
167
+ ```
168
+
169
+ The callback receives the same `page`, `signal` and five helpers as a flow file. It can close over your script's locators and state. See the [runnable existing-page example](examples/existing-page.mjs); in a clone, run `node examples/existing-page.mjs` to save `.traceframe/existing-page.mp4` using synthetic local content.
170
+
171
+ Use Playwright 1.64.1 with Chromium, a fixed viewport, 100% browser zoom and an unscaled page. Traceframe checks the page's inner dimensions and visual viewport before starting capture, rejecting unsupported geometry with `CAPTURE_SIZE_MISMATCH`. `recordPage` keeps the page, context and browser open on success, failure and cancellation. It does not navigate, resize, run a `ready` hook, or change page timeouts. Your script owns setup and shutdown. The CLI and `record` continue to create and close their own browser.
172
+
173
+ `page` and a function `flow` are required. `output`, `audio`, `postRoll`, `flowTimeout`, `runDirectory` and `signal` have the same meaning as in `record`, and the return value is the same recording summary. Set `initialPointer: { x, y }` to choose the starting cursor position. The page's viewport determines the dimensions; `scale` defaults to 1 and accepts 1 or 2, and `fps` defaults to 30 and accepts 30 or 60. Use these explicit settings instead of a preset. Browser setup options such as `url`, `browser`, `storageState`, viewport dimensions and `readyTimeout` are rejected.
174
+
175
+ Traceframe moves the caller's pointer only after validating the first frame. To show the starting hover state from frame 0, move the page's mouse to your chosen `initialPointer` before calling `recordPage`.
176
+
177
+ For 2× frames, launch the caller's browser with `chromium.launch({ args: ['--force-device-scale-factor=2'] })`, create its context with `deviceScaleFactor: 2`, and pass `scale: 2` to `recordPage`. The same launch argument works with `launchPersistentContext`. Setting `deviceScaleFactor` alone does not ensure 2× screencast frames. Traceframe checks the first JPEG's actual dimensions before running the callback and rejects mismatches with `CAPTURE_SIZE_MISMATCH`.
178
+
179
+ Keep the viewport, zoom and visual scale fixed and the page open until `recordPage` settles; geometry is checked only at startup. Only that page is recorded; a tab closed during a take may lose its capture. Use the page exclusively for recording: concurrent screencasts, context `recordVideo` and tracing with screenshots can change frame dimensions or supply stale frames. A size check cannot detect every conflict. Overlapping `recordPage` calls on the same page reject with `PAGE_RECORDING_ACTIVE`.
180
+
181
+ Await every helper. Helpers observe cancellation before further pointer or scroll actions, and release a mouse button that was already pressed; an already-issued action may still complete. Arbitrary callback code must cooperate with `signal`, including passing it to supported Playwright operations. A flow timeout does not forcibly terminate that code. Sequential takes are supported after a successful screencast stop, including ordinary flow failures and cooperative cancellation. If Playwright's FFmpeg is missing, Traceframe stops the partial capture before rejecting. Other startup or stop failures may need a new page or context; Traceframe leaves that recovery to the caller and does not stop a pre-existing caller screencast.
182
+
183
+ Traceframe removes its own temporary outputs unless `runDirectory` is supplied. Playwright keeps original WebMs in its browser artifacts directory until the caller closes the browser, even after a take has been saved. For extension pages, use full Chromium; the headless shell alone does not load extensions. This path was checked on Linux with Playwright 1.64.1 and its bundled full Chromium using a synthetic extension.
184
+
185
+ ### Add audio
186
+
187
+ Both `record` and `recordPage` accept `audio: { path: './sound.wav', offset: 0 }`. The CLI accepts the same settings:
188
+
189
+ ```sh
190
+ npx @milkstraw/traceframe record --url http://localhost:3000 --flow ./flow.mjs \
191
+ --audio .traceframe/sound.wav --audio-offset 0.5 --out .traceframe/demo.mp4
192
+ ```
193
+
194
+ `offset` is seconds from the first video frame to the first audio sample. Positive values add leading silence; negative values discard the beginning of the audio (in the CLI, use e.g. `--audio-offset=-0.25`). The default is 0. Short audio is padded with silence, and longer audio is trimmed to the video's end. Nothing is looped or stretched. The first audio stream is encoded as stereo AAC at 48 kHz, 192 kb/s; system FFmpeg must provide its standard `aac` encoder. Successful summaries include `audio: true`.
195
+
196
+ The input must be a local file, such as WAV, MP3, M4A or audio WebM. Static inputs are checked before browser recording, so a missing file or missing audio stream cannot waste a take (`AUDIO_INVALID`). After capture, an audio start at or after the video's end rejects with `AUDIO_OUTSIDE_VIDEO`. A negative offset that discards the entire source produces a silent audio track.
197
+
198
+ Traceframe reads the file without modifying or deleting it; use an input path different from the output and the reserved files in `runDirectory` (`capture.webm`, `render.filter`, `failure.png`). Keep recordings in an ignored directory.
199
+
200
+ For audio recorded alongside the flow, supply an async function instead. It runs once after successful capture and post-roll, before rendering, so it can finalize the external recorder and return its file. `startedAt` is the first video frame's timestamp in epoch milliseconds:
201
+
202
+ ```js
203
+ await recordPage({
204
+ page,
205
+ output: '.traceframe/demo.mp4',
206
+ flow,
207
+ audio: async ({ startedAt, signal }) => {
208
+ const { path, startedAt: audioStartedAt } = await finishAudioRecording({ signal })
209
+ return { path, offset: (audioStartedAt - startedAt) / 1000 }
210
+ },
211
+ })
212
+ ```
213
+
214
+ Here `finishAudioRecording` belongs to your script and reports the timestamp of its first recorded sample. Use timestamps on a shared clock, or determine the offset using an audiovisual marker. An offset corrects a constant alignment difference; it does not correct clock drift. Check markers near both ends of longer takes. See the [audio workflow](docs/recording-flows.md#align-external-audio).
215
+
216
+ Your script owns the external recorder and its cleanup, including on flow failure or cancellation. The audio function is skipped if capture fails and must cooperate with `signal` when supplied. It may finish writing after capture; a static input must already exist before recording. Neither form captures browser or system sound by itself.
217
+
218
+ ### Errors and cancellation
219
+
148
220
  Traceframe's own errors are `TraceframeError` instances (exported by the package) with a `code`, such as `READY_FAILED` or `FLOW_TARGET_OBSCURED`. Other exceptions thrown by the flow become `FLOW_FAILED`, with the original thrown value in `cause`; existing Traceframe error codes and causes are preserved.
149
221
 
150
222
  When available, `error.diagnostics` contains:
151
223
 
152
224
  | Field | Meaning |
153
225
  | --- | --- |
154
- | `flowLocations` | Locations in the supplied flow file, as `{ file, line, column }` objects with an absolute path and 1-based line/column. Empty when the error stack has no matching frame. |
226
+ | `flowLocations` | Locations in the supplied flow file, as `{ file, line, column }` objects with an absolute path and 1-based line/column. Empty for a callback or when the error stack has no matching frame. |
155
227
  | `recordingTime` | Elapsed seconds from the first captured frame for a capture-stage failure, measured before collecting failure artifacts. Omitted before the first frame and for later rendering failures. |
156
228
  | `capturePath` | Absolute path to the retained raw capture, only when `runDirectory` was supplied and saving succeeded. |
157
229
  | `screenshotPath` | Absolute path to the saved failure screenshot, only when `runDirectory` was supplied and the screenshot succeeded. |
158
230
 
159
231
  A flow timeout may have no flow location because it is raised outside the blocked operation. Screenshots are best effort and have a five-second collection budget. Partial captures are saved before browser shutdown, with no fixed time limit so slower encoders can finish. Diagnostic failures do not replace the original error.
160
232
 
161
- 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.
233
+ Pass `signal` to stop a recording early. Traceframe stops its recording/rendering work and removes its temporary files before rejecting with the signal's reason. `record` also closes its browser; `recordPage` leaves the caller's browser open. 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.
162
234
 
163
235
  A stuck recording encoder can delay saving and cancellation indefinitely. A second Ctrl-C forces the CLI to exit and can discard the unfinished capture. API callers that need a hard shutdown deadline should run recording in a separate process they can terminate.
164
236
 
@@ -20,7 +20,48 @@ In a repository clone, run `npx traceframe record --url "file://$PWD/examples/re
20
20
 
21
21
  Inspect the running application at the recording viewport. Pick a short journey with a visible result and known demo data. Use role, label, or test-id locators; `locator.describe()` can make errors clearer.
22
22
 
23
- Put setup and page readiness in `ready({ page, signal })`. This runs after initial navigation and before recording. Use `--storage-state` for an existing login, and keep that file in an ignored directory. For headers, locale, or themes, see [optional startup settings](#optional-startup-settings).
23
+ Put setup and page readiness in `ready({ page, signal })`. This runs after initial navigation and before recording. Use `--storage-state` for an existing login, as below. For headers, locale, or themes, see [optional startup settings](#optional-startup-settings).
24
+
25
+ If your script already owns the page or an extension context, use [`recordPage`](../README.md#record-a-page-you-already-opened) with an inline callback. Prepare the page before calling it; the caller owns navigation and browser cleanup.
26
+
27
+ ## Save and switch login state
28
+
29
+ Add `.auth/` to your project's `.gitignore`; saved state can grant access to the account. To create it interactively, install full Chromium and open Playwright's login window:
30
+
31
+ ```sh
32
+ mkdir -p .auth
33
+ npx playwright@1.64.1 install chromium
34
+ npx playwright@1.64.1 codegen --save-storage=.auth/first-account.json http://localhost:3000
35
+ ```
36
+
37
+ Sign in normally, wait for the authenticated screen, then close the browser window to save the state. This requires a desktop/display; the headless-shell-only installation used for recording is insufficient. Record with the same application origin, including its port:
38
+
39
+ ```sh
40
+ npx @milkstraw/traceframe record \
41
+ --url http://localhost:3000 --flow ./flow.mjs \
42
+ --storage-state .auth/first-account.json --out ./recording.mp4
43
+ ```
44
+
45
+ If a Playwright script already signs in, save state after checking that login finished and before closing its browser:
46
+
47
+ ```js
48
+ await page.getByRole('heading', { name: 'Example workspace' }).waitFor()
49
+ await page.context().storageState({ path: '.auth/first-account.json', indexedDB: true })
50
+ ```
51
+
52
+ Storage state includes cookies and local storage. The scripted `indexedDB: true` also saves IndexedDB, which some apps use for authentication; the codegen command above does not include it.
53
+
54
+ For a second account, save a separate file from a fresh login. To switch during a flow, first navigate the recorded page to `about:blank` to release its open IndexedDB connections. Apply the second account with Playwright 1.64.1's `setStorageState`, then return to the app on that same page and verify the account:
55
+
56
+ ```js
57
+ await page.goto('about:blank')
58
+ await page.context().setStorageState('.auth/second-account.json')
59
+ await page.goto('http://localhost:3000')
60
+ await page.getByRole('heading', { name: 'Second account' }).waitFor()
61
+ await page.evaluate(() => document.fonts.ready)
62
+ ```
63
+
64
+ Replace the URL and headings with your application's account-specific checks. `setStorageState` replaces cookies and origin storage, including local storage and IndexedDB, rather than just adding cookies. Neither saving nor replacing storage state handles `sessionStorage`; if the app uses it for authentication, restore or clear it separately for the relevant origins and follow the app's login requirements. Opening a replacement page would leave Traceframe recording the original page.
24
65
 
25
66
  ## Choose the right actions
26
67
 
@@ -121,12 +162,36 @@ Use `--preset draft` while writing the flow and the default preset for the final
121
162
 
122
163
  While writing the flow, pass `--run-dir` with an ignored folder, such as `.traceframe/runs/demo`. Use a separate directory for each take. A failed take keeps its partial `capture.webm` when saving succeeds and attempts a viewport screenshot, `failure.png`. The raw capture has no added cursor overlay and may include repeated final frames after the failure time.
123
164
 
124
- A failed take may already have submitted real jobs, so reset demo data before recording again. Check the important transitions and the final result. You can also check the file for decode errors:
165
+ A failed take may already have submitted real jobs, so reset demo data before recording again. Check the finished file for decode errors:
125
166
 
126
167
  ```sh
127
168
  ffmpeg -v error -xerror -i ./recording.mp4 -f null -
128
169
  ```
129
170
 
171
+ For a visual overview of the whole recording, sample roughly every two seconds into numbered contact sheets. Add `.traceframe/` to your project's `.gitignore` and use a new inspection directory for each take:
172
+
173
+ ```sh
174
+ mkdir -p .traceframe/review/take-1
175
+ ffmpeg -i ./recording.mp4 \
176
+ -vf "select='isnan(prev_selected_t)+gte(t-prev_selected_t,2)',scale=640:-1,drawtext=text='%{pts\:hms}':x=8:y=8:fontsize=20:fontcolor=white:box=1:boxcolor=black@0.6,tile=4x5:padding=4" \
177
+ -fps_mode vfr .traceframe/review/take-1/sheet-%03d.png
178
+ ```
179
+
180
+ Each sheet holds 20 timestamped samples, covering about 40 seconds. The command continues through the entire input; the last sheet may have empty cells. Do not add `-frames:v 1`, which would stop after the first sheet.
181
+
182
+ On FFmpeg 5.0, replace `-fps_mode vfr` with `-vsync vfr`.
183
+
184
+ Timestamp labels require FFmpeg's `drawtext` filter and an available font. Homebrew's current standard `ffmpeg` omits that filter; run `brew install ffmpeg-full` and use `"$(brew --prefix ffmpeg-full)/bin/ffmpeg"` in place of `ffmpeg` in the command above. If `drawtext` is available but no default font is found, add `fontfile=/path/to/font.ttf:` before `text=`.
185
+
186
+ Inspect every sheet, then extract important results, account changes and transitions at the video's full resolution. For example, inspect the frame at 12.4 seconds:
187
+
188
+ ```sh
189
+ ffmpeg -ss 00:00:12.400 -i ./recording.mp4 \
190
+ -frames:v 1 -update 1 .traceframe/review/take-1/detail-12.400.png
191
+ ```
192
+
193
+ Small text, focus rings and brief glitches can disappear in the reduced samples. Extract nearby frames around a suspicious transition and check caption reading pauses, pointer motion and result holds against the flow's timing. Contact sheets and successful decoding alone do not establish readable pacing or smooth motion. An agent preparing a demonstration should complete these checks before presenting it for the human's feature review.
194
+
130
195
  ## Diagnose a failed take
131
196
 
132
197
  The CLI keeps the original error message and adds available context on standard error. For example:
@@ -141,7 +206,15 @@ traceframe: [FLOW_FAILED] Expected the Saved status.
141
206
 
142
207
  Inspect the reported screenshot, then the capture near the reported time if needed. The time is measured from the first captured frame before diagnostic collection; it is not the raw file's duration. Correct the locator or application check and reset demo data before retrying.
143
208
 
144
- Only paths saved for the current attempt are reported. A closed or unresponsive page may prevent a screenshot or capture from being saved. Errors before the first frame have no recording time, and a flow timeout may have no source location. Without `--run-dir`, flow locations and timing are still reported when available, but temporary captures are removed and no failure screenshot is saved. The API exposes the same context through [`error.diagnostics`](../README.md#programmatic-usage).
209
+ Only paths saved for the current attempt are reported. A closed or unresponsive page may prevent a screenshot or capture from being saved. Errors before the first frame have no recording time, and a flow timeout may have no source location. Without `--run-dir`, flow locations and timing are still reported when available, but temporary captures are removed and no failure screenshot is saved. The API exposes the same context through [`error.diagnostics`](../README.md#errors-and-cancellation).
210
+
211
+ ## Align external audio
212
+
213
+ Capture the application's actual processed output with your own audio recorder, then pass its local file through [`audio`](../README.md#add-audio). Use the same prepared page with `recordPage` when the audio setup needs a caller-owned context. Audio recording remains your script's responsibility; Traceframe aligns and encodes the supplied file in the final MP4.
214
+
215
+ Use a distinct sound and visible change as a synchronization marker. If the visible change lands at 2.00 seconds in the video and its sound starts at 2.08 seconds in the audio file, pass `offset: -0.08`. Measure on the actual recorded streams, accounting for capture-device and encoder latency. For a live recorder, the audio supplier's `startedAt` gives the video clock; the recorder's timestamp must refer to its first sample on a shared clock.
216
+
217
+ Inspect the rendered result with audio enabled, and compare another marker near the end. If the difference grows, a constant offset is insufficient: correct the external recording's rate separately before adding it. Traceframe never changes audio speed or pitch. Trimmed audio ends with the video, including post-roll; shorter audio leaves silence. For independent takes, calculate alignment for each take and close the external recorder in your script's `finally` block.
145
218
 
146
219
  ## Edit the finished video
147
220
 
@@ -160,6 +233,8 @@ export default async function ({ page, click, wait }) {
160
233
 
161
234
  Log to `console.error`, because Traceframe prints its JSON summary on standard output. This command plays seconds 2 to 9 at 4× speed. Set `fps` to the video's frame rate: 60 for the default preset, 30 for draft.
162
235
 
236
+ This command drops the audio track. To keep audio synchronized, apply the same cuts and speed changes to its timeline, including when speeding up silent intervals.
237
+
163
238
  ```sh
164
239
  ffmpeg -i recording.mp4 -filter_complex "[0:v]trim=0:2,setpts=PTS-STARTPTS[a];[0:v]trim=2:9,setpts=(PTS-STARTPTS)/4[b];[0:v]trim=9,setpts=PTS-STARTPTS[c];[a][b][c]concat=n=3,fps=60[v]" -map "[v]" -c:v libx264 -crf 18 -movflags +faststart faster.mp4
165
240
  ```
@@ -0,0 +1,30 @@
1
+ import { chromium } from 'playwright'
2
+ import { recordPage } from '@milkstraw/traceframe'
3
+
4
+ const browser = await chromium.launch()
5
+ try {
6
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
7
+ await page.goto(new URL('./recipes.html', import.meta.url).href)
8
+ await page.getByRole('heading', { name: 'Conversation demo' }).waitFor()
9
+ await page.evaluate(() => document.fonts.ready)
10
+
11
+ // These locators belong to the caller's existing script.
12
+ const message = page.getByRole('textbox', { name: 'Message' })
13
+ const replies = page.getByRole('list', { name: 'Replies' }).getByRole('listitem')
14
+ const summary = await recordPage({
15
+ page,
16
+ output: process.argv[2] ?? '.traceframe/existing-page.mp4',
17
+ flow: async ({ click, wait, signal }) => {
18
+ await click(message)
19
+ await message.pressSequentially('Record this existing page.', { delay: 45, signal })
20
+ await click(page.getByRole('button', { name: 'Send', exact: true }))
21
+ await replies.first().waitFor({ signal })
22
+ await wait(3)
23
+ },
24
+ })
25
+ // The page is still usable after recording.
26
+ await page.getByRole('status').filter({ hasText: 'Reply complete.' }).waitFor()
27
+ console.log(JSON.stringify(summary))
28
+ } finally {
29
+ await browser.close()
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milkstraw/traceframe",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Record scripted browser flows and render smooth cursor overlays into video.",
5
5
  "keywords": [
6
6
  "playwright",
@@ -26,6 +26,7 @@
26
26
  "files": [
27
27
  "src",
28
28
  "docs/recording-flows.md",
29
+ "examples/existing-page.mjs",
29
30
  "examples/recipes.html",
30
31
  "examples/recipes-flow.mjs"
31
32
  ],
package/src/capture.mjs CHANGED
@@ -12,31 +12,43 @@ const PLAYWRIGHT_DEFAULT_TIMEOUT = 30_000
12
12
  const FIRST_FRAME_TIMEOUT = 10_000
13
13
  const FAILURE_SCREENSHOT_TIMEOUT = 5_000
14
14
 
15
- export async function captureFlow({ config, flow, directory, signal: callerSignal }) {
15
+ export async function captureFlow({ config, flow, directory, page, signal: callerSignal }) {
16
16
  const signal = callerSignal ?? new AbortController().signal
17
17
  const rawPath = join(directory, 'capture.webm')
18
18
  const trace = { initialPointer: flow.initialPointer, moves: [], clicks: [] }
19
19
 
20
20
  signal.throwIfAborted()
21
- const browser = await launchBrowser(config, { handleSignals: !callerSignal })
22
- let page
21
+ const browser = page ? undefined : await launchBrowser(config, { handleSignals: !callerSignal })
23
22
  let clock
24
23
  let recording = false
25
24
  let failed = false
26
25
  try {
27
- page = await browser.newPage({ viewport: config.viewport, deviceScaleFactor: config.scale, storageState: config.storageState })
28
- await raceAbort(waitForReady(page, flow, config, signal), signal)
29
- await page.mouse.move(trace.initialPointer.x, trace.initialPointer.y)
26
+ if (browser) {
27
+ page = await browser.newPage({ viewport: config.viewport, deviceScaleFactor: config.scale, storageState: config.storageState })
28
+ await raceAbort(waitForReady(page, flow, config, signal), signal)
29
+ signal.throwIfAborted()
30
+ await page.mouse.move(trace.initialPointer.x, trace.initialPointer.y)
31
+ } else {
32
+ await assertPageGeometry(page, config, signal)
33
+ }
30
34
 
31
35
  const firstFrame = Promise.withResolvers()
32
- await startScreencast(page, rawPath, config, ({ timestamp }) => firstFrame.resolve(timestamp))
36
+ signal.throwIfAborted()
37
+ await startScreencast(page, rawPath, config, firstFrame.resolve)
33
38
  recording = true
34
- clock = await withDeadline(
39
+ const frame = await withDeadline(
35
40
  () => firstFrame.promise,
36
41
  FIRST_FRAME_TIMEOUT,
37
42
  new TraceframeError(`No screencast frame arrived within ${FIRST_FRAME_TIMEOUT / 1000} seconds.`, { code: 'FIRST_FRAME_TIMEOUT' }),
38
43
  signal,
39
44
  )
45
+ clock = frame.timestamp
46
+ if (!browser) {
47
+ assertFrameSize(frame, config)
48
+ signal.throwIfAborted()
49
+ // Rejected caller-page attempts must leave the caller's pointer alone.
50
+ await page.mouse.move(trace.initialPointer.x, trace.initialPointer.y)
51
+ }
40
52
  try {
41
53
  await withDeadline(
42
54
  (flowSignal) => flow.run({ page, signal: flowSignal, ...createFlowApi({ page, trace, clock, viewport: config.viewport, signal: flowSignal }) }),
@@ -53,7 +65,7 @@ export async function captureFlow({ config, flow, directory, signal: callerSigna
53
65
  // A failed stop must not be retried: Playwright may still be waiting to finish its artifact.
54
66
  recording = false
55
67
  await page.screencast.stop()
56
- return { rawPath, trace }
68
+ return { rawPath, trace, startedAt: clock }
57
69
  } catch (error) {
58
70
  failed = true
59
71
  const recordingTime = clock === undefined ? undefined : Math.max(0, (Date.now() - clock) / 1000)
@@ -68,8 +80,46 @@ export async function captureFlow({ config, flow, directory, signal: callerSigna
68
80
  throw await addDiagnostics(error, { flow: config.flow, recordingTime, capturePath, screenshotPath })
69
81
  } finally {
70
82
  // A shutdown error must not hide the flow failure.
71
- if (failed) await browser.close().catch(() => {})
72
- else await browser.close()
83
+ if (browser) {
84
+ if (failed) await browser.close().catch(() => {})
85
+ else await browser.close()
86
+ }
87
+ }
88
+ }
89
+
90
+ async function assertPageGeometry(page, config, signal) {
91
+ const [width, height, scale, left, top] = await withDeadline(
92
+ () => page.evaluate(() => [innerWidth, innerHeight, visualViewport.scale, visualViewport.offsetLeft, visualViewport.offsetTop]),
93
+ FIRST_FRAME_TIMEOUT,
94
+ new TraceframeError(`Could not verify page geometry within ${FIRST_FRAME_TIMEOUT / 1000} seconds. Make sure the page is responsive.`, { code: 'CAPTURE_SIZE_MISMATCH' }),
95
+ signal,
96
+ )
97
+ if (width !== config.viewport.width || height !== config.viewport.height || scale !== 1 || left !== 0 || top !== 0) {
98
+ throw new TraceframeError(`Page geometry is ${width}×${height} with visual scale ${scale} and offset ${left},${top}; expected ${config.viewport.width}×${config.viewport.height} with scale 1 and no offset. Use 100% browser zoom and an unscaled page, and keep its geometry fixed during recording.`, { code: 'CAPTURE_SIZE_MISMATCH' })
99
+ }
100
+ }
101
+
102
+ function assertFrameSize(frame, config) {
103
+ // Playwright may pad the saved WebM. Check the actual JPEG, not DPR or the container size.
104
+ const size = jpegSize(frame.data)
105
+ if (size?.width !== config.video.width || size?.height !== config.video.height ||
106
+ frame.viewportWidth !== config.viewport.width || frame.viewportHeight !== config.viewport.height) {
107
+ const scaleHint = config.scale === 2 ? ' For scale 2, launch Chromium with --force-device-scale-factor=2; deviceScaleFactor alone is insufficient.' : ''
108
+ throw new TraceframeError(`Screencast frames are ${size ? `${size.width}×${size.height}` : 'not a readable JPEG'} for a ${frame.viewportWidth}×${frame.viewportHeight} viewport; expected ${config.video.width}×${config.video.height} for a ${config.viewport.width}×${config.viewport.height} viewport. Keep the viewport fixed and use the page exclusively for this recording.${scaleHint}`, { code: 'CAPTURE_SIZE_MISMATCH' })
109
+ }
110
+ }
111
+
112
+ function jpegSize(data) {
113
+ if (data.length < 2 || data.readUInt16BE(0) !== 0xffd8) return undefined
114
+ for (let offset = 2; offset + 8 < data.length;) {
115
+ const marker = data.readUInt16BE(offset)
116
+ // Chromium emits baseline JPEG; accept the other SOF encodings as well.
117
+ if (marker >= 0xffc0 && marker <= 0xffcf && ![0xffc4, 0xffc8, 0xffcc].includes(marker)) {
118
+ return { width: data.readUInt16BE(offset + 7), height: data.readUInt16BE(offset + 5) }
119
+ }
120
+ const length = data.readUInt16BE(offset + 2)
121
+ if (length < 2) return undefined
122
+ offset += length + 2
73
123
  }
74
124
  }
75
125
 
@@ -122,6 +172,8 @@ async function startScreencast(page, path, config, onFrame) {
122
172
  await page.screencast.start({ path, size: { width: config.video.width, height: config.video.height }, fps: config.video.fps, quality: 95, onFrame })
123
173
  } catch (error) {
124
174
  if (!/Executable doesn't exist/i.test(error.message)) throw error
175
+ // Playwright added our frame client before looking for FFmpeg; no caller recorder was active.
176
+ await page.screencast.stop().catch(() => {})
125
177
  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 })
126
178
  }
127
179
  }
@@ -132,6 +184,7 @@ async function withDeadline(task, milliseconds, error, signal) {
132
184
  const timer = setTimeout(() => controller.abort(error), milliseconds)
133
185
  const deadline = AbortSignal.any([signal, controller.signal])
134
186
  try {
187
+ deadline.throwIfAborted()
135
188
  return await raceAbort(task(deadline), deadline)
136
189
  } finally {
137
190
  clearTimeout(timer)
package/src/cli.mjs CHANGED
@@ -24,8 +24,10 @@ const OPTION_KEYS = {
24
24
  'ready-timeout': 'readyTimeout',
25
25
  'flow-timeout': 'flowTimeout',
26
26
  'run-dir': 'runDirectory',
27
+ audio: 'audio',
28
+ 'audio-offset': 'audioOffset',
27
29
  }
28
- const NUMBER_KEYS = new Set(['viewportWidth', 'viewportHeight', 'scale', 'fps', 'postRoll', 'readyTimeout', 'flowTimeout'])
30
+ const NUMBER_KEYS = new Set(['viewportWidth', 'viewportHeight', 'scale', 'fps', 'postRoll', 'readyTimeout', 'flowTimeout', 'audioOffset'])
29
31
  const HELP = `traceframe ${VERSION}
30
32
 
31
33
  Record a scripted Chromium flow and render a smooth cursor and click pulse.
@@ -46,6 +48,8 @@ Options:
46
48
  --ready-timeout <seconds> Playwright default timeout during navigation and ready() (default: 30)
47
49
  --flow-timeout <seconds> Time limit for the flow, including waits (default: 120)
48
50
  --run-dir <path> Keep the raw capture, failure screenshot and FFmpeg filter
51
+ --audio <path> Add an external audio file (AAC stereo in the MP4)
52
+ --audio-offset <seconds> Audio start in the video; negative trims its start (default: 0)
49
53
  -h, --help Show this help
50
54
  -v, --version Show the version
51
55
 
@@ -108,5 +112,9 @@ function parseCommand(args) {
108
112
  for (const [name, key] of Object.entries(OPTION_KEYS)) {
109
113
  if (values[name] !== undefined) options[key] = NUMBER_KEYS.has(key) ? Number(values[name]) : values[name]
110
114
  }
115
+ if (options.audio !== undefined || options.audioOffset !== undefined) {
116
+ options.audio = { path: options.audio, offset: options.audioOffset }
117
+ delete options.audioOffset
118
+ }
111
119
  return { options }
112
120
  }
package/src/config.mjs CHANGED
@@ -16,43 +16,77 @@ export function createConfig(options = {}) {
16
16
  width: options.viewportWidth ?? preset.viewport.width,
17
17
  height: options.viewportHeight ?? preset.viewport.height,
18
18
  }
19
+ check(options.flow, 'A flow file is required (the flow option, or --flow).')
20
+ check(options.url, 'A URL is required (the url option, or --url).')
21
+ const readyTimeout = options.readyTimeout ?? 30
22
+ check(Number.isFinite(readyTimeout) && readyTimeout > 0, 'Ready timeout must be more than 0 seconds.')
23
+
24
+ return {
25
+ ...recordingConfig(options, viewport, preset),
26
+ url: options.url,
27
+ flow: resolve(options.flow),
28
+ storageState: options.storageState,
29
+ browser: options.browser ?? process.env.CHROME_PATH,
30
+ readyTimeout,
31
+ }
32
+ }
33
+
34
+ export function createPageConfig(options = {}) {
35
+ const { page } = options
36
+ check(typeof page?.viewportSize === 'function' && page.screencast, 'A Playwright Page with screencast support is required.')
37
+ check(!page.isClosed(), 'The supplied page is closed.')
38
+ const viewport = page.viewportSize()
39
+ check(viewport, 'recordPage requires a fixed viewport. Set it when creating the page or context; viewport: null is not supported.')
40
+ for (const key of ['url', 'browser', 'storageState', 'readyTimeout', 'viewportWidth', 'viewportHeight', 'preset']) {
41
+ check(options[key] === undefined, `recordPage does not accept ${key}; prepare the page before recording and use scale/fps for video settings.`)
42
+ }
43
+ return recordingConfig(options, viewport, { scale: 1, fps: 30 })
44
+ }
45
+
46
+ function recordingConfig(options, viewport, defaults) {
19
47
  check(Number.isInteger(viewport.width) && viewport.width > 0, 'Viewport width must be a positive integer.')
20
48
  check(Number.isInteger(viewport.height) && viewport.height > 0, 'Viewport height must be a positive integer.')
21
- const scale = options.scale ?? preset.scale
49
+ const scale = options.scale ?? defaults.scale
22
50
  check(scale === 1 || scale === 2, 'Scale must be 1 or 2.')
23
51
  const video = {
24
52
  width: viewport.width * scale,
25
53
  height: viewport.height * scale,
26
- fps: options.fps ?? preset.fps,
54
+ fps: options.fps ?? defaults.fps,
27
55
  }
28
56
  check(video.fps === 30 || video.fps === 60, 'FPS must be 30 or 60.')
29
57
  check(video.width % 2 === 0 && video.height % 2 === 0, 'At scale 1, the viewport width and height must be even.')
30
58
 
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
59
  const output = resolve(options.output ?? 'traceframe.mp4')
34
60
  check(output.toLowerCase().endsWith('.mp4'), 'Output path must use the .mp4 extension.')
35
61
  const postRoll = options.postRoll ?? 0.3
36
62
  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
63
  const flowTimeout = options.flowTimeout ?? 120
40
64
  check(Number.isFinite(flowTimeout) && flowTimeout > 0, 'Flow timeout must be more than 0 seconds.')
65
+ const runDirectory = options.runDirectory ? resolve(options.runDirectory) : undefined
41
66
 
42
67
  return {
43
- url: options.url,
44
- flow: resolve(options.flow),
45
68
  output,
46
- storageState: options.storageState,
47
- browser: options.browser ?? process.env.CHROME_PATH,
48
- viewport,
69
+ audio: typeof options.audio === 'function' ? options.audio : createAudioConfig(options.audio, output, runDirectory),
70
+ viewport: { ...viewport },
49
71
  video,
50
72
  scale,
51
73
  postRoll,
52
- readyTimeout,
53
74
  flowTimeout,
54
- runDirectory: options.runDirectory ? resolve(options.runDirectory) : undefined,
75
+ runDirectory,
76
+ }
77
+ }
78
+
79
+ export function createAudioConfig(audio, output, directory) {
80
+ if (audio === undefined) return undefined
81
+ check(typeof audio?.path === 'string' && audio.path.length > 0, 'Audio must specify a local file path (--audio <path>, or audio: { path }).')
82
+ const path = resolve(audio.path)
83
+ check(path !== output, 'Audio input and video output must use different paths.')
84
+ if (directory) {
85
+ check(!['capture.webm', 'render.filter', 'failure.png'].some((name) => path === resolve(directory, name)), `Audio input must not use a reserved recording file: ${path}`)
55
86
  }
87
+ const offset = audio.offset ?? 0
88
+ check(Number.isFinite(offset), 'Audio offset must be a finite number of seconds.')
89
+ return { path, offset }
56
90
  }
57
91
 
58
92
  function check(valid, message) {
@@ -7,20 +7,22 @@ import { TraceframeError } from './errors.mjs'
7
7
  export async function addDiagnostics(error, { flow, ...details }) {
8
8
  if (!(error instanceof Error)) return error
9
9
  try {
10
- const paths = new Set([flow, await realpath(flow).catch(() => flow)])
11
- const names = [...paths].flatMap((path) => [path, pathToFileURL(path).href])
12
- const escaped = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
13
- const location = new RegExp(`^\\s+at (?:async )?(?:.+ \\()?(${escaped.join('|')}):(\\d+):(\\d+)\\)?$`)
14
10
  const flowLocations = []
15
- const seenErrors = new Set()
16
- const seenLocations = new Set()
17
- for (let current = error; current instanceof Error && !seenErrors.has(current); current = current.cause) {
18
- seenErrors.add(current)
19
- for (const frame of String(current.stack ?? '').split('\n')) {
20
- const match = frame.match(location)
21
- if (!match || seenLocations.has(`${match[2]}:${match[3]}`)) continue
22
- seenLocations.add(`${match[2]}:${match[3]}`)
23
- flowLocations.push({ file: flow, line: Number(match[2]), column: Number(match[3]) })
11
+ if (flow) {
12
+ const paths = new Set([flow, await realpath(flow).catch(() => flow)])
13
+ const names = [...paths].flatMap((path) => [path, pathToFileURL(path).href])
14
+ const escaped = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
15
+ const location = new RegExp(`^\\s+at (?:async )?(?:.+ \\()?(${escaped.join('|')}):(\\d+):(\\d+)\\)?$`)
16
+ const seenErrors = new Set()
17
+ const seenLocations = new Set()
18
+ for (let current = error; current instanceof Error && !seenErrors.has(current); current = current.cause) {
19
+ seenErrors.add(current)
20
+ for (const frame of String(current.stack ?? '').split('\n')) {
21
+ const match = frame.match(location)
22
+ if (!match || seenLocations.has(`${match[2]}:${match[3]}`)) continue
23
+ seenLocations.add(`${match[2]}:${match[3]}`)
24
+ flowLocations.push({ file: flow, line: Number(match[2]), column: Number(match[3]) })
25
+ }
24
26
  }
25
27
  }
26
28
  error.diagnostics = { flowLocations, ...Object.fromEntries(Object.entries(details).filter(([, value]) => value !== undefined)) }
package/src/ffmpeg.mjs CHANGED
@@ -1,11 +1,12 @@
1
1
  import { execFile } from 'node:child_process'
2
+ import { stat } from 'node:fs/promises'
2
3
  import { promisify } from 'node:util'
3
4
 
4
5
  import { TraceframeError } from './errors.mjs'
5
6
 
6
7
  const execFileAsync = promisify(execFile)
7
8
 
8
- export async function probeFfmpeg() {
9
+ export async function probeFfmpeg({ audio = false } = {}) {
9
10
  const version = await runCommand('ffmpeg', ['-version'])
10
11
  const major = parseFfmpegMajor(version.stdout)
11
12
  if (!(major >= 5)) {
@@ -15,12 +16,27 @@ export async function probeFfmpeg() {
15
16
  if (!codecs.stdout.includes('libx264')) {
16
17
  throw new TraceframeError('System FFmpeg does not provide the libx264 encoder.', { code: 'FFMPEG_UNSUPPORTED' })
17
18
  }
19
+ if (audio && !/^\s*A\S*\s+aac\s/m.test(codecs.stdout)) {
20
+ throw new TraceframeError('System FFmpeg does not provide the AAC encoder required for audio.', { code: 'FFMPEG_UNSUPPORTED' })
21
+ }
18
22
  await runCommand('ffprobe', ['-version'])
19
23
  return { filterComplexFileOption: filterComplexFileOption(major) }
20
24
  }
21
25
 
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 })
26
+ export async function probeAudio(path, signal) {
27
+ try {
28
+ if (!(await stat(path)).isFile()) throw new Error('The input is not a regular file.')
29
+ const { stdout } = await runCommand('ffprobe', ['-v', 'error', '-select_streams', 'a:0', '-show_entries', 'stream=index', '-of', 'json', path], { signal })
30
+ const { streams } = JSON.parse(stdout)
31
+ if (!streams.length) throw new Error('The file has no audio stream.')
32
+ } catch (cause) {
33
+ signal?.throwIfAborted()
34
+ throw new TraceframeError(`Could not read audio from ${path}: ${cause.message}`, { code: 'AUDIO_INVALID', cause })
35
+ }
36
+ }
37
+
38
+ export async function probeVideoDuration(path, signal) {
39
+ const { stdout } = await runCommand('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=duration', '-of', 'csv=p=0', path], { signal })
24
40
  return Number(stdout)
25
41
  }
26
42
 
package/src/flow.mjs CHANGED
@@ -22,11 +22,16 @@ export async function loadFlow(path, viewport) {
22
22
  throw new TraceframeError(`Could not load flow: ${path}: ${error instanceof Error ? error.message : error}`, { code: 'INVALID_FLOW', cause: error })
23
23
  }
24
24
  if (typeof module.default !== 'function') throw new TraceframeError('Flow must export a default function.', { code: 'INVALID_FLOW' })
25
- const { x, y } = module.initialPointer ?? { x: Math.round(viewport.width / 4), y: Math.max(0, viewport.height - 80) }
25
+ return { ...createFlow(module.default, viewport, module.initialPointer), ready: module.ready }
26
+ }
27
+
28
+ export function createFlow(run, viewport, initialPointer) {
29
+ if (typeof run !== 'function') throw new TraceframeError('Flow must be a function.', { code: 'INVALID_FLOW' })
30
+ const { x, y } = initialPointer ?? { x: Math.round(viewport.width / 4), y: Math.max(0, viewport.height - 80) }
26
31
  if (!insideViewport({ x, y }, viewport)) {
27
32
  throw new TraceframeError('Flow initialPointer must be a point inside the viewport.', { code: 'INVALID_FLOW' })
28
33
  }
29
- return { run: module.default, ready: module.ready, initialPointer: { x, y } }
34
+ return { run, initialPointer: { x, y } }
30
35
  }
31
36
 
32
37
  // Events are timed in seconds from `clock`, the epoch milliseconds of the first video frame.
@@ -35,6 +40,7 @@ export function createFlowApi({ page, trace, clock, viewport, signal }) {
35
40
  const sleep = (milliseconds) => delay(milliseconds, undefined, { signal })
36
41
 
37
42
  const movePointer = async (point, duration) => {
43
+ signal?.throwIfAborted()
38
44
  assertBelowLimit(trace.moves, LIMITS.maxMoves, 'move')
39
45
  const from = pointer
40
46
  const to = { x: point.x, y: point.y }
@@ -48,24 +54,25 @@ export function createFlowApi({ page, trace, clock, viewport, signal }) {
48
54
  await sleep(Math.min(MOVE_SAMPLE_MS, total - elapsed))
49
55
  elapsed = Math.min(total, performance.now() - startedAt)
50
56
  const eased = easeOutCubic(elapsed / total)
57
+ signal?.throwIfAborted()
51
58
  await page.mouse.move(from.x + (to.x - from.x) * eased, from.y + (to.y - from.y) * eased)
52
59
  }
53
60
  pointer = to
54
61
  }
55
62
 
56
- const moveTo = async (locator, duration, name) => {
63
+ const moveTo = async (locator, duration, name, handles) => {
57
64
  const seconds = durationSeconds(duration ?? MOVE_SECONDS, `${name} duration`)
58
- const target = await findTarget(locator)
65
+ const target = await findTarget(locator, signal, handles)
59
66
  // A person does not chase a target that shifts under a pointer already on it.
60
67
  if (pointInsideBox(pointer, target.box)) return pointer
61
68
  let point = await aimPoint(target)
62
69
  if (!insideViewport(point, viewport)) {
63
- const aim = await reveal(target.element, locator)
70
+ const aim = await reveal(target.element, locator, signal, handles)
64
71
  if (!aim.visible) throw new TraceframeError(`${locator} could not be revealed by scrolling.`, { code: 'FLOW_TARGET_OUTSIDE_VIEWPORT' })
65
72
  point = aim.point
66
73
  }
67
74
  await movePointer(point, seconds)
68
- const after = await locator.boundingBox()
75
+ const after = await locator.boundingBox({ signal })
69
76
  if (!after || !pointInsideBox(point, after)) {
70
77
  throw new TraceframeError(`${locator} moved while the pointer was arriving.`, { code: 'FLOW_TARGET_MOVED' })
71
78
  }
@@ -73,21 +80,23 @@ export function createFlowApi({ page, trace, clock, viewport, signal }) {
73
80
  }
74
81
 
75
82
  return {
76
- async hover(locator, options = {}) {
77
- await moveTo(locator, options.duration, 'hover')
78
- },
79
- async click(locator, options = {}) {
83
+ hover: withHandles(async (handles, locator, options = {}) => {
84
+ await moveTo(locator, options.duration, 'hover', handles)
85
+ }),
86
+ click: withHandles(async (handles, locator, options = {}) => {
80
87
  assertBelowLimit(trace.clicks, LIMITS.maxClicks, 'click')
81
88
  const dwell = durationSeconds(options.dwell ?? DWELL_SECONDS, 'click dwell', { allowZero: true })
82
- const point = await moveTo(locator, options.duration, 'click')
89
+ const point = await moveTo(locator, options.duration, 'click', handles)
83
90
  await sleep(dwell * 1000)
84
91
  // Hold on to this element: a control may rename or hide itself once it is pressed.
85
- const element = await locator.elementHandle()
86
- await element.waitForElementState('enabled').catch((error) => {
92
+ const element = await locator.elementHandle({ signal })
93
+ handles.push(element)
94
+ await element.waitForElementState('enabled', { signal }).catch((error) => {
87
95
  throw error.name === 'TimeoutError' ? new TraceframeError(`${locator} stayed disabled.`, { code: 'FLOW_TARGET_DISABLED', cause: error }) : error
88
96
  })
89
- await assertReceivesPointer(element, locator, point)
97
+ await assertReceivesPointer(element, locator, point, handles)
90
98
  const at = secondsSince(clock)
99
+ signal?.throwIfAborted()
91
100
  await page.mouse.down()
92
101
  try {
93
102
  await sleep(PRESS_HOLD_MS)
@@ -101,23 +110,35 @@ export function createFlowApi({ page, trace, clock, viewport, signal }) {
101
110
  await page.mouse.up()
102
111
  }
103
112
  trace.clicks.push({ at, point })
104
- },
113
+ }),
105
114
  async move(point, options = {}) {
106
115
  if (!insideViewport(point, viewport)) {
107
116
  throw new TraceframeError('move requires an { x, y } point inside the viewport.', { code: 'INVALID_FLOW' })
108
117
  }
109
118
  await movePointer(point, durationSeconds(options.duration ?? MOVE_SECONDS, 'move duration'))
110
119
  },
111
- async scroll(locator) {
112
- const { shown } = await reveal((await findTarget(locator)).element, locator)
120
+ scroll: withHandles(async (handles, locator) => {
121
+ const { shown } = await reveal((await findTarget(locator, signal, handles)).element, locator, signal, handles)
113
122
  if (!shown) throw new TraceframeError(`${locator} could not be revealed by scrolling.`, { code: 'FLOW_TARGET_OUTSIDE_VIEWPORT' })
114
- },
123
+ }),
115
124
  async wait(seconds) {
116
125
  await sleep(durationSeconds(seconds, 'wait', { allowZero: true }) * 1000)
117
126
  },
118
127
  }
119
128
  }
120
129
 
130
+ // A helper may outlive a cancelled recording; release its handles when that helper actually settles.
131
+ function withHandles(run) {
132
+ return async (...args) => {
133
+ const handles = []
134
+ try {
135
+ return await run(handles, ...args)
136
+ } finally {
137
+ await Promise.all(handles.map(handle => handle.dispose().catch(() => {})))
138
+ }
139
+ }
140
+ }
141
+
121
142
  function insideViewport(point, viewport) {
122
143
  return point?.x >= 0 && point?.y >= 0 && point.x <= viewport.width && point.y <= viewport.height
123
144
  }
@@ -145,9 +166,10 @@ function assertBelowLimit(events, limit, type) {
145
166
  }
146
167
  }
147
168
 
148
- async function findTarget(locator) {
149
- await locator.waitFor()
150
- const element = await locator.elementHandle()
169
+ async function findTarget(locator, signal, handles) {
170
+ await locator.waitFor({ signal })
171
+ const element = await locator.elementHandle({ signal })
172
+ handles.push(element)
151
173
  const box = await element.boundingBox()
152
174
  if (!box) throw new TraceframeError(`Could not find ${locator}.`, { code: 'FLOW_TARGET_MISSING' })
153
175
  return { element, box }
@@ -168,10 +190,10 @@ async function aimPoint({ element, box }) {
168
190
  return { x: box.x + offset.x * box.width, y: box.y + offset.y * box.height }
169
191
  }
170
192
 
171
- async function reveal(element, locator) {
193
+ async function reveal(element, locator, signal, handles) {
172
194
  // Content that loads above the target during the scroll can push it out again.
173
195
  for (let attempt = 0; attempt < 2; attempt++) {
174
- const target = await scrollIntoView(element, locator)
196
+ const target = await scrollIntoView(element, locator, signal, handles)
175
197
  const point = await aimPoint(target)
176
198
  const visibility = await target.element.evaluate((node, offset) => new Promise((resolve) => {
177
199
  new IntersectionObserver(([entry], observer) => {
@@ -191,10 +213,11 @@ async function reveal(element, locator) {
191
213
 
192
214
  // Awaiting the scroll waits for a scrolling panel. Chromium resolves a page scroll before it stops, and a sticky target
193
215
  // or iframe can stay put while its page scrolls, so also wait for every enclosing page to stop.
194
- async function scrollIntoView(element, locator) {
216
+ async function scrollIntoView(element, locator, signal, handles) {
217
+ signal?.throwIfAborted()
195
218
  await element.evaluate((target) => target.scrollIntoView({ behavior: 'smooth', block: 'center' }))
196
219
  try {
197
- await element.waitForElementState('stable')
220
+ await element.waitForElementState('stable', { signal })
198
221
  let frame = await element.ownerFrame()
199
222
  while (frame) {
200
223
  await frame.evaluate(() => new Promise((resolve) => {
@@ -208,14 +231,16 @@ async function scrollIntoView(element, locator) {
208
231
  requestAnimationFrame(check)
209
232
  }))
210
233
  if (!frame.parentFrame()) break
211
- await (await frame.frameElement()).waitForElementState('stable')
234
+ const frameElement = await frame.frameElement()
235
+ handles.push(frameElement)
236
+ await frameElement.waitForElementState('stable', { signal })
212
237
  frame = frame.parentFrame()
213
238
  }
214
239
  // The page may replace the target while it scrolls, or remove or hide it, as a menu that closes on scroll does.
215
- const target = await findTarget(locator).catch((error) => {
240
+ const target = await findTarget(locator, signal, handles).catch((error) => {
216
241
  throw error.name === 'TimeoutError' ? new TraceframeError(`${locator} disappeared when it was scrolled into view.`, { code: 'FLOW_TARGET_MISSING', cause: error }) : error
217
242
  })
218
- await target.element.waitForElementState('stable')
243
+ await target.element.waitForElementState('stable', { signal })
219
244
  const box = await target.element.boundingBox()
220
245
  if (!box) throw new TraceframeError(`Could not find ${locator}.`, { code: 'FLOW_TARGET_MISSING' })
221
246
  return { element: target.element, box }
@@ -224,7 +249,7 @@ async function scrollIntoView(element, locator) {
224
249
  }
225
250
  }
226
251
 
227
- async function assertReceivesPointer(element, locator, point) {
252
+ async function assertReceivesPointer(element, locator, point, handles) {
228
253
  const box = await element.boundingBox()
229
254
  if (!box || !pointInsideBox(point, box)) {
230
255
  throw new TraceframeError(`${locator} moved before the click.`, { code: 'FLOW_TARGET_MOVED' })
@@ -238,15 +263,16 @@ async function assertReceivesPointer(element, locator, point) {
238
263
  }
239
264
  return false
240
265
  }, relativePoint(point, box))
241
- if (!receivesPointer || !(await framesReceivePointer(element, point))) {
266
+ if (!receivesPointer || !(await framesReceivePointer(element, point, handles))) {
242
267
  throw new TraceframeError(`${locator} is covered at the click point. If a dialog or toast covers it, wait until it closes; if a panel clips it or a sticky header or bar covers it, call scroll() on the target first.`, { code: 'FLOW_TARGET_OBSCURED' })
243
268
  }
244
269
  }
245
270
 
246
- async function framesReceivePointer(element, point) {
271
+ async function framesReceivePointer(element, point, handles) {
247
272
  let frame = await element.ownerFrame()
248
273
  while (frame?.parentFrame()) {
249
274
  const frameElement = await frame.frameElement()
275
+ handles.push(frameElement)
250
276
  const box = await frameElement.boundingBox()
251
277
  if (!box) return false
252
278
  const hit = await frameElement.evaluate((target, { x, y }) => {
package/src/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export { record } from './record.mjs'
1
+ export { record, recordPage } from './record.mjs'
2
2
  export { TraceframeError } from './errors.mjs'
package/src/record.mjs CHANGED
@@ -2,21 +2,45 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { dirname, join } from 'node:path'
4
4
 
5
- import { createConfig } from './config.mjs'
5
+ import { createAudioConfig, createConfig, createPageConfig } from './config.mjs'
6
6
  import { captureFlow } from './capture.mjs'
7
7
  import { addDiagnostics, retainedFile } from './diagnostics.mjs'
8
- import { probeFfmpeg } from './ffmpeg.mjs'
9
- import { loadFlow } from './flow.mjs'
8
+ import { probeAudio, probeFfmpeg } from './ffmpeg.mjs'
9
+ import { createFlow, loadFlow } from './flow.mjs'
10
10
  import { renderVideo } from './render.mjs'
11
+ import { TraceframeError } from './errors.mjs'
12
+
13
+ const activePages = new WeakSet()
11
14
 
12
15
  export async function record(options = {}) {
13
16
  const { signal } = options
14
17
  signal?.throwIfAborted()
15
18
  const config = createConfig(options)
16
- const ffmpeg = await probeFfmpeg()
19
+ const ffmpeg = await probeFfmpeg({ audio: Boolean(config.audio) })
17
20
  const flow = await loadFlow(config.flow, config.viewport).catch(async (error) => {
18
21
  throw await addDiagnostics(error, { flow: config.flow })
19
22
  })
23
+ return recordFlow({ config, flow, ffmpeg, signal })
24
+ }
25
+
26
+ export async function recordPage(options = {}) {
27
+ const { page, signal } = options
28
+ signal?.throwIfAborted()
29
+ const config = createPageConfig(options)
30
+ const flow = createFlow(options.flow, config.viewport, options.initialPointer)
31
+ if (activePages.has(page)) throw new TraceframeError('This page is already being recorded by Traceframe.', { code: 'PAGE_RECORDING_ACTIVE' })
32
+ activePages.add(page)
33
+ try {
34
+ const ffmpeg = await probeFfmpeg({ audio: Boolean(config.audio) })
35
+ return await recordFlow({ config, flow, ffmpeg, signal, page })
36
+ } finally {
37
+ activePages.delete(page)
38
+ }
39
+ }
40
+
41
+ async function recordFlow({ config, flow, ffmpeg, signal, page }) {
42
+ let audio = typeof config.audio === 'function' ? undefined : config.audio
43
+ if (audio) await probeAudio(audio.path, signal)
20
44
  await mkdir(dirname(config.output), { recursive: true })
21
45
  const directory = config.runDirectory ?? await mkdtemp(join(tmpdir(), 'traceframe-'))
22
46
  await mkdir(directory, { recursive: true })
@@ -24,9 +48,15 @@ export async function record(options = {}) {
24
48
  let capture
25
49
  let failed = false
26
50
  try {
27
- capture = await captureFlow({ config, flow, directory, signal })
51
+ capture = await captureFlow({ config, flow, directory, signal, page })
28
52
  const { rawPath, trace } = capture
29
- const duration = await renderVideo({ rawPath, trace, directory, config, ffmpeg, signal })
53
+ if (typeof config.audio === 'function') {
54
+ signal?.throwIfAborted()
55
+ audio = createAudioConfig(await config.audio({ startedAt: capture.startedAt, signal }), config.output, directory)
56
+ if (!audio) throw new TraceframeError('The audio function must return { path, offset }.', { code: 'INVALID_CONFIG' })
57
+ await probeAudio(audio.path, signal)
58
+ }
59
+ const duration = await renderVideo({ rawPath, trace, directory, config, ffmpeg, signal, audio })
30
60
  return {
31
61
  output: config.output,
32
62
  width: config.video.width,
@@ -36,6 +66,7 @@ export async function record(options = {}) {
36
66
  moveCount: trace.moves.length,
37
67
  clickCount: trace.clicks.length,
38
68
  runDirectory: config.runDirectory,
69
+ ...(audio ? { audio: true } : {}),
39
70
  }
40
71
  } catch (error) {
41
72
  failed = true
package/src/render.mjs CHANGED
@@ -2,7 +2,7 @@ import { rename, rm, writeFile } from 'node:fs/promises'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { randomUUID } from 'node:crypto'
4
4
 
5
- import { probeDuration, runCommand } from './ffmpeg.mjs'
5
+ import { probeVideoDuration, runCommand } from './ffmpeg.mjs'
6
6
  import { TraceframeError } from './errors.mjs'
7
7
 
8
8
  const ASSETS = join(import.meta.dirname, 'assets')
@@ -13,11 +13,14 @@ const CLICK = { duration: 0.24, scaleStart: 0.7, scaleEnd: 1.25 }
13
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
14
  const OVERLAY = 'eof_action=pass:format=yuv444'
15
15
 
16
- export async function renderVideo({ rawPath, trace, directory, config, ffmpeg, signal }) {
16
+ export async function renderVideo({ rawPath, trace, directory, config, ffmpeg, signal, audio }) {
17
17
  const { fps } = config.video
18
18
  const outputDuration = Math.max(trace.end + config.postRoll, ...trace.clicks.map((click) => click.at + CLICK.duration))
19
+ if (audio && audio.offset >= outputDuration) {
20
+ throw new TraceframeError(`Audio offset ${audio.offset} seconds starts at or after the video's end (${number(outputDuration)} seconds). Reduce its offset.`, { code: 'AUDIO_OUTSIDE_VIDEO' })
21
+ }
19
22
  const filterPath = join(directory, 'render.filter')
20
- await writeFile(filterPath, buildFilterScript({ trace, scale: config.scale }))
23
+ await writeFile(filterPath, buildFilterScript({ trace, scale: config.scale, audio }))
21
24
  const outputTemp = join(dirname(config.output), `.traceframe-${randomUUID()}.tmp.mp4`)
22
25
  const args = [
23
26
  '-y', '-hide_banner', '-loglevel', 'error',
@@ -25,9 +28,13 @@ export async function renderVideo({ rawPath, trace, directory, config, ffmpeg, s
25
28
  '-loop', '1', '-framerate', String(fps), '-i', join(ASSETS, `cursor-${config.scale}x.png`),
26
29
  ]
27
30
  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`))
31
+ if (audio) args.push('-i', audio.path)
28
32
  args.push(
29
33
  ffmpeg.filterComplexFileOption, filterPath,
30
34
  '-map', '[final]',
35
+ )
36
+ if (audio) args.push('-map', '[finalAudio]', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2')
37
+ args.push(
31
38
  '-t', String(number(outputDuration)),
32
39
  '-r', String(fps),
33
40
  '-c:v', 'libx264', '-preset', 'fast', '-crf', '18',
@@ -37,7 +44,7 @@ export async function renderVideo({ rawPath, trace, directory, config, ffmpeg, s
37
44
  )
38
45
  try {
39
46
  await runCommand('ffmpeg', args, { signal })
40
- const duration = await probeDuration(outputTemp, signal)
47
+ const duration = await probeVideoDuration(outputTemp, signal)
41
48
  if (!(Math.abs(duration - outputDuration) <= 1.5 / fps)) {
42
49
  throw new TraceframeError(`The rendered video is ${duration.toFixed(2)} seconds long, but the recording is ${outputDuration.toFixed(2)} seconds.`, { code: 'OUTPUT_MISMATCH' })
43
50
  }
@@ -51,7 +58,7 @@ export async function renderVideo({ rawPath, trace, directory, config, ffmpeg, s
51
58
  }
52
59
 
53
60
  // Event times are seconds from the first frame of the raw video.
54
- export function buildFilterScript({ trace, scale }) {
61
+ export function buildFilterScript({ trace, scale, audio }) {
55
62
  const { clicks, moves } = trace
56
63
  // 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
64
  const filters = [`[0:v]setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration=1[video0]`]
@@ -75,6 +82,13 @@ export function buildFilterScript({ trace, scale }) {
75
82
  filters.push(`[cursorVideo${index}][cursor${index + 1}]overlay=x='${x}':y='${y}':${OVERLAY}:enable='${enable}'[cursorVideo${index + 1}]`)
76
83
  })
77
84
  filters.push(`[cursorVideo${moves.length}]colorspace=ispace=bt470bg:iprimaries=bt709:itrc=bt709:all=bt709:format=yuv420p[final]`)
85
+ if (audio) {
86
+ const input = clicks.length ? 3 : 2
87
+ const trim = number(Math.max(0, -audio.offset))
88
+ const delay = number(Math.max(0, audio.offset) * 1000)
89
+ // Reset the selected stream before trimming: its first sample may start after the container's video.
90
+ filters.push(`[${input}:a:0]asetpts=PTS-STARTPTS,atrim=start=${trim},asetpts=PTS-STARTPTS,adelay=${delay}:all=1,apad[finalAudio]`)
91
+ }
78
92
  return filters.join(';')
79
93
  }
80
94