@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 +76 -4
- package/docs/recording-flows.md +78 -3
- package/examples/existing-page.mjs +30 -0
- package/package.json +2 -1
- package/src/capture.mjs +64 -11
- package/src/cli.mjs +9 -1
- package/src/config.mjs +47 -13
- package/src/diagnostics.mjs +15 -13
- package/src/ffmpeg.mjs +19 -3
- package/src/flow.mjs +57 -31
- package/src/index.mjs +1 -1
- package/src/record.mjs +37 -6
- package/src/render.mjs +19 -5
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`.
|
|
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
|
|
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
|
|
package/docs/recording-flows.md
CHANGED
|
@@ -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,
|
|
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
|
|
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#
|
|
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
|
+
"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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
36
|
+
signal.throwIfAborted()
|
|
37
|
+
await startScreencast(page, rawPath, config, firstFrame.resolve)
|
|
33
38
|
recording = true
|
|
34
|
-
|
|
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 (
|
|
72
|
-
|
|
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 ??
|
|
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 ??
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
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) {
|
package/src/diagnostics.mjs
CHANGED
|
@@ -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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
77
|
-
await moveTo(locator, options.duration, 'hover')
|
|
78
|
-
},
|
|
79
|
-
async
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|