@milkstraw/traceframe 0.6.2 → 0.7.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 +28 -6
- package/docs/recording-flows.md +9 -1
- package/package.json +2 -2
- package/src/browser.mjs +70 -0
- package/src/capture.mjs +41 -51
- package/src/cli.mjs +3 -1
- package/src/config.mjs +5 -2
- package/src/deadline.mjs +24 -0
- package/src/record.mjs +1 -0
package/README.md
CHANGED
|
@@ -61,15 +61,19 @@ The cursor and click pulses are always drawn at the full frame rate. Chromium ma
|
|
|
61
61
|
|
|
62
62
|
Use `--capture-diagnostics` (`captureDiagnostics: true` with `record` or `recordPage`) to include `captureStats` in the result. It reports received frames, their timestamp span and rate, the largest forward timestamp jumps and delivery gaps, and out-of-order timestamps. `lateFrames` counts frames older than the first received frame or the last written frame; `overflowFrames` counts frames dropped at the 64 MiB capture-buffer limit, and `maxBufferedBytes` reports its peak use. `writeWaitMs` and `maxWriteWaitMs` measure total and maximum time waiting for frame-write callbacks, including the final static frame; they do not measure durable disk writes. Slow writes can delay frame acknowledgments and reduce what Chromium sends, even when the writer drop counters stay at zero. These measurements describe frames delivered by Chromium. They do not count distinct pictures or prove smoothness; a static page legitimately delivers few frames, and a 30 FPS video cannot supply 60 distinct source frames per second.
|
|
63
63
|
|
|
64
|
-
`--capture-quality <1–100>` (`captureQuality` in either API) controls the JPEG frames exported by Chromium; the default
|
|
64
|
+
`--capture-quality <1–100>` (`captureQuality` in either API) controls the JPEG frames exported by Chromium; the default is 85. In 3024 × 1964 tests with software rendering, 85 made captured frames about a third smaller than 95, with slightly lower CPU use and no visible difference in the exported MP4, and quality did not change how many frames Chromium captured. In [GPU rendering](#gpu-rendering) tests on machines whose CPU was the limit, 85 worked best: higher quality captured fewer pictures, and lower quality stalled video playback. Otherwise, use 95 for maximum detail, or a lower setting such as 75 for smaller temporary captures with softer fine detail. This is separate from the final MP4 encoder. Check representative moving content and text at the intended resolution before choosing a non-default value.
|
|
65
65
|
|
|
66
66
|
Since v0.6.0, the retained raw file is `capture.mkv` instead of `capture.webm`; update scripts that inspect it. Final output remains MP4.
|
|
67
67
|
|
|
68
|
-
Traceframe saves Chromium's JPEG frames and presentation timestamps directly into a temporary `capture.mkv`, then encodes the final MP4 after the flow ends. This avoids a live intermediate video encode and works with CPU or GPU browser rendering. Chromium frame timestamps are retained with microsecond precision; a 150 ms buffer orders delayed arrivals. The first received frame establishes the audio clock, so subsequently delivered frames older than that origin are dropped. Slow storage applies backpressure.
|
|
68
|
+
Traceframe saves Chromium's JPEG frames and presentation timestamps directly into a temporary `capture.mkv`, then encodes the final MP4 after the flow ends. This avoids a live intermediate video encode and works with CPU or GPU browser rendering. Chromium frame timestamps are retained with microsecond precision; a 150 ms buffer orders delayed arrivals. The first received frame establishes the audio clock, so subsequently delivered frames older than that origin are dropped. Slow storage applies backpressure. The final renderer supplies the requested frame rate while keeping cursor animations smooth.
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
Moving content needs more temporary space than an inter-frame codec: at high resolution, allow hundreds of megabytes for short takes and potentially gigabytes for longer ones. Full-screen video recorded at 3024 × 1964 with GPU rendering took about 20–35 MB per second, roughly 1–2 GB per minute. Static pages need only a few stored frames. Captures are written to the system temporary directory, or to `--run-dir` (`runDirectory` in the API) when supplied.
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
SSD wear is usually a minor concern for occasional recordings; no special storage setup is needed. For frequent recording batches, you can optionally use an existing RAM-backed temporary directory to reduce SSD writes, provided it can hold the complete capture and leave ample memory for Chromium and export. If RAM-backed temporary storage is too small for a take, choose a disk-backed `--run-dir` and remove it afterward.
|
|
73
|
+
|
|
74
|
+
When measuring performance, use an otherwise idle machine, keep the browser version, source codec, viewport and device scale fixed, and compare recorded image updates and audio timing as well as file metadata.
|
|
75
|
+
|
|
76
|
+
Start performance comparisons with the Chromium build bundled with Playwright. A custom `--browser` executable can have different screencast performance even when recording succeeds. Traceframe preserves your requested resolution and quality. If the workload cannot sustain the desired cadence, increasing the encoded FPS repeats existing frames. On a slower machine, smaller dimensions help most, so choose them explicitly if that tradeoff suits your recording: `--scale 1` leaves Chromium a quarter of the pixels to capture. In full-screen 4K60 video tests on a 2017 4-core laptop with GPU rendering, it saved about 60 distinct pictures per second, against 24–30 at scale 2. A lower encoded FPS shortens export but does not reduce capture work, and on a machine that cannot keep up, 60 FPS saves more distinct pictures than 30.
|
|
73
77
|
|
|
74
78
|
For video-heavy flows, see [checking video playback](docs/recording-flows.md#check-video-playback), including optional page effects and media sizing.
|
|
75
79
|
|
|
@@ -77,6 +81,22 @@ Use `--run-dir .traceframe/runs/name` (`runDirectory` in the API) to keep the ra
|
|
|
77
81
|
|
|
78
82
|
When available, failure output includes the error code, flow-file locations, elapsed recording time, and paths to artifacts saved for that attempt. Without `--run-dir`, temporary captures are removed and no failure screenshot is saved. See [diagnosing a failed take](docs/recording-flows.md#diagnose-a-failed-take).
|
|
79
83
|
|
|
84
|
+
### GPU rendering
|
|
85
|
+
|
|
86
|
+
By default, Traceframe tries hardware rendering (`--gpu auto`, or `gpu: 'auto'` in the API). Before navigation, it checks Chromium's compositing status and renderer. If acceleration is unavailable or the GPU browser fails to start, it closes that attempt and uses software rendering instead. Navigation, `ready` and the flow run only on the selected browser; a later recording failure is not retried. Use `--gpu off` (`gpu: 'off'`) to render in software without trying the GPU.
|
|
87
|
+
|
|
88
|
+
Auto mode uses ANGLE's EGL backend on Linux and Chromium's platform backend elsewhere (Metal on the tested Mac). GPU access and driver support depend on the environment. These launch configurations were tested on Linux Intel/Mesa, Linux NVIDIA and Apple M3 Pro; Windows hardware acceleration has not been validated.
|
|
89
|
+
|
|
90
|
+
The JSON result includes `graphics`. When hardware rendering was verified, `hardwareAccelerated` is `true` and `renderer` names the GPU. Otherwise `hardwareAccelerated` is `false`; after a failed GPU attempt, `fallbackReason` is `software-rendering` (the GPU browser drew in software), `graphics-check-unavailable` (Chromium did not report its graphics status) or `gpu-launch-failed`. These describe the startup check, not GPU utilization or a guarantee of smooth playback or correct drawing. If a recording shows black or garbled areas, record again with `--gpu off`.
|
|
91
|
+
|
|
92
|
+
Traceframe does not change GPU drivers or permissions. On Linux, Chromium usually needs to open the GPU device (`/dev/dri/renderD*`), which the `render` group allows. That access is often missing over SSH, in services and in containers, so auto mode falls back to software. An administrator can add the account to the group with `sudo usermod -aG render <user>`; this takes effect in new login sessions and restarted services. In Docker, pass the device with `--device` and, for a non-root user, its group ID with `--group-add`.
|
|
93
|
+
|
|
94
|
+
On a Linux laptop with Intel and NVIDIA GPUs, auto mode can pick the Intel one; `graphics.renderer` names the GPU used. To use the faster NVIDIA GPU, install NVIDIA's EGL library for the installed driver if it is missing (on Ubuntu, `libnvidia-gl-<driver version>`) and run Traceframe with `__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json`.
|
|
95
|
+
|
|
96
|
+
Video decoding stays in software, and JPEG capture and final MP4 encoding still use the CPU; GPU rendering is not hardware video encoding. Smoother capture can store more frames and increase temporary storage and export time. For `recordPage`, configure and check the caller's browser yourself; its result has no `graphics`.
|
|
97
|
+
|
|
98
|
+
GPU rendering matters most for video-heavy pages. In full-screen 4K60 video tests at 3024 × 1964, it recorded several times more distinct pictures per second than software rendering. With spare CPU, a local 4K60 test video reached about 60 pictures per second, while YouTube's player settled at about 45; Chromium's screencast has no setting to raise that. On slower machines, video decoding and Chromium's JPEG capture compete for the CPU. Full Chromium, which extensions need, captured noticeably fewer pictures than the default headless shell on Linux, so use the default shell unless the flow needs extensions.
|
|
99
|
+
|
|
80
100
|
## Write a flow
|
|
81
101
|
|
|
82
102
|
The flow module exports a default function. Timing values are seconds:
|
|
@@ -184,13 +204,15 @@ The callback receives the same `page`, `signal` and five helpers as a flow file.
|
|
|
184
204
|
|
|
185
205
|
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.
|
|
186
206
|
|
|
187
|
-
`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.
|
|
207
|
+
`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`, `gpu`, `storageState`, viewport dimensions and `readyTimeout` are rejected.
|
|
188
208
|
|
|
189
209
|
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`.
|
|
190
210
|
|
|
191
211
|
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`.
|
|
192
212
|
|
|
193
|
-
|
|
213
|
+
If the page uses DOM fullscreen, such as a video player's fullscreen button, Chromium's default headless shell sizes it from the browser window, 800 × 600 pixels unless set. Launch that browser with the recording's pixel size too, for example `--window-size=3024,1964` for a 1512 × 982 viewport at scale 2. Browsers launched by `record` and the CLI already use the recording size.
|
|
214
|
+
|
|
215
|
+
Keep the viewport, zoom and visual scale fixed and the page open until `recordPage` settles. Page geometry is checked at startup. During the take, a frame whose size differs from the first stops recording with `CAPTURE_SIZE_CHANGED`. 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`.
|
|
194
216
|
|
|
195
217
|
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. 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.
|
|
196
218
|
|
package/docs/recording-flows.md
CHANGED
|
@@ -30,9 +30,17 @@ For a video-heavy journey, check playback in your first take at the final record
|
|
|
30
30
|
|
|
31
31
|
On YouTube, the player's **Settings (gear) → Ambient mode → Off** can reduce expensive background drawing during CPU-only playback. Ambient mode is available only with YouTube's dark theme; check the setting under the theme you will record. Disable this decorative effect only when it is unnecessary for the demonstration, and verify the setting before the take. Use the site's setting in `ready`, or prepare the caller-owned page before `recordPage`; Traceframe does not change it automatically.
|
|
32
32
|
|
|
33
|
+
If the journey enters fullscreen, wait for the fullscreen layout, not only for `document.fullscreenElement`. On a slower test machine, YouTube sometimes reported fullscreen while keeping its normal page layout. Check that the video's bounding box reaches the viewport's width or height; if it does not within a few seconds, exit fullscreen and enter it again. One retry fixed every such case in those tests.
|
|
34
|
+
|
|
33
35
|
For video assets you control, matching the source dimensions to the displayed physical pixels can avoid costly browser resizing during CPU-only playback. Physical dimensions include device scale: a video displayed 1512 CSS pixels wide at scale 2 occupies 3024 physical pixels. Preparing another asset can change image quality, so compare it with the original and preserve the intended framing. Do not silently replace third-party media, crop content or lower the required output resolution to claim a performance improvement.
|
|
34
36
|
|
|
35
|
-
If the video looks choppy
|
|
37
|
+
If the video looks choppy:
|
|
38
|
+
|
|
39
|
+
1. Apply any preparation above that suits the demonstration and make one more take at the same recording settings.
|
|
40
|
+
2. For a browser Traceframe launches, check `graphics.hardwareAccelerated` in the result. If it is `false`, or `graphics.renderer` names a slower GPU than the machine has, see [GPU rendering](../README.md#gpu-rendering) for fixes; most change system settings, so ask the machine's owner first.
|
|
41
|
+
3. If the video is still choppy, or nothing applies, keep the requested output dimensions, playback speed and source quality, finish the requested recording and report the limitation. Mention that a smaller scale usually helps most on a slower machine: `--scale 1` (`scale: 1` in the API) leaves Chromium a quarter of the pixels to capture, while a lower FPS does not reduce capture work.
|
|
42
|
+
|
|
43
|
+
For optional diagnosis of playback without recording, let buffering and any extension processing warm up, then read the video's `getVideoPlaybackQuality()` counters before and after a representative segment with recording off, for example in `ready` or before `recordPage`. Page preparation can improve playback while capture remains expensive.
|
|
36
44
|
|
|
37
45
|
## Save and switch login state
|
|
38
46
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@milkstraw/traceframe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Record scripted browser flows and render smooth cursor overlays into video.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"node": ">=22"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "node --test",
|
|
37
|
+
"test": "cd test && node --test",
|
|
38
38
|
"test:smoke": "node scripts/smoke.mjs",
|
|
39
39
|
"test:package": "node scripts/package-smoke.mjs",
|
|
40
40
|
"build:assets": "node scripts/build-assets.mjs"
|
package/src/browser.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { chromium } from 'playwright'
|
|
2
|
+
|
|
3
|
+
import { TraceframeError } from './errors.mjs'
|
|
4
|
+
import { withDeadline } from './deadline.mjs'
|
|
5
|
+
|
|
6
|
+
const GRAPHICS_CHECK_TIMEOUT = 5_000
|
|
7
|
+
const SOFTWARE_RENDERER = /swiftshader|llvmpipe|softpipe|lavapipe|software|basic render|\bwarp\b/i
|
|
8
|
+
|
|
9
|
+
export async function launchBrowser(config, { handleSignals, signal }) {
|
|
10
|
+
const baseArgs = [
|
|
11
|
+
...(config.scale === 2 ? ['--force-device-scale-factor=2'] : []),
|
|
12
|
+
// The headless shell sizes DOM fullscreen from its window (800×600 pixels by default).
|
|
13
|
+
// Match the recording's pixel size so a fullscreen page keeps the requested viewport.
|
|
14
|
+
`--window-size=${config.video.width},${config.video.height}`,
|
|
15
|
+
// Keep video decoding in software: hardware decoding fails in headless Linux and added no captured frames on macOS.
|
|
16
|
+
'--disable-accelerated-video-decode',
|
|
17
|
+
]
|
|
18
|
+
async function launch(args) {
|
|
19
|
+
try {
|
|
20
|
+
return await chromium.launch({
|
|
21
|
+
executablePath: config.browser,
|
|
22
|
+
args: [...baseArgs, ...args],
|
|
23
|
+
handleSIGINT: handleSignals,
|
|
24
|
+
handleSIGTERM: handleSignals,
|
|
25
|
+
handleSIGHUP: handleSignals,
|
|
26
|
+
})
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (!/Executable doesn't exist/i.test(error.message)) throw error
|
|
29
|
+
const message = config.browser
|
|
30
|
+
? `Configured browser executable is unavailable: ${config.browser}`
|
|
31
|
+
: 'Playwright Chromium is not installed. Run `npx playwright@1.64.1 install --only-shell chromium`, or pass --browser to a Chrome executable.'
|
|
32
|
+
throw new TraceframeError(message, { code: 'PLAYWRIGHT_RUNTIME_MISSING', cause: error })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const softwareArgs = ['--disable-gpu']
|
|
37
|
+
if (config.gpu === 'off') return { browser: await launch(softwareArgs), graphics: { hardwareAccelerated: false } }
|
|
38
|
+
|
|
39
|
+
// Decide before navigation or ready(): application preparation runs only once.
|
|
40
|
+
const candidate = await launch(['--enable-gpu', ...(process.platform === 'linux' ? ['--use-angle=gl-egl'] : [])])
|
|
41
|
+
.catch(error => { if (error.code === 'PLAYWRIGHT_RUNTIME_MISSING') throw error })
|
|
42
|
+
const { renderer, fallbackReason } = candidate ? await inspectGraphics(candidate, signal) : { fallbackReason: 'gpu-launch-failed' }
|
|
43
|
+
if (!fallbackReason) return { browser: candidate, graphics: { hardwareAccelerated: true, renderer } }
|
|
44
|
+
await candidate?.close()
|
|
45
|
+
// The launch or check may have stopped because the caller cancelled.
|
|
46
|
+
signal.throwIfAborted()
|
|
47
|
+
return { browser: await launch(softwareArgs), graphics: { hardwareAccelerated: false, fallbackReason } }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Returns the GPU renderer when hardware rendering is verified, otherwise why it was not.
|
|
51
|
+
async function inspectGraphics(browser, signal) {
|
|
52
|
+
try {
|
|
53
|
+
const { gpu } = await withDeadline(async () => {
|
|
54
|
+
const session = await browser.newBrowserCDPSession()
|
|
55
|
+
try {
|
|
56
|
+
return await session.send('SystemInfo.getInfo')
|
|
57
|
+
} finally {
|
|
58
|
+
// A rejected browser is closed next, so do not wait for the session.
|
|
59
|
+
session.detach().catch(() => {})
|
|
60
|
+
}
|
|
61
|
+
}, GRAPHICS_CHECK_TIMEOUT, new Error('Graphics check timed out.'), signal)
|
|
62
|
+
const compositing = gpu.featureStatus?.gpu_compositing
|
|
63
|
+
const renderer = gpu.auxAttributes?.glRenderer
|
|
64
|
+
if (!compositing || !renderer) return { fallbackReason: 'graphics-check-unavailable' }
|
|
65
|
+
if (!compositing.startsWith('enabled') || SOFTWARE_RENDERER.test(renderer)) return { fallbackReason: 'software-rendering' }
|
|
66
|
+
return { renderer }
|
|
67
|
+
} catch {
|
|
68
|
+
return { fallbackReason: 'graphics-check-unavailable' }
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/capture.mjs
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
import { addAbortListener } from 'node:events'
|
|
2
1
|
import { writeFile } from 'node:fs/promises'
|
|
3
2
|
import { join } from 'node:path'
|
|
4
3
|
import { setTimeout as delay } from 'node:timers/promises'
|
|
5
|
-
import { chromium } from 'playwright'
|
|
6
4
|
|
|
7
5
|
import { TraceframeError } from './errors.mjs'
|
|
8
6
|
import { addDiagnostics, retainedFile } from './diagnostics.mjs'
|
|
9
7
|
import { createFlowApi } from './flow.mjs'
|
|
10
8
|
import { createCaptureStats } from './capture-stats.mjs'
|
|
11
9
|
import { createJpegCapture } from './jpeg-capture.mjs'
|
|
10
|
+
import { launchBrowser } from './browser.mjs'
|
|
11
|
+
import { raceAbort, withDeadline } from './deadline.mjs'
|
|
12
12
|
|
|
13
13
|
const PLAYWRIGHT_DEFAULT_TIMEOUT = 30_000
|
|
14
14
|
const FIRST_FRAME_TIMEOUT = 10_000
|
|
15
15
|
const FAILURE_SCREENSHOT_TIMEOUT = 5_000
|
|
16
|
+
// Playwright uses the screencast fps only to let Chromium have 10 frames in flight above 30,
|
|
17
|
+
// otherwise 5. Export sets the output rate, so 30 FPS output keeps the larger allowance: with 5,
|
|
18
|
+
// a busy machine captured fewer pictures without saving any work.
|
|
19
|
+
const SCREENCAST_FPS = 60
|
|
16
20
|
|
|
17
21
|
export async function captureFlow({ config, flow, directory, page, signal: callerSignal }) {
|
|
18
22
|
const captureErrors = new AbortController()
|
|
@@ -22,7 +26,7 @@ export async function captureFlow({ config, flow, directory, page, signal: calle
|
|
|
22
26
|
const stats = config.captureDiagnostics ? createCaptureStats() : undefined
|
|
23
27
|
|
|
24
28
|
signal.throwIfAborted()
|
|
25
|
-
const browser = page ?
|
|
29
|
+
const { browser, graphics } = page ? {} : await launchBrowser(config, { handleSignals: !callerSignal, signal })
|
|
26
30
|
let clock
|
|
27
31
|
let recording = false
|
|
28
32
|
let failed = false
|
|
@@ -33,7 +37,10 @@ export async function captureFlow({ config, flow, directory, page, signal: calle
|
|
|
33
37
|
}
|
|
34
38
|
try {
|
|
35
39
|
if (browser) {
|
|
40
|
+
// A browser launch cannot be interrupted, so check before creating the page and again before navigation.
|
|
41
|
+
signal.throwIfAborted()
|
|
36
42
|
page = await browser.newPage({ viewport: config.viewport, deviceScaleFactor: config.scale, storageState: config.storageState })
|
|
43
|
+
signal.throwIfAborted()
|
|
37
44
|
await raceAbort(waitForReady(page, flow, config, signal), signal)
|
|
38
45
|
signal.throwIfAborted()
|
|
39
46
|
await page.mouse.move(trace.initialPointer.x, trace.initialPointer.y)
|
|
@@ -44,9 +51,18 @@ export async function captureFlow({ config, flow, directory, page, signal: calle
|
|
|
44
51
|
const firstFrame = Promise.withResolvers()
|
|
45
52
|
signal.throwIfAborted()
|
|
46
53
|
capture = createJpegCapture(rawPath, { ...config.video, onError: cause => captureErrors.abort(new TraceframeError(`Could not save capture: ${cause.message}`, { code: 'CAPTURE_FAILED', cause })) })
|
|
47
|
-
|
|
54
|
+
let firstSize
|
|
55
|
+
await page.screencast.start({ size: { width: config.video.width, height: config.video.height }, fps: SCREENCAST_FPS, quality: config.captureQuality, onFrame: (frame) => {
|
|
48
56
|
stats?.observe(frame.timestamp, performance.now())
|
|
49
57
|
firstFrame.resolve(frame)
|
|
58
|
+
// The first frame is checked against the request below. A later size change would
|
|
59
|
+
// otherwise surface only after export, as a truncated video.
|
|
60
|
+
const size = frameSize(frame)
|
|
61
|
+
firstSize ??= size
|
|
62
|
+
if (!sameSize(size, firstSize)) {
|
|
63
|
+
captureErrors.abort(new TraceframeError(`Screencast frames changed size during recording, from ${describeSize(firstSize)} to ${describeSize(size)}. Keep the page geometry fixed while recording.`, { code: 'CAPTURE_SIZE_CHANGED' }))
|
|
64
|
+
return
|
|
65
|
+
}
|
|
50
66
|
return capture.append(frame)
|
|
51
67
|
} })
|
|
52
68
|
recording = true
|
|
@@ -79,7 +95,7 @@ export async function captureFlow({ config, flow, directory, page, signal: calle
|
|
|
79
95
|
// Stop the page subscription only once, including when shutdown fails.
|
|
80
96
|
recording = false
|
|
81
97
|
await stopCapture()
|
|
82
|
-
return { rawPath, trace, startedAt: clock, ...(stats ? { captureStats: { ...stats.summary(), ...capture.stats } } : {}) }
|
|
98
|
+
return { rawPath, trace, startedAt: clock, graphics, ...(stats ? { captureStats: { ...stats.summary(), ...capture.stats } } : {}) }
|
|
83
99
|
} catch (error) {
|
|
84
100
|
failed = true
|
|
85
101
|
// A writer failure during cleanup must not replace the initiating failure.
|
|
@@ -91,7 +107,8 @@ export async function captureFlow({ config, flow, directory, page, signal: calle
|
|
|
91
107
|
.then(() => capture?.stop())
|
|
92
108
|
.then(() => clock !== undefined && config.runDirectory ? retainedFile(rawPath) : undefined)
|
|
93
109
|
.catch(() => undefined),
|
|
94
|
-
|
|
110
|
+
// A frame size change or a capture write error also aborts signal; only caller cancellation skips this.
|
|
111
|
+
config.runDirectory && page && !callerSignal?.aborted ? saveFailureScreenshot(page, directory, callerSignal) : undefined,
|
|
95
112
|
])
|
|
96
113
|
if (callerSignal?.aborted) throw callerSignal.reason
|
|
97
114
|
throw await addDiagnostics(failure, { flow: config.flow, recordingTime, capturePath, screenshotPath })
|
|
@@ -117,13 +134,27 @@ async function assertPageGeometry(page, config, signal) {
|
|
|
117
134
|
}
|
|
118
135
|
}
|
|
119
136
|
|
|
137
|
+
// [JPEG width, JPEG height, viewport width, viewport height]; the JPEG values are undefined if unreadable.
|
|
138
|
+
function frameSize(frame) {
|
|
139
|
+
const jpeg = jpegSize(frame.data)
|
|
140
|
+
return [jpeg?.width, jpeg?.height, frame.viewportWidth, frame.viewportHeight]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sameSize(a, b) {
|
|
144
|
+
return a.every((value, index) => value === b[index])
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function describeSize([width, height, viewportWidth, viewportHeight]) {
|
|
148
|
+
return `${width === undefined ? 'an unreadable JPEG' : `${width}×${height}`} for a ${viewportWidth}×${viewportHeight} viewport`
|
|
149
|
+
}
|
|
150
|
+
|
|
120
151
|
function assertFrameSize(frame, config) {
|
|
121
152
|
// Check the exported JPEG dimensions; deviceScaleFactor alone is insufficient.
|
|
122
|
-
const size =
|
|
123
|
-
|
|
124
|
-
|
|
153
|
+
const size = frameSize(frame)
|
|
154
|
+
const expected = [config.video.width, config.video.height, config.viewport.width, config.viewport.height]
|
|
155
|
+
if (!sameSize(size, expected)) {
|
|
125
156
|
const scaleHint = config.scale === 2 ? ' For scale 2, launch Chromium with --force-device-scale-factor=2; deviceScaleFactor alone is insufficient.' : ''
|
|
126
|
-
throw new TraceframeError(`Screencast frames are ${size
|
|
157
|
+
throw new TraceframeError(`Screencast frames are ${describeSize(size)}; expected ${describeSize(expected)}. Keep the viewport fixed and use the page exclusively for this recording.${scaleHint}`, { code: 'CAPTURE_SIZE_MISMATCH' })
|
|
127
158
|
}
|
|
128
159
|
}
|
|
129
160
|
|
|
@@ -155,24 +186,6 @@ async function saveFailureScreenshot(page, directory, signal) {
|
|
|
155
186
|
}
|
|
156
187
|
}
|
|
157
188
|
|
|
158
|
-
async function launchBrowser(config, { handleSignals }) {
|
|
159
|
-
try {
|
|
160
|
-
return await chromium.launch({
|
|
161
|
-
executablePath: config.browser,
|
|
162
|
-
args: config.scale === 2 ? ['--force-device-scale-factor=2'] : [],
|
|
163
|
-
handleSIGINT: handleSignals,
|
|
164
|
-
handleSIGTERM: handleSignals,
|
|
165
|
-
handleSIGHUP: handleSignals,
|
|
166
|
-
})
|
|
167
|
-
} catch (error) {
|
|
168
|
-
if (!/Executable doesn't exist/i.test(error.message)) throw error
|
|
169
|
-
const message = config.browser
|
|
170
|
-
? `Configured browser executable is unavailable: ${config.browser}`
|
|
171
|
-
: 'Playwright Chromium is not installed. Run `npx playwright@1.64.1 install --only-shell chromium`, or pass --browser to a Chrome executable.'
|
|
172
|
-
throw new TraceframeError(message, { code: 'PLAYWRIGHT_RUNTIME_MISSING', cause: error })
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
189
|
async function waitForReady(page, flow, config, signal) {
|
|
177
190
|
try {
|
|
178
191
|
page.setDefaultTimeout(config.readyTimeout * 1000)
|
|
@@ -184,26 +197,3 @@ async function waitForReady(page, flow, config, signal) {
|
|
|
184
197
|
throw new TraceframeError(`Ready check failed: ${error instanceof Error ? error.message : error}`, { code: 'READY_FAILED', cause: error })
|
|
185
198
|
}
|
|
186
199
|
}
|
|
187
|
-
|
|
188
|
-
// Settles with the task, or rejects at the deadline or on abort, even if the task ignores its signal.
|
|
189
|
-
async function withDeadline(task, milliseconds, error, signal) {
|
|
190
|
-
const controller = new AbortController()
|
|
191
|
-
const timer = setTimeout(() => controller.abort(error), milliseconds)
|
|
192
|
-
const deadline = AbortSignal.any([signal, controller.signal])
|
|
193
|
-
try {
|
|
194
|
-
deadline.throwIfAborted()
|
|
195
|
-
return await raceAbort(task(deadline), deadline)
|
|
196
|
-
} finally {
|
|
197
|
-
clearTimeout(timer)
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function raceAbort(promise, signal) {
|
|
202
|
-
const aborted = Promise.withResolvers()
|
|
203
|
-
const listener = addAbortListener(signal, () => aborted.reject(signal.reason))
|
|
204
|
-
try {
|
|
205
|
-
return await Promise.race([promise, aborted.promise])
|
|
206
|
-
} finally {
|
|
207
|
-
listener[Symbol.dispose]()
|
|
208
|
-
}
|
|
209
|
-
}
|
package/src/cli.mjs
CHANGED
|
@@ -15,6 +15,7 @@ const OPTION_KEYS = {
|
|
|
15
15
|
out: 'output',
|
|
16
16
|
preset: 'preset',
|
|
17
17
|
browser: 'browser',
|
|
18
|
+
gpu: 'gpu',
|
|
18
19
|
'storage-state': 'storageState',
|
|
19
20
|
'viewport-width': 'viewportWidth',
|
|
20
21
|
'viewport-height': 'viewportHeight',
|
|
@@ -41,12 +42,13 @@ Options:
|
|
|
41
42
|
--preset <standard|draft> standard: 1280x800 CSS viewport, 2560x1600 at 60 FPS (default)
|
|
42
43
|
draft: 1280x800 CSS viewport, 1280x800 at 30 FPS
|
|
43
44
|
--browser <path> Chromium executable (or set CHROME_PATH)
|
|
45
|
+
--gpu <auto|off> Verified GPU with software fallback (default), or software only
|
|
44
46
|
--storage-state <path> Playwright storage state JSON
|
|
45
47
|
--viewport-width <pixels> CSS viewport width
|
|
46
48
|
--viewport-height <pixels> CSS viewport height
|
|
47
49
|
--scale <1|2> Device scale factor; the video is the viewport times this
|
|
48
50
|
--fps <30|60> Encoded frame rate
|
|
49
|
-
--capture-quality <1–100> JPEG frame quality (default:
|
|
51
|
+
--capture-quality <1–100> JPEG frame quality (default: 85); higher keeps more detail
|
|
50
52
|
--capture-diagnostics Include frame delivery cadence and gaps in the JSON summary
|
|
51
53
|
--post-roll <seconds> Tail after the flow (default: 0.3)
|
|
52
54
|
--ready-timeout <seconds> Playwright default timeout during navigation and ready() (default: 30)
|
package/src/config.mjs
CHANGED
|
@@ -20,6 +20,8 @@ export function createConfig(options = {}) {
|
|
|
20
20
|
check(options.url, 'A URL is required (the url option, or --url).')
|
|
21
21
|
const readyTimeout = options.readyTimeout ?? 30
|
|
22
22
|
check(Number.isFinite(readyTimeout) && readyTimeout > 0, 'Ready timeout must be more than 0 seconds.')
|
|
23
|
+
const gpu = options.gpu ?? 'auto'
|
|
24
|
+
check(['auto', 'off'].includes(gpu), 'GPU must be auto or off.')
|
|
23
25
|
|
|
24
26
|
return {
|
|
25
27
|
...recordingConfig(options, viewport, preset),
|
|
@@ -27,6 +29,7 @@ export function createConfig(options = {}) {
|
|
|
27
29
|
flow: resolve(options.flow),
|
|
28
30
|
storageState: options.storageState,
|
|
29
31
|
browser: options.browser ?? process.env.CHROME_PATH,
|
|
32
|
+
gpu,
|
|
30
33
|
readyTimeout,
|
|
31
34
|
}
|
|
32
35
|
}
|
|
@@ -37,7 +40,7 @@ export function createPageConfig(options = {}) {
|
|
|
37
40
|
check(!page.isClosed(), 'The supplied page is closed.')
|
|
38
41
|
const viewport = page.viewportSize()
|
|
39
42
|
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']) {
|
|
43
|
+
for (const key of ['url', 'browser', 'gpu', 'storageState', 'readyTimeout', 'viewportWidth', 'viewportHeight', 'preset']) {
|
|
41
44
|
check(options[key] === undefined, `recordPage does not accept ${key}; prepare the page before recording and use scale/fps for video settings.`)
|
|
42
45
|
}
|
|
43
46
|
return recordingConfig(options, viewport, { scale: 1, fps: 30 })
|
|
@@ -54,7 +57,7 @@ function recordingConfig(options, viewport, defaults) {
|
|
|
54
57
|
fps: options.fps ?? defaults.fps,
|
|
55
58
|
}
|
|
56
59
|
check(video.fps === 30 || video.fps === 60, 'FPS must be 30 or 60.')
|
|
57
|
-
const captureQuality = options.captureQuality ??
|
|
60
|
+
const captureQuality = options.captureQuality ?? 85
|
|
58
61
|
check(Number.isInteger(captureQuality) && captureQuality >= 1 && captureQuality <= 100, 'Capture quality must be an integer from 1 to 100.')
|
|
59
62
|
check(options.captureDiagnostics === undefined || typeof options.captureDiagnostics === 'boolean', 'Capture diagnostics must be a boolean.')
|
|
60
63
|
check(video.width % 2 === 0 && video.height % 2 === 0, 'At scale 1, the viewport width and height must be even.')
|
package/src/deadline.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { addAbortListener } from 'node:events'
|
|
2
|
+
|
|
3
|
+
// Settles with the task, or rejects at the deadline or on abort, even if the task ignores its signal.
|
|
4
|
+
export async function withDeadline(task, milliseconds, error, signal) {
|
|
5
|
+
const controller = new AbortController()
|
|
6
|
+
const timer = setTimeout(() => controller.abort(error), milliseconds)
|
|
7
|
+
const deadline = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
|
|
8
|
+
try {
|
|
9
|
+
deadline.throwIfAborted()
|
|
10
|
+
return await raceAbort(task(deadline), deadline)
|
|
11
|
+
} finally {
|
|
12
|
+
clearTimeout(timer)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function raceAbort(promise, signal) {
|
|
17
|
+
const aborted = Promise.withResolvers()
|
|
18
|
+
const listener = addAbortListener(signal, () => aborted.reject(signal.reason))
|
|
19
|
+
try {
|
|
20
|
+
return await Promise.race([promise, aborted.promise])
|
|
21
|
+
} finally {
|
|
22
|
+
listener[Symbol.dispose]()
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/record.mjs
CHANGED
|
@@ -65,6 +65,7 @@ async function recordFlow({ config, flow, ffmpeg, signal, page }) {
|
|
|
65
65
|
duration,
|
|
66
66
|
moveCount: trace.moves.length,
|
|
67
67
|
clickCount: trace.clicks.length,
|
|
68
|
+
...(capture.graphics ? { graphics: capture.graphics } : {}),
|
|
68
69
|
runDirectory: config.runDirectory,
|
|
69
70
|
...(audio ? { audio: true } : {}),
|
|
70
71
|
...(capture.captureStats ? { captureStats: capture.captureStats } : {}),
|