@milkstraw/traceframe 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,13 +8,13 @@ https://github.com/user-attachments/assets/38e02cd5-a9ba-4f3c-938c-159955353141
8
8
 
9
9
  ## Prerequisites
10
10
 
11
- Traceframe requires Node.js 22+ and system FFmpeg 5+ with `libx264` and `ffprobe` to render the MP4 (for example, `brew install ffmpeg` or `sudo apt install ffmpeg`). Playwright uses its own Chromium and FFmpeg builds to record the page. Install them with the exact Playwright version Traceframe uses; other cached versions are not used:
11
+ Traceframe requires Node.js 22+ and system FFmpeg 5+ with `libx264` and `ffprobe` to render the MP4 (for example, `brew install ffmpeg` or `sudo apt install ffmpeg`). Install Chromium with the exact Playwright version Traceframe uses; other cached versions are not used:
12
12
 
13
13
  ```sh
14
- npx playwright@1.64.1 install --only-shell chromium ffmpeg
14
+ npx playwright@1.64.1 install --only-shell chromium
15
15
  ```
16
16
 
17
- To use system Chrome 150 or later, pass `--browser` or set `CHROME_PATH`, and install Playwright's recording FFmpeg with `npx playwright@1.64.1 install ffmpeg`. Older Chrome versions can fail to scroll targets inside panels.
17
+ To use system Chrome 150 or later, pass `--browser` or set `CHROME_PATH`. Older Chrome versions can fail to scroll targets inside panels. Recording uses Playwright's frame callback and does not require its separate FFmpeg executable.
18
18
 
19
19
  In a sandbox, such as a coding agent's, allow Playwright's browser download and let Chromium start.
20
20
 
@@ -59,7 +59,19 @@ Both presets use a 1280×800 CSS viewport. `draft` produces 1280×800 at 30 FPS;
59
59
 
60
60
  The cursor and click pulses are always drawn at the full frame rate. Chromium may capture page changes, such as scrolling and animations, at a lower rate at 2560×1600, depending on the machine. The video repeats the last page frame until the next one arrives.
61
61
 
62
- Use `--run-dir .traceframe/runs/name` (`runDirectory` in the API) to keep the raw capture and FFmpeg filter file, including a partial capture if the flow fails during recording. On a preparation or recording failure, Traceframe also tries to save a viewport screenshot as `failure.png`. Keep that directory out of Git, because captures and screenshots can contain private page data.
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
+
64
+ `--capture-quality <1–100>` (`captureQuality` in either API) controls the JPEG frames exported by Chromium; the default remains 95. A lower setting such as 80 can reduce frame-export and transfer cost, with a loss of image detail. This is separate from the final MP4 encoder. Check representative moving content and text at the intended resolution before choosing it.
65
+
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
+
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 millisecond 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. Moving content needs more temporary disk space than an inter-frame codec: at high resolution, allow hundreds of megabytes for short takes and potentially gigabytes for longer ones. Static pages need only a few stored frames. If your temporary directory is backed by RAM, choose a disk-backed `--run-dir` for large takes and remove it afterward. The final renderer supplies the requested frame rate before drawing the cursor.
69
+
70
+ GPU rendering and hardware video decoding can help the browser, but frame export still costs CPU. The bundled headless browser used software rendering on the tested Linux setup; selecting an executable or adding a GPU flag does not guarantee acceleration. For a caller-owned browser, configure and verify its GPU use before `recordPage`; use full Chromium for extensions. A GPU capability flag alone does not establish that the active video is hardware-decoded. When measuring performance, keep the browser version, source codec, viewport and device scale fixed, and compare recorded image updates and audio timing as well as file metadata.
71
+
72
+ 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; choose a lower quality or smaller dimensions explicitly if that tradeoff suits your recording.
73
+
74
+ Use `--run-dir .traceframe/runs/name` (`runDirectory` in the API) to keep the raw capture and FFmpeg filter file, including a partial capture if the flow fails during recording. An interrupted or failed capture can be incomplete; a retained file is not proof that recording finalized successfully. On a preparation or recording failure, Traceframe also tries to save a viewport screenshot as `failure.png`. Keep that directory out of Git, because captures and screenshots can contain private page data.
63
75
 
64
76
  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).
65
77
 
@@ -178,9 +190,9 @@ For 2× frames, launch the caller's browser with `chromium.launch({ args: ['--fo
178
190
 
179
191
  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
192
 
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.
193
+ 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.
182
194
 
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.
195
+ Traceframe removes its temporary capture unless `runDirectory` is supplied. 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
196
 
185
197
  ### Add audio
186
198
 
@@ -195,7 +207,7 @@ npx @milkstraw/traceframe record --url http://localhost:3000 --flow ./flow.mjs \
195
207
 
196
208
  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
209
 
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.
210
+ Traceframe reads the file without modifying or deleting it; use an input path different from the output and the reserved files in `runDirectory` (`capture.mkv`, legacy `capture.webm`, `render.filter`, `failure.png`). Keep recordings in an ignored directory.
199
211
 
200
212
  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
213
 
@@ -228,11 +240,11 @@ When available, `error.diagnostics` contains:
228
240
  | `capturePath` | Absolute path to the retained raw capture, only when `runDirectory` was supplied and saving succeeded. |
229
241
  | `screenshotPath` | Absolute path to the saved failure screenshot, only when `runDirectory` was supplied and the screenshot succeeded. |
230
242
 
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.
243
+ 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 flushed before browser shutdown, with no fixed time limit so slower storage can finish. Diagnostic failures do not replace the original error.
232
244
 
233
245
  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.
234
246
 
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.
247
+ A stalled storage device can delay flushing and cancellation. A second Ctrl-C forces the CLI to exit and can discard unfinished writes. API callers that need a hard shutdown deadline should run recording in a separate process they can terminate.
236
248
 
237
249
  ## Sample output
238
250
 
@@ -160,7 +160,7 @@ The two `Emulation` calls set the locale that `navigator.language`, `Intl`, and
160
160
 
161
161
  Use `--preset draft` while writing the flow and the default preset for the final video.
162
162
 
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.
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.mkv` when available and attempts a viewport screenshot, `failure.png`. An interrupted capture may be incomplete. The raw capture has no added cursor overlay and may include repeated final frames after the failure time.
164
164
 
165
165
  A failed take may already have submitted real jobs, so reset demo data before recording again. Check the finished file for decode errors:
166
166
 
@@ -200,7 +200,7 @@ The CLI keeps the original error message and adds available context on standard
200
200
  traceframe: [FLOW_FAILED] Expected the Saved status.
201
201
  Flow: /project/review.mjs:18:9
202
202
  Recording time: 12.35 s
203
- Capture: /project/.traceframe/runs/demo/capture.webm
203
+ Capture: /project/.traceframe/runs/demo/capture.mkv
204
204
  Screenshot: /project/.traceframe/runs/demo/failure.png
205
205
  ```
206
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milkstraw/traceframe",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Record scripted browser flows and render smooth cursor overlays into video.",
5
5
  "keywords": [
6
6
  "playwright",
@@ -0,0 +1,33 @@
1
+ // Delivery cadence describes Chromium's exported frames. It cannot establish
2
+ // distinct pixels or smoothness: a static page legitimately sends few frames.
3
+ export function createCaptureStats() {
4
+ let framesReceived = 0, earliestTimestamp, latestTimestamp, lastDelivery
5
+ let maxFrameGapMs = 0, maxDeliveryGapMs = 0, outOfOrderFrames = 0
6
+ return {
7
+ observe(timestamp, delivery) {
8
+ if (framesReceived === 0) earliestTimestamp = latestTimestamp = timestamp
9
+ else {
10
+ earliestTimestamp = Math.min(earliestTimestamp, timestamp)
11
+ if (timestamp < latestTimestamp) outOfOrderFrames++
12
+ else {
13
+ maxFrameGapMs = Math.max(maxFrameGapMs, timestamp - latestTimestamp)
14
+ latestTimestamp = timestamp
15
+ }
16
+ maxDeliveryGapMs = Math.max(maxDeliveryGapMs, delivery - lastDelivery)
17
+ }
18
+ lastDelivery = delivery
19
+ framesReceived++
20
+ },
21
+ summary() {
22
+ const sourceSpanSeconds = framesReceived > 1 ? (latestTimestamp - earliestTimestamp) / 1000 : 0
23
+ return {
24
+ framesReceived,
25
+ sourceSpanSeconds,
26
+ receivedFps: sourceSpanSeconds > 0 ? (framesReceived - 1) / sourceSpanSeconds : 0,
27
+ maxFrameGapMs,
28
+ maxDeliveryGapMs,
29
+ outOfOrderFrames,
30
+ }
31
+ },
32
+ }
33
+ }
package/src/capture.mjs CHANGED
@@ -7,21 +7,30 @@ import { chromium } from 'playwright'
7
7
  import { TraceframeError } from './errors.mjs'
8
8
  import { addDiagnostics, retainedFile } from './diagnostics.mjs'
9
9
  import { createFlowApi } from './flow.mjs'
10
+ import { createCaptureStats } from './capture-stats.mjs'
11
+ import { createJpegCapture } from './jpeg-capture.mjs'
10
12
 
11
13
  const PLAYWRIGHT_DEFAULT_TIMEOUT = 30_000
12
14
  const FIRST_FRAME_TIMEOUT = 10_000
13
15
  const FAILURE_SCREENSHOT_TIMEOUT = 5_000
14
16
 
15
17
  export async function captureFlow({ config, flow, directory, page, signal: callerSignal }) {
16
- const signal = callerSignal ?? new AbortController().signal
17
- const rawPath = join(directory, 'capture.webm')
18
+ const captureErrors = new AbortController()
19
+ const signal = callerSignal ? AbortSignal.any([callerSignal, captureErrors.signal]) : captureErrors.signal
20
+ const rawPath = join(directory, 'capture.mkv')
18
21
  const trace = { initialPointer: flow.initialPointer, moves: [], clicks: [] }
22
+ const stats = config.captureDiagnostics ? createCaptureStats() : undefined
19
23
 
20
24
  signal.throwIfAborted()
21
25
  const browser = page ? undefined : await launchBrowser(config, { handleSignals: !callerSignal })
22
26
  let clock
23
27
  let recording = false
24
28
  let failed = false
29
+ let capture
30
+ async function stopCapture() {
31
+ const stoppedAt = Date.now()
32
+ try { await page.screencast.stop() } finally { await capture?.stop(stoppedAt) }
33
+ }
25
34
  try {
26
35
  if (browser) {
27
36
  page = await browser.newPage({ viewport: config.viewport, deviceScaleFactor: config.scale, storageState: config.storageState })
@@ -34,7 +43,12 @@ export async function captureFlow({ config, flow, directory, page, signal: calle
34
43
 
35
44
  const firstFrame = Promise.withResolvers()
36
45
  signal.throwIfAborted()
37
- await startScreencast(page, rawPath, config, firstFrame.resolve)
46
+ capture = createJpegCapture(rawPath, { ...config.video, onError: cause => captureErrors.abort(new TraceframeError(`Could not save capture: ${cause.message}`, { code: 'CAPTURE_FAILED', cause })) })
47
+ await page.screencast.start({ size: { width: config.video.width, height: config.video.height }, fps: config.video.fps, quality: config.captureQuality, onFrame: (frame) => {
48
+ stats?.observe(frame.timestamp, performance.now())
49
+ firstFrame.resolve(frame)
50
+ return capture.append(frame)
51
+ } })
38
52
  recording = true
39
53
  const frame = await withDeadline(
40
54
  () => firstFrame.promise,
@@ -43,8 +57,8 @@ export async function captureFlow({ config, flow, directory, page, signal: calle
43
57
  signal,
44
58
  )
45
59
  clock = frame.timestamp
60
+ assertFrameSize(frame, config)
46
61
  if (!browser) {
47
- assertFrameSize(frame, config)
48
62
  signal.throwIfAborted()
49
63
  // Rejected caller-page attempts must leave the caller's pointer alone.
50
64
  await page.mouse.move(trace.initialPointer.x, trace.initialPointer.y)
@@ -62,23 +76,27 @@ export async function captureFlow({ config, flow, directory, page, signal: calle
62
76
  }
63
77
  trace.end = (Date.now() - clock) / 1000
64
78
  await delay(config.postRoll * 1000, undefined, { signal })
65
- // A failed stop must not be retried: Playwright may still be waiting to finish its artifact.
79
+ // Stop the page subscription only once, including when shutdown fails.
66
80
  recording = false
67
- await page.screencast.stop()
68
- return { rawPath, trace, startedAt: clock }
81
+ await stopCapture()
82
+ return { rawPath, trace, startedAt: clock, ...(stats ? { captureStats: { ...stats.summary(), ...capture.stats } } : {}) }
69
83
  } catch (error) {
70
84
  failed = true
85
+ // A writer failure during cleanup must not replace the initiating failure.
86
+ const failure = signal.aborted ? signal.reason : error
71
87
  const recordingTime = clock === undefined ? undefined : Math.max(0, (Date.now() - clock) / 1000)
72
- // Let the encoder finish saving before closing the browser, including on slower machines.
88
+ // Flush captured frames before closing the browser, including on slower disks.
73
89
  const [capturePath, screenshotPath] = await Promise.all([
74
- recording ? page.screencast.stop()
75
- .then(() => config.runDirectory ? retainedFile(rawPath) : undefined)
76
- .catch(() => undefined) : undefined,
90
+ (recording ? stopCapture().catch(() => {}) : Promise.resolve())
91
+ .then(() => capture?.stop())
92
+ .then(() => clock !== undefined && config.runDirectory ? retainedFile(rawPath) : undefined)
93
+ .catch(() => undefined),
77
94
  config.runDirectory && page && !signal.aborted ? saveFailureScreenshot(page, directory, signal) : undefined,
78
95
  ])
79
- if (signal.aborted) throw error
80
- throw await addDiagnostics(error, { flow: config.flow, recordingTime, capturePath, screenshotPath })
96
+ if (callerSignal?.aborted) throw callerSignal.reason
97
+ throw await addDiagnostics(failure, { flow: config.flow, recordingTime, capturePath, screenshotPath })
81
98
  } finally {
99
+ await capture?.stop().catch(() => {})
82
100
  // A shutdown error must not hide the flow failure.
83
101
  if (browser) {
84
102
  if (failed) await browser.close().catch(() => {})
@@ -100,7 +118,7 @@ async function assertPageGeometry(page, config, signal) {
100
118
  }
101
119
 
102
120
  function assertFrameSize(frame, config) {
103
- // Playwright may pad the saved WebM. Check the actual JPEG, not DPR or the container size.
121
+ // Check the exported JPEG dimensions; deviceScaleFactor alone is insufficient.
104
122
  const size = jpegSize(frame.data)
105
123
  if (size?.width !== config.video.width || size?.height !== config.video.height ||
106
124
  frame.viewportWidth !== config.viewport.width || frame.viewportHeight !== config.viewport.height) {
@@ -150,7 +168,7 @@ async function launchBrowser(config, { handleSignals }) {
150
168
  if (!/Executable doesn't exist/i.test(error.message)) throw error
151
169
  const message = config.browser
152
170
  ? `Configured browser executable is unavailable: ${config.browser}`
153
- : 'Playwright Chromium is not installed. Run `npx playwright@1.64.1 install --only-shell chromium`, or pass --browser to a Chrome executable and run `npx playwright@1.64.1 install ffmpeg`.'
171
+ : 'Playwright Chromium is not installed. Run `npx playwright@1.64.1 install --only-shell chromium`, or pass --browser to a Chrome executable.'
154
172
  throw new TraceframeError(message, { code: 'PLAYWRIGHT_RUNTIME_MISSING', cause: error })
155
173
  }
156
174
  }
@@ -167,17 +185,6 @@ async function waitForReady(page, flow, config, signal) {
167
185
  }
168
186
  }
169
187
 
170
- async function startScreencast(page, path, config, onFrame) {
171
- try {
172
- await page.screencast.start({ path, size: { width: config.video.width, height: config.video.height }, fps: config.video.fps, quality: 95, onFrame })
173
- } catch (error) {
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(() => {})
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 })
178
- }
179
- }
180
-
181
188
  // Settles with the task, or rejects at the deadline or on abort, even if the task ignores its signal.
182
189
  async function withDeadline(task, milliseconds, error, signal) {
183
190
  const controller = new AbortController()
package/src/cli.mjs CHANGED
@@ -20,6 +20,8 @@ const OPTION_KEYS = {
20
20
  'viewport-height': 'viewportHeight',
21
21
  scale: 'scale',
22
22
  fps: 'fps',
23
+ 'capture-quality': 'captureQuality',
24
+ 'capture-diagnostics': 'captureDiagnostics',
23
25
  'post-roll': 'postRoll',
24
26
  'ready-timeout': 'readyTimeout',
25
27
  'flow-timeout': 'flowTimeout',
@@ -27,7 +29,7 @@ const OPTION_KEYS = {
27
29
  audio: 'audio',
28
30
  'audio-offset': 'audioOffset',
29
31
  }
30
- const NUMBER_KEYS = new Set(['viewportWidth', 'viewportHeight', 'scale', 'fps', 'postRoll', 'readyTimeout', 'flowTimeout', 'audioOffset'])
32
+ const NUMBER_KEYS = new Set(['viewportWidth', 'viewportHeight', 'scale', 'fps', 'captureQuality', 'postRoll', 'readyTimeout', 'flowTimeout', 'audioOffset'])
31
33
  const HELP = `traceframe ${VERSION}
32
34
 
33
35
  Record a scripted Chromium flow and render a smooth cursor and click pulse.
@@ -44,6 +46,8 @@ Options:
44
46
  --viewport-height <pixels> CSS viewport height
45
47
  --scale <1|2> Device scale factor; the video is the viewport times this
46
48
  --fps <30|60> Encoded frame rate
49
+ --capture-quality <1–100> JPEG frame quality (default: 95); lower can reduce capture cost
50
+ --capture-diagnostics Include frame delivery cadence and gaps in the JSON summary
47
51
  --post-roll <seconds> Tail after the flow (default: 0.3)
48
52
  --ready-timeout <seconds> Playwright default timeout during navigation and ready() (default: 30)
49
53
  --flow-timeout <seconds> Time limit for the flow, including waits (default: 120)
@@ -99,7 +103,7 @@ function parseCommand(args) {
99
103
  options: {
100
104
  help: { type: 'boolean', short: 'h' },
101
105
  version: { type: 'boolean', short: 'v' },
102
- ...Object.fromEntries(Object.keys(OPTION_KEYS).map((name) => [name, { type: 'string' }])),
106
+ ...Object.fromEntries(Object.keys(OPTION_KEYS).map((name) => [name, { type: name === 'capture-diagnostics' ? 'boolean' : 'string' }])),
103
107
  },
104
108
  })
105
109
  if (values.help || args.length === 0) return { help: true }
package/src/config.mjs CHANGED
@@ -54,6 +54,9 @@ function recordingConfig(options, viewport, defaults) {
54
54
  fps: options.fps ?? defaults.fps,
55
55
  }
56
56
  check(video.fps === 30 || video.fps === 60, 'FPS must be 30 or 60.')
57
+ const captureQuality = options.captureQuality ?? 95
58
+ check(Number.isInteger(captureQuality) && captureQuality >= 1 && captureQuality <= 100, 'Capture quality must be an integer from 1 to 100.')
59
+ check(options.captureDiagnostics === undefined || typeof options.captureDiagnostics === 'boolean', 'Capture diagnostics must be a boolean.')
57
60
  check(video.width % 2 === 0 && video.height % 2 === 0, 'At scale 1, the viewport width and height must be even.')
58
61
 
59
62
  const output = resolve(options.output ?? 'traceframe.mp4')
@@ -69,6 +72,8 @@ function recordingConfig(options, viewport, defaults) {
69
72
  audio: typeof options.audio === 'function' ? options.audio : createAudioConfig(options.audio, output, runDirectory),
70
73
  viewport: { ...viewport },
71
74
  video,
75
+ captureQuality,
76
+ captureDiagnostics: options.captureDiagnostics ?? false,
72
77
  scale,
73
78
  postRoll,
74
79
  flowTimeout,
@@ -82,7 +87,7 @@ export function createAudioConfig(audio, output, directory) {
82
87
  const path = resolve(audio.path)
83
88
  check(path !== output, 'Audio input and video output must use different paths.')
84
89
  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}`)
90
+ check(!['capture.mkv', 'capture.webm', 'render.filter', 'failure.png'].some((name) => path === resolve(directory, name)), `Audio input must not use a reserved recording file: ${path}`)
86
91
  }
87
92
  const offset = audio.offset ?? 0
88
93
  check(Number.isFinite(offset), 'Audio offset must be a finite number of seconds.')
package/src/ffmpeg.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from 'node:child_process'
2
+ import { addAbortListener } from 'node:events'
2
3
  import { stat } from 'node:fs/promises'
3
4
  import { promisify } from 'node:util'
4
5
 
@@ -20,7 +21,11 @@ export async function probeFfmpeg({ audio = false } = {}) {
20
21
  throw new TraceframeError('System FFmpeg does not provide the AAC encoder required for audio.', { code: 'FFMPEG_UNSUPPORTED' })
21
22
  }
22
23
  await runCommand('ffprobe', ['-version'])
23
- return { filterComplexFileOption: filterComplexFileOption(major) }
24
+ return {
25
+ filterComplexFileOption: filterComplexFileOption(major),
26
+ // FFmpeg 7's pass mode cuts off padded main-video frames at the original EOF.
27
+ overlayEofAction: major === 7 ? 'repeat' : 'pass',
28
+ }
24
29
  }
25
30
 
26
31
  export async function probeAudio(path, signal) {
@@ -41,8 +46,19 @@ export async function probeVideoDuration(path, signal) {
41
46
  }
42
47
 
43
48
  export async function runCommand(command, args, options = {}) {
44
- const run = execFileAsync(command, args, options)
49
+ const windowsSignal = process.platform === 'win32' ? options.signal : undefined
50
+ windowsSignal?.throwIfAborted()
51
+ const run = execFileAsync(command, args, windowsSignal ? { ...options, signal: undefined } : options)
45
52
  const closed = new Promise((resolve) => run.child.once('close', resolve))
53
+ let termination
54
+ const abort = windowsSignal && addAbortListener(windowsSignal, () => {
55
+ // Chocolatey shims launch the real encoder as a child. Stop both before
56
+ // removing files, which Windows keeps locked while the encoder is alive.
57
+ if (run.child.pid && run.child.exitCode === null && run.child.signalCode === null) {
58
+ termination = execFileAsync('taskkill', ['/pid', String(run.child.pid), '/t', '/f'])
59
+ .catch(() => run.child.kill())
60
+ }
61
+ })
46
62
  try {
47
63
  return await run
48
64
  } catch (error) {
@@ -54,6 +70,9 @@ export async function runCommand(command, args, options = {}) {
54
70
  const rawDetail = error.stderr?.trim() || error.stdout?.trim() || error.message
55
71
  const detail = rawDetail.length > 2_000 ? `${rawDetail.slice(0, 1_900)}\n… output truncated` : rawDetail
56
72
  throw new TraceframeError(`${command} failed: ${detail}`, { code: 'COMMAND_FAILED', cause: error })
73
+ } finally {
74
+ abort?.[Symbol.dispose]()
75
+ await termination
57
76
  }
58
77
  }
59
78
 
@@ -0,0 +1,132 @@
1
+ import { createWriteStream } from 'node:fs'
2
+ import { open, rm } from 'node:fs/promises'
3
+ import { finished } from 'node:stream/promises'
4
+
5
+ // Keep Chromium's JPEGs and presentation timestamps; encode only during rendering.
6
+ // A short reorder window handles JPEGs that finish compression out of order.
7
+ const REORDER_MS = 150
8
+ const MAX_BUFFER_BYTES = 64 * 1024 * 1024
9
+
10
+ export function createJpegCapture(path, { width, height, fps, onError }) {
11
+ const output = createWriteStream(path)
12
+ let error, origin, newest = -Infinity, last, bufferedBytes = 0, draining, closing
13
+ const pending = []
14
+ const stats = { lateFrames: 0, overflowFrames: 0, maxBufferedBytes: 0, writeWaitMs: 0, maxWriteWaitMs: 0 }
15
+ function fail(cause) {
16
+ if (!error) { error = cause; onError(cause) }
17
+ }
18
+ output.on('error', fail)
19
+ const { bytes, durationOffset } = header(width, height, fps)
20
+ output.write(bytes)
21
+
22
+ async function write(frame) {
23
+ if (frame.timestamp < (last?.timestamp ?? origin)) { stats.lateFrames++; return }
24
+ const started = performance.now()
25
+ try {
26
+ await new Promise((resolve, reject) => {
27
+ output.cork()
28
+ output.write(cluster(Math.round(frame.timestamp - origin), frame.data.length))
29
+ output.write(frame.data, cause => cause ? reject(cause) : resolve())
30
+ output.uncork()
31
+ })
32
+ } finally {
33
+ const elapsed = performance.now() - started
34
+ stats.writeWaitMs += elapsed
35
+ stats.maxWriteWaitMs = Math.max(stats.maxWriteWaitMs, elapsed)
36
+ }
37
+ last = frame
38
+ }
39
+ async function flush(all = false) {
40
+ while (!error && pending.length && (all || pending[0].timestamp <= newest - REORDER_MS)) {
41
+ const frame = pending.shift()
42
+ try { await write(frame) } finally { bufferedBytes -= frame.data.length }
43
+ }
44
+ }
45
+ return {
46
+ stats,
47
+ append(frame) {
48
+ if (closing || error) return
49
+ origin ??= frame.timestamp
50
+ newest = Math.max(newest, frame.timestamp)
51
+ if (bufferedBytes + frame.data.length > MAX_BUFFER_BYTES) stats.overflowFrames++
52
+ else {
53
+ pending.push(frame)
54
+ pending.sort((a, b) => a.timestamp - b.timestamp)
55
+ bufferedBytes += frame.data.length
56
+ }
57
+ stats.maxBufferedBytes = Math.max(stats.maxBufferedBytes, bufferedBytes)
58
+ draining ??= flush().catch(fail).finally(() => { draining = undefined })
59
+ return draining
60
+ },
61
+ stop(stoppedAt = Date.now()) {
62
+ closing ??= (async () => {
63
+ try {
64
+ await draining
65
+ await flush(true)
66
+ if (error) throw error
67
+ // Preserve a static tail without encoding duplicate pictures live.
68
+ if (last && stoppedAt > last.timestamp) await write({ data: last.data, timestamp: stoppedAt })
69
+ output.end()
70
+ await finished(output)
71
+ if (last) {
72
+ const duration = Buffer.from('4489880000000000000000', 'hex')
73
+ duration.writeDoubleBE(Math.round(last.timestamp - origin) + 1000 / fps, 3)
74
+ const file = await open(path, 'r+')
75
+ try {
76
+ const { bytesWritten } = await file.write(duration, 0, duration.length, durationOffset)
77
+ if (bytesWritten !== duration.length) throw new Error('Could not finish the capture duration.')
78
+ } finally { await file.close() }
79
+ }
80
+ } catch (cause) {
81
+ fail(cause)
82
+ throw cause
83
+ } finally {
84
+ output.destroy()
85
+ await finished(output).catch(() => {})
86
+ pending.length = 0
87
+ last = undefined
88
+ if (origin === undefined && !error) await rm(path, { force: true })
89
+ }
90
+ })()
91
+ return closing
92
+ },
93
+ }
94
+ }
95
+
96
+ // The fixed Matroska subset contains one MJPEG track and one keyframe per cluster.
97
+ // Timestamps are milliseconds; JPEG dimensions and pixels remain unchanged.
98
+ function uint(value) {
99
+ const bytes = []
100
+ do { bytes.unshift(value % 256); value = Math.floor(value / 256) } while (value)
101
+ return Buffer.from(bytes)
102
+ }
103
+ function size(value) {
104
+ let length = 1
105
+ while (value >= 2 ** (7 * length) - 1) length++
106
+ const bytes = Buffer.alloc(length)
107
+ for (let i = length - 1; i >= 0; i--) { bytes[i] = value % 256; value = Math.floor(value / 256) }
108
+ bytes[0] |= 1 << (8 - length)
109
+ return bytes
110
+ }
111
+ function element(id, value) {
112
+ const data = typeof value === 'number' ? uint(value) : typeof value === 'string' ? Buffer.from(value) : Buffer.concat(value)
113
+ return Buffer.concat([Buffer.from(id, 'hex'), size(data.length), data])
114
+ }
115
+ function header(width, height, fps) {
116
+ const prefix = Buffer.concat([
117
+ element('1a45dfa3', [element('4286', 1), element('42f7', 1), element('42f2', 4), element('42f3', 8), element('4282', 'matroska'), element('4287', 4), element('4285', 2)]),
118
+ Buffer.from('1853806701ffffffffffffff', 'hex'),
119
+ ])
120
+ // Reserve a Void element, then replace it with Duration after the last write.
121
+ const durationSlot = Buffer.from('ec89000000000000000000', 'hex')
122
+ const info = element('1549a966', [element('2ad7b1', 1_000_000), element('4d80', 'traceframe'), element('5741', 'traceframe'), durationSlot])
123
+ const bytes = Buffer.concat([prefix, info,
124
+ element('1654ae6b', [element('ae', [element('d7', 1), element('73c5', 1), element('83', 1), element('9c', 0), element('86', 'V_MJPEG'), element('23e383', Math.round(1e9 / fps)), element('e0', [element('b0', width), element('ba', height)])])]),
125
+ ])
126
+ return { bytes, durationOffset: prefix.length + info.length - durationSlot.length }
127
+ }
128
+ function cluster(timestamp, length) {
129
+ const time = element('e7', timestamp)
130
+ const block = Buffer.concat([Buffer.from('a3', 'hex'), size(length + 4), Buffer.from('81000080', 'hex')])
131
+ return Buffer.concat([Buffer.from('1f43b675', 'hex'), size(time.length + block.length + length), time, block])
132
+ }
package/src/record.mjs CHANGED
@@ -67,6 +67,7 @@ async function recordFlow({ config, flow, ffmpeg, signal, page }) {
67
67
  clickCount: trace.clicks.length,
68
68
  runDirectory: config.runDirectory,
69
69
  ...(audio ? { audio: true } : {}),
70
+ ...(capture.captureStats ? { captureStats: capture.captureStats } : {}),
70
71
  }
71
72
  } catch (error) {
72
73
  failed = true
package/src/render.mjs CHANGED
@@ -9,18 +9,15 @@ const ASSETS = join(import.meta.dirname, 'assets')
9
9
  // The arrow tip in cursor.svg.
10
10
  const CURSOR_HOTSPOT = { x: 6.6, y: 1.6 }
11
11
  const CLICK = { duration: 0.24, scaleStart: 0.7, scaleEnd: 1.25 }
12
- // eof_action=pass lets the main video drive each overlay instead of waiting on the looping image inputs; without it, rendering is many times slower.
13
- // format=yuv444 places overlays on whole pixels. The default yuv420 snaps them to even pixels, and RGB costs more for the same result.
14
- const OVERLAY = 'eof_action=pass:format=yuv444'
15
12
 
16
13
  export async function renderVideo({ rawPath, trace, directory, config, ffmpeg, signal, audio }) {
17
14
  const { fps } = config.video
18
- const outputDuration = Math.max(trace.end + config.postRoll, ...trace.clicks.map((click) => click.at + CLICK.duration))
15
+ const outputDuration = Math.max(1 / fps, trace.end + config.postRoll, ...trace.clicks.map((click) => click.at + CLICK.duration))
19
16
  if (audio && audio.offset >= outputDuration) {
20
17
  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
18
  }
22
19
  const filterPath = join(directory, 'render.filter')
23
- await writeFile(filterPath, buildFilterScript({ trace, scale: config.scale, audio }))
20
+ await writeFile(filterPath, buildFilterScript({ trace, scale: config.scale, fps, audio, overlayEofAction: ffmpeg.overlayEofAction }))
24
21
  const outputTemp = join(dirname(config.output), `.traceframe-${randomUUID()}.tmp.mp4`)
25
22
  const args = [
26
23
  '-y', '-hide_banner', '-loglevel', 'error',
@@ -58,18 +55,25 @@ export async function renderVideo({ rawPath, trace, directory, config, ffmpeg, s
58
55
  }
59
56
 
60
57
  // Event times are seconds from the first frame of the raw video.
61
- export function buildFilterScript({ trace, scale, audio }) {
58
+ export function buildFilterScript({ trace, scale, fps = 60, audio, overlayEofAction = 'pass' }) {
62
59
  const { clicks, moves } = trace
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.
64
- const filters = [`[0:v]setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration=1[video0]`]
60
+ // Pass mode avoids waiting on looping image inputs; FFmpeg 7 needs repeat for padding.
61
+ // yuv444 places overlays on whole pixels rather than snapping them to even pixels.
62
+ const overlay = `eof_action=${overlayEofAction}:format=yuv444`
63
+ // Expand sparse capture timestamps before overlays so the cursor stays smooth
64
+ // even on a static page. Padding also covers a sub-frame take or final rounding.
65
+ // Chrome JPEGs are full range; normalize before compositing limited-range overlays.
66
+ // Convert to the overlay format before duplicating frames. Converting afterward
67
+ // can queue a full-size buffer for every repeated frame in a long static gap.
68
+ const filters = [`[0:v]scale=out_range=tv,format=yuv420p,format=yuv444p,setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration=1,fps=${fps}[video0]`]
65
69
  if (clicks.length) filters.push(`[2:v]format=rgba,split=${clicks.length}${clicks.map((_, index) => `[pulse${index}]`).join('')}`)
66
- clicks.forEach((click, index) => filters.push(...clickFilter(click, index, scale)))
70
+ clicks.forEach((click, index) => filters.push(...clickFilter(click, index, scale, overlay)))
67
71
 
68
72
  const cursorLabels = moves.map((_, index) => `[cursor${index}]`).join('')
69
73
  filters.push(`[1:v]format=rgba,split=${moves.length + 1}${cursorLabels}[cursor${moves.length}]`)
70
74
  const coordinate = (value, hotspot) => number(value * scale - hotspot * scale)
71
75
  const initialEnable = moves.length ? `:enable='lt(t,${number(moves[0].at)})'` : ''
72
- filters.push(`[video${clicks.length}][cursor0]overlay=x='${coordinate(trace.initialPointer.x, CURSOR_HOTSPOT.x)}':y='${coordinate(trace.initialPointer.y, CURSOR_HOTSPOT.y)}':${OVERLAY}${initialEnable}[cursorVideo0]`)
76
+ filters.push(`[video${clicks.length}][cursor0]overlay=x='${coordinate(trace.initialPointer.x, CURSOR_HOTSPOT.x)}':y='${coordinate(trace.initialPointer.y, CURSOR_HOTSPOT.y)}':${overlay}${initialEnable}[cursorVideo0]`)
73
77
  moves.forEach((move, index) => {
74
78
  const start = number(move.at)
75
79
  const end = index + 1 < moves.length ? number(moves[index + 1].at) : undefined
@@ -79,7 +83,7 @@ export function buildFilterScript({ trace, scale, audio }) {
79
83
  const eased = `(1-pow(1-clip((t-${start})/${number(move.duration)},0,1),3))`
80
84
  const x = `${coordinate(move.from.x, CURSOR_HOTSPOT.x)}+${number((move.to.x - move.from.x) * scale)}*${eased}`
81
85
  const y = `${coordinate(move.from.y, CURSOR_HOTSPOT.y)}+${number((move.to.y - move.from.y) * scale)}*${eased}`
82
- filters.push(`[cursorVideo${index}][cursor${index + 1}]overlay=x='${x}':y='${y}':${OVERLAY}:enable='${enable}'[cursorVideo${index + 1}]`)
86
+ filters.push(`[cursorVideo${index}][cursor${index + 1}]overlay=x='${x}':y='${y}':${overlay}:enable='${enable}'[cursorVideo${index + 1}]`)
83
87
  })
84
88
  filters.push(`[cursorVideo${moves.length}]colorspace=ispace=bt470bg:iprimaries=bt709:itrc=bt709:all=bt709:format=yuv420p[final]`)
85
89
  if (audio) {
@@ -87,18 +91,19 @@ export function buildFilterScript({ trace, scale, audio }) {
87
91
  const trim = number(Math.max(0, -audio.offset))
88
92
  const delay = number(Math.max(0, audio.offset) * 1000)
89
93
  // 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]`)
94
+ // Preserve real gaps before sample-count filters, tolerating normal recorder timestamp jitter.
95
+ filters.push(`[${input}:a:0]asetpts=PTS-STARTPTS,aresample=async=1:min_hard_comp=0.02,atrim=start=${trim},asetpts=PTS-STARTPTS,adelay=${delay}:all=1,apad[finalAudio]`)
91
96
  }
92
97
  return filters.join(';')
93
98
  }
94
99
 
95
- function clickFilter(click, index, scale) {
100
+ function clickFilter(click, index, scale, overlay) {
96
101
  const start = number(click.at)
97
102
  const end = number(start + CLICK.duration)
98
103
  const growth = `(${CLICK.scaleStart}+${CLICK.scaleEnd - CLICK.scaleStart}*clip((t-${start})/${CLICK.duration},0,1))`
99
104
  return [
100
105
  `[pulse${index}]setpts=PTS-STARTPTS+${start}/TB,scale=w='iw*${growth}':h='ih*${growth}':eval=frame,fade=t=out:st=${start}:d=${CLICK.duration}:alpha=1[click${index}]`,
101
- `[video${index}][click${index}]overlay=x='${number(click.point.x * scale)}-w/2':y='${number(click.point.y * scale)}-h/2':${OVERLAY}:enable='between(t,${start},${end})'[video${index + 1}]`,
106
+ `[video${index}][click${index}]overlay=x='${number(click.point.x * scale)}-w/2':y='${number(click.point.y * scale)}-h/2':${overlay}:enable='between(t,${start},${end})'[video${index + 1}]`,
102
107
  ]
103
108
  }
104
109