@milkstraw/traceframe 0.5.1 → 0.6.1
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 +21 -9
- package/docs/recording-flows.md +2 -2
- package/package.json +1 -1
- package/src/capture-stats.mjs +33 -0
- package/src/capture.mjs +33 -26
- package/src/cli.mjs +6 -2
- package/src/config.mjs +6 -1
- package/src/ffmpeg.mjs +21 -2
- package/src/jpeg-capture.mjs +132 -0
- package/src/record.mjs +1 -0
- package/src/render.mjs +23 -15
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`).
|
|
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
|
|
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
|
|
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 `--
|
|
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 while keeping cursor animations smooth.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/docs/recording-flows.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
@@ -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
|
|
17
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
79
|
+
// Stop the page subscription only once, including when shutdown fails.
|
|
66
80
|
recording = false
|
|
67
|
-
await
|
|
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
|
-
//
|
|
88
|
+
// Flush captured frames before closing the browser, including on slower disks.
|
|
73
89
|
const [capturePath, screenshotPath] = await Promise.all([
|
|
74
|
-
recording ?
|
|
75
|
-
.then(() =>
|
|
76
|
-
.
|
|
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 (
|
|
80
|
-
throw await addDiagnostics(
|
|
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
|
-
//
|
|
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
|
|
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 {
|
|
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
|
|
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,29 @@ 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
|
-
|
|
64
|
-
const
|
|
60
|
+
const animatedOverlays = clicks.length > 0 || moves.length > 0
|
|
61
|
+
const cadence = `tpad=stop_mode=clone:stop_duration=1,fps=${fps}`
|
|
62
|
+
// Pass mode avoids waiting on looping image inputs; FFmpeg 7 needs repeat for padding.
|
|
63
|
+
// yuv444 places overlays on whole pixels rather than snapping them to even pixels.
|
|
64
|
+
const overlay = `eof_action=${overlayEofAction}:format=yuv444`
|
|
65
|
+
// Animated overlays need every output tick, even on a static page.
|
|
66
|
+
// With stationary overlays, composite before duplicating identical frames.
|
|
67
|
+
// Padding also covers a sub-frame take or final rounding.
|
|
68
|
+
// Chrome JPEGs are full range; normalize before compositing limited-range overlays.
|
|
69
|
+
// Convert to the overlay format before duplicating frames. Converting afterward
|
|
70
|
+
// can queue a full-size buffer for every repeated frame in a long static gap.
|
|
71
|
+
const filters = [`[0:v]scale=out_range=tv,format=yuv420p,format=yuv444p,setpts=PTS-STARTPTS${animatedOverlays ? ',' + cadence : ''}[video0]`]
|
|
65
72
|
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)))
|
|
73
|
+
clicks.forEach((click, index) => filters.push(...clickFilter(click, index, scale, overlay)))
|
|
67
74
|
|
|
68
75
|
const cursorLabels = moves.map((_, index) => `[cursor${index}]`).join('')
|
|
69
|
-
|
|
76
|
+
// Convert the cursor once before splitting it into movement segments.
|
|
77
|
+
filters.push(`[1:v]format=yuva444p,split=${moves.length + 1}${cursorLabels}[cursor${moves.length}]`)
|
|
70
78
|
const coordinate = (value, hotspot) => number(value * scale - hotspot * scale)
|
|
71
79
|
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)}':${
|
|
80
|
+
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
81
|
moves.forEach((move, index) => {
|
|
74
82
|
const start = number(move.at)
|
|
75
83
|
const end = index + 1 < moves.length ? number(moves[index + 1].at) : undefined
|
|
@@ -79,9 +87,9 @@ export function buildFilterScript({ trace, scale, audio }) {
|
|
|
79
87
|
const eased = `(1-pow(1-clip((t-${start})/${number(move.duration)},0,1),3))`
|
|
80
88
|
const x = `${coordinate(move.from.x, CURSOR_HOTSPOT.x)}+${number((move.to.x - move.from.x) * scale)}*${eased}`
|
|
81
89
|
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}':${
|
|
90
|
+
filters.push(`[cursorVideo${index}][cursor${index + 1}]overlay=x='${x}':y='${y}':${overlay}:enable='${enable}'[cursorVideo${index + 1}]`)
|
|
83
91
|
})
|
|
84
|
-
filters.push(`[cursorVideo${moves.length}]colorspace=ispace=bt470bg:iprimaries=bt709:itrc=bt709:all=bt709:format=yuv420p[final]`)
|
|
92
|
+
filters.push(`[cursorVideo${moves.length}]colorspace=ispace=bt470bg:iprimaries=bt709:itrc=bt709:all=bt709:format=yuv420p${animatedOverlays ? '' : ',' + cadence}[final]`)
|
|
85
93
|
if (audio) {
|
|
86
94
|
const input = clicks.length ? 3 : 2
|
|
87
95
|
const trim = number(Math.max(0, -audio.offset))
|
|
@@ -93,13 +101,13 @@ export function buildFilterScript({ trace, scale, audio }) {
|
|
|
93
101
|
return filters.join(';')
|
|
94
102
|
}
|
|
95
103
|
|
|
96
|
-
function clickFilter(click, index, scale) {
|
|
104
|
+
function clickFilter(click, index, scale, overlay) {
|
|
97
105
|
const start = number(click.at)
|
|
98
106
|
const end = number(start + CLICK.duration)
|
|
99
107
|
const growth = `(${CLICK.scaleStart}+${CLICK.scaleEnd - CLICK.scaleStart}*clip((t-${start})/${CLICK.duration},0,1))`
|
|
100
108
|
return [
|
|
101
109
|
`[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}]`,
|
|
102
|
-
`[video${index}][click${index}]overlay=x='${number(click.point.x * scale)}-w/2':y='${number(click.point.y * scale)}-h/2':${
|
|
110
|
+
`[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}]`,
|
|
103
111
|
]
|
|
104
112
|
}
|
|
105
113
|
|