@milkstraw/traceframe 0.4.0 → 0.5.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 +35 -2
- package/docs/recording-flows.md +10 -0
- package/package.json +1 -1
- package/src/capture.mjs +1 -1
- package/src/cli.mjs +9 -1
- package/src/config.mjs +16 -1
- package/src/ffmpeg.mjs +19 -3
- package/src/record.mjs +14 -5
- package/src/render.mjs +20 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Traceframe records a scripted Chromium flow and renders an MP4 with a smooth cursor and click pulse. Everything runs locally: pages, flows, videos, and authentication state stay on your machine.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Browser capture is silent by default. Add an [external audio file](#add-audio) with explicit timing; tab audio and microphones are not captured automatically.
|
|
6
6
|
|
|
7
7
|
https://github.com/user-attachments/assets/38e02cd5-a9ba-4f3c-938c-159955353141
|
|
8
8
|
|
|
@@ -170,7 +170,7 @@ The callback receives the same `page`, `signal` and five helpers as a flow file.
|
|
|
170
170
|
|
|
171
171
|
Use Playwright 1.64.1 with Chromium, a fixed viewport, 100% browser zoom and an unscaled page. Traceframe checks the page's inner dimensions and visual viewport before starting capture, rejecting unsupported geometry with `CAPTURE_SIZE_MISMATCH`. `recordPage` keeps the page, context and browser open on success, failure and cancellation. It does not navigate, resize, run a `ready` hook, or change page timeouts. Your script owns setup and shutdown. The CLI and `record` continue to create and close their own browser.
|
|
172
172
|
|
|
173
|
-
`page` and a function `flow` are required. `output`, `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.
|
|
173
|
+
`page` and a function `flow` are required. `output`, `audio`, `postRoll`, `flowTimeout`, `runDirectory` and `signal` have the same meaning as in `record`, and the return value is the same recording summary. Set `initialPointer: { x, y }` to choose the starting cursor position. The page's viewport determines the dimensions; `scale` defaults to 1 and accepts 1 or 2, and `fps` defaults to 30 and accepts 30 or 60. Use these explicit settings instead of a preset. Browser setup options such as `url`, `browser`, `storageState`, viewport dimensions and `readyTimeout` are rejected.
|
|
174
174
|
|
|
175
175
|
Traceframe moves the caller's pointer only after validating the first frame. To show the starting hover state from frame 0, move the page's mouse to your chosen `initialPointer` before calling `recordPage`.
|
|
176
176
|
|
|
@@ -182,6 +182,39 @@ Await every helper. Helpers observe cancellation before further pointer or scrol
|
|
|
182
182
|
|
|
183
183
|
Traceframe removes its own temporary outputs unless `runDirectory` is supplied. Playwright keeps original WebMs in its browser artifacts directory until the caller closes the browser, even after a take has been saved. For extension pages, use full Chromium; the headless shell alone does not load extensions. This path was checked on Linux with Playwright 1.64.1 and its bundled full Chromium using a synthetic extension.
|
|
184
184
|
|
|
185
|
+
### Add audio
|
|
186
|
+
|
|
187
|
+
Both `record` and `recordPage` accept `audio: { path: './sound.wav', offset: 0 }`. The CLI accepts the same settings:
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
npx @milkstraw/traceframe record --url http://localhost:3000 --flow ./flow.mjs \
|
|
191
|
+
--audio .traceframe/sound.wav --audio-offset 0.5 --out .traceframe/demo.mp4
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`offset` is seconds from the first video frame to the first audio sample. Positive values add leading silence; negative values discard the beginning of the audio (in the CLI, use e.g. `--audio-offset=-0.25`). The default is 0. Short audio is padded with silence, and longer audio is trimmed to the video's end. Nothing is looped or stretched. The first audio stream is encoded as stereo AAC at 48 kHz, 192 kb/s; system FFmpeg must provide its standard `aac` encoder. Successful summaries include `audio: true`.
|
|
195
|
+
|
|
196
|
+
The input must be a local file, such as WAV, MP3, M4A or audio WebM. Static inputs are checked before browser recording, so a missing file or missing audio stream cannot waste a take (`AUDIO_INVALID`). After capture, an audio start at or after the video's end rejects with `AUDIO_OUTSIDE_VIDEO`. A negative offset that discards the entire source produces a silent audio track.
|
|
197
|
+
|
|
198
|
+
Traceframe reads the file without modifying or deleting it; use an input path different from the output and the reserved files in `runDirectory` (`capture.webm`, `render.filter`, `failure.png`). Keep recordings in an ignored directory.
|
|
199
|
+
|
|
200
|
+
For audio recorded alongside the flow, supply an async function instead. It runs once after successful capture and post-roll, before rendering, so it can finalize the external recorder and return its file. `startedAt` is the first video frame's timestamp in epoch milliseconds:
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
await recordPage({
|
|
204
|
+
page,
|
|
205
|
+
output: '.traceframe/demo.mp4',
|
|
206
|
+
flow,
|
|
207
|
+
audio: async ({ startedAt, signal }) => {
|
|
208
|
+
const { path, startedAt: audioStartedAt } = await finishAudioRecording({ signal })
|
|
209
|
+
return { path, offset: (audioStartedAt - startedAt) / 1000 }
|
|
210
|
+
},
|
|
211
|
+
})
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Here `finishAudioRecording` belongs to your script and reports the timestamp of its first recorded sample. Use timestamps on a shared clock, or determine the offset using an audiovisual marker. An offset corrects a constant alignment difference; it does not correct clock drift. Check markers near both ends of longer takes. See the [audio workflow](docs/recording-flows.md#align-external-audio).
|
|
215
|
+
|
|
216
|
+
Your script owns the external recorder and its cleanup, including on flow failure or cancellation. The audio function is skipped if capture fails and must cooperate with `signal` when supplied. It may finish writing after capture; a static input must already exist before recording. Neither form captures browser or system sound by itself.
|
|
217
|
+
|
|
185
218
|
### Errors and cancellation
|
|
186
219
|
|
|
187
220
|
Traceframe's own errors are `TraceframeError` instances (exported by the package) with a `code`, such as `READY_FAILED` or `FLOW_TARGET_OBSCURED`. Other exceptions thrown by the flow become `FLOW_FAILED`, with the original thrown value in `cause`; existing Traceframe error codes and causes are preserved.
|
package/docs/recording-flows.md
CHANGED
|
@@ -208,6 +208,14 @@ Inspect the reported screenshot, then the capture near the reported time if need
|
|
|
208
208
|
|
|
209
209
|
Only paths saved for the current attempt are reported. A closed or unresponsive page may prevent a screenshot or capture from being saved. Errors before the first frame have no recording time, and a flow timeout may have no source location. Without `--run-dir`, flow locations and timing are still reported when available, but temporary captures are removed and no failure screenshot is saved. The API exposes the same context through [`error.diagnostics`](../README.md#errors-and-cancellation).
|
|
210
210
|
|
|
211
|
+
## Align external audio
|
|
212
|
+
|
|
213
|
+
Capture the application's actual processed output with your own audio recorder, then pass its local file through [`audio`](../README.md#add-audio). Use the same prepared page with `recordPage` when the audio setup needs a caller-owned context. Audio recording remains your script's responsibility; Traceframe aligns and encodes the supplied file in the final MP4.
|
|
214
|
+
|
|
215
|
+
Use a distinct sound and visible change as a synchronization marker. If the visible change lands at 2.00 seconds in the video and its sound starts at 2.08 seconds in the audio file, pass `offset: -0.08`. Measure on the actual recorded streams, accounting for capture-device and encoder latency. For a live recorder, the audio supplier's `startedAt` gives the video clock; the recorder's timestamp must refer to its first sample on a shared clock.
|
|
216
|
+
|
|
217
|
+
Inspect the rendered result with audio enabled, and compare another marker near the end. If the difference grows, a constant offset is insufficient: correct the external recording's rate separately before adding it. Traceframe never changes audio speed or pitch. Trimmed audio ends with the video, including post-roll; shorter audio leaves silence. For independent takes, calculate alignment for each take and close the external recorder in your script's `finally` block.
|
|
218
|
+
|
|
211
219
|
## Edit the finished video
|
|
212
220
|
|
|
213
221
|
Edit the finished MP4 with FFmpeg rather than changing the flow. Editing finished frames keeps the page and cursor together. Keep the original, and say when a clip's pacing was edited.
|
|
@@ -225,6 +233,8 @@ export default async function ({ page, click, wait }) {
|
|
|
225
233
|
|
|
226
234
|
Log to `console.error`, because Traceframe prints its JSON summary on standard output. This command plays seconds 2 to 9 at 4× speed. Set `fps` to the video's frame rate: 60 for the default preset, 30 for draft.
|
|
227
235
|
|
|
236
|
+
This command drops the audio track. To keep audio synchronized, apply the same cuts and speed changes to its timeline, including when speeding up silent intervals.
|
|
237
|
+
|
|
228
238
|
```sh
|
|
229
239
|
ffmpeg -i recording.mp4 -filter_complex "[0:v]trim=0:2,setpts=PTS-STARTPTS[a];[0:v]trim=2:9,setpts=(PTS-STARTPTS)/4[b];[0:v]trim=9,setpts=PTS-STARTPTS[c];[a][b][c]concat=n=3,fps=60[v]" -map "[v]" -c:v libx264 -crf 18 -movflags +faststart faster.mp4
|
|
230
240
|
```
|
package/package.json
CHANGED
package/src/capture.mjs
CHANGED
|
@@ -65,7 +65,7 @@ export async function captureFlow({ config, flow, directory, page, signal: calle
|
|
|
65
65
|
// A failed stop must not be retried: Playwright may still be waiting to finish its artifact.
|
|
66
66
|
recording = false
|
|
67
67
|
await page.screencast.stop()
|
|
68
|
-
return { rawPath, trace }
|
|
68
|
+
return { rawPath, trace, startedAt: clock }
|
|
69
69
|
} catch (error) {
|
|
70
70
|
failed = true
|
|
71
71
|
const recordingTime = clock === undefined ? undefined : Math.max(0, (Date.now() - clock) / 1000)
|
package/src/cli.mjs
CHANGED
|
@@ -24,8 +24,10 @@ const OPTION_KEYS = {
|
|
|
24
24
|
'ready-timeout': 'readyTimeout',
|
|
25
25
|
'flow-timeout': 'flowTimeout',
|
|
26
26
|
'run-dir': 'runDirectory',
|
|
27
|
+
audio: 'audio',
|
|
28
|
+
'audio-offset': 'audioOffset',
|
|
27
29
|
}
|
|
28
|
-
const NUMBER_KEYS = new Set(['viewportWidth', 'viewportHeight', 'scale', 'fps', 'postRoll', 'readyTimeout', 'flowTimeout'])
|
|
30
|
+
const NUMBER_KEYS = new Set(['viewportWidth', 'viewportHeight', 'scale', 'fps', 'postRoll', 'readyTimeout', 'flowTimeout', 'audioOffset'])
|
|
29
31
|
const HELP = `traceframe ${VERSION}
|
|
30
32
|
|
|
31
33
|
Record a scripted Chromium flow and render a smooth cursor and click pulse.
|
|
@@ -46,6 +48,8 @@ Options:
|
|
|
46
48
|
--ready-timeout <seconds> Playwright default timeout during navigation and ready() (default: 30)
|
|
47
49
|
--flow-timeout <seconds> Time limit for the flow, including waits (default: 120)
|
|
48
50
|
--run-dir <path> Keep the raw capture, failure screenshot and FFmpeg filter
|
|
51
|
+
--audio <path> Add an external audio file (AAC stereo in the MP4)
|
|
52
|
+
--audio-offset <seconds> Audio start in the video; negative trims its start (default: 0)
|
|
49
53
|
-h, --help Show this help
|
|
50
54
|
-v, --version Show the version
|
|
51
55
|
|
|
@@ -108,5 +112,9 @@ function parseCommand(args) {
|
|
|
108
112
|
for (const [name, key] of Object.entries(OPTION_KEYS)) {
|
|
109
113
|
if (values[name] !== undefined) options[key] = NUMBER_KEYS.has(key) ? Number(values[name]) : values[name]
|
|
110
114
|
}
|
|
115
|
+
if (options.audio !== undefined || options.audioOffset !== undefined) {
|
|
116
|
+
options.audio = { path: options.audio, offset: options.audioOffset }
|
|
117
|
+
delete options.audioOffset
|
|
118
|
+
}
|
|
111
119
|
return { options }
|
|
112
120
|
}
|
package/src/config.mjs
CHANGED
|
@@ -62,18 +62,33 @@ function recordingConfig(options, viewport, defaults) {
|
|
|
62
62
|
check(Number.isFinite(postRoll) && postRoll >= 0, 'Post-roll must be 0 or more seconds.')
|
|
63
63
|
const flowTimeout = options.flowTimeout ?? 120
|
|
64
64
|
check(Number.isFinite(flowTimeout) && flowTimeout > 0, 'Flow timeout must be more than 0 seconds.')
|
|
65
|
+
const runDirectory = options.runDirectory ? resolve(options.runDirectory) : undefined
|
|
65
66
|
|
|
66
67
|
return {
|
|
67
68
|
output,
|
|
69
|
+
audio: typeof options.audio === 'function' ? options.audio : createAudioConfig(options.audio, output, runDirectory),
|
|
68
70
|
viewport: { ...viewport },
|
|
69
71
|
video,
|
|
70
72
|
scale,
|
|
71
73
|
postRoll,
|
|
72
74
|
flowTimeout,
|
|
73
|
-
runDirectory
|
|
75
|
+
runDirectory,
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
export function createAudioConfig(audio, output, directory) {
|
|
80
|
+
if (audio === undefined) return undefined
|
|
81
|
+
check(typeof audio?.path === 'string' && audio.path.length > 0, 'Audio must specify a local file path (--audio <path>, or audio: { path }).')
|
|
82
|
+
const path = resolve(audio.path)
|
|
83
|
+
check(path !== output, 'Audio input and video output must use different paths.')
|
|
84
|
+
if (directory) {
|
|
85
|
+
check(!['capture.webm', 'render.filter', 'failure.png'].some((name) => path === resolve(directory, name)), `Audio input must not use a reserved recording file: ${path}`)
|
|
86
|
+
}
|
|
87
|
+
const offset = audio.offset ?? 0
|
|
88
|
+
check(Number.isFinite(offset), 'Audio offset must be a finite number of seconds.')
|
|
89
|
+
return { path, offset }
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
function check(valid, message) {
|
|
78
93
|
if (!valid) throw new TraceframeError(message, { code: 'INVALID_CONFIG' })
|
|
79
94
|
}
|
package/src/ffmpeg.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process'
|
|
2
|
+
import { stat } from 'node:fs/promises'
|
|
2
3
|
import { promisify } from 'node:util'
|
|
3
4
|
|
|
4
5
|
import { TraceframeError } from './errors.mjs'
|
|
5
6
|
|
|
6
7
|
const execFileAsync = promisify(execFile)
|
|
7
8
|
|
|
8
|
-
export async function probeFfmpeg() {
|
|
9
|
+
export async function probeFfmpeg({ audio = false } = {}) {
|
|
9
10
|
const version = await runCommand('ffmpeg', ['-version'])
|
|
10
11
|
const major = parseFfmpegMajor(version.stdout)
|
|
11
12
|
if (!(major >= 5)) {
|
|
@@ -15,12 +16,27 @@ export async function probeFfmpeg() {
|
|
|
15
16
|
if (!codecs.stdout.includes('libx264')) {
|
|
16
17
|
throw new TraceframeError('System FFmpeg does not provide the libx264 encoder.', { code: 'FFMPEG_UNSUPPORTED' })
|
|
17
18
|
}
|
|
19
|
+
if (audio && !/^\s*A\S*\s+aac\s/m.test(codecs.stdout)) {
|
|
20
|
+
throw new TraceframeError('System FFmpeg does not provide the AAC encoder required for audio.', { code: 'FFMPEG_UNSUPPORTED' })
|
|
21
|
+
}
|
|
18
22
|
await runCommand('ffprobe', ['-version'])
|
|
19
23
|
return { filterComplexFileOption: filterComplexFileOption(major) }
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
export async function
|
|
23
|
-
|
|
26
|
+
export async function probeAudio(path, signal) {
|
|
27
|
+
try {
|
|
28
|
+
if (!(await stat(path)).isFile()) throw new Error('The input is not a regular file.')
|
|
29
|
+
const { stdout } = await runCommand('ffprobe', ['-v', 'error', '-select_streams', 'a:0', '-show_entries', 'stream=index', '-of', 'json', path], { signal })
|
|
30
|
+
const { streams } = JSON.parse(stdout)
|
|
31
|
+
if (!streams.length) throw new Error('The file has no audio stream.')
|
|
32
|
+
} catch (cause) {
|
|
33
|
+
signal?.throwIfAborted()
|
|
34
|
+
throw new TraceframeError(`Could not read audio from ${path}: ${cause.message}`, { code: 'AUDIO_INVALID', cause })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function probeVideoDuration(path, signal) {
|
|
39
|
+
const { stdout } = await runCommand('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=duration', '-of', 'csv=p=0', path], { signal })
|
|
24
40
|
return Number(stdout)
|
|
25
41
|
}
|
|
26
42
|
|
package/src/record.mjs
CHANGED
|
@@ -2,10 +2,10 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { dirname, join } from 'node:path'
|
|
4
4
|
|
|
5
|
-
import { createConfig, createPageConfig } from './config.mjs'
|
|
5
|
+
import { createAudioConfig, createConfig, createPageConfig } from './config.mjs'
|
|
6
6
|
import { captureFlow } from './capture.mjs'
|
|
7
7
|
import { addDiagnostics, retainedFile } from './diagnostics.mjs'
|
|
8
|
-
import { probeFfmpeg } from './ffmpeg.mjs'
|
|
8
|
+
import { probeAudio, probeFfmpeg } from './ffmpeg.mjs'
|
|
9
9
|
import { createFlow, loadFlow } from './flow.mjs'
|
|
10
10
|
import { renderVideo } from './render.mjs'
|
|
11
11
|
import { TraceframeError } from './errors.mjs'
|
|
@@ -16,7 +16,7 @@ export async function record(options = {}) {
|
|
|
16
16
|
const { signal } = options
|
|
17
17
|
signal?.throwIfAborted()
|
|
18
18
|
const config = createConfig(options)
|
|
19
|
-
const ffmpeg = await probeFfmpeg()
|
|
19
|
+
const ffmpeg = await probeFfmpeg({ audio: Boolean(config.audio) })
|
|
20
20
|
const flow = await loadFlow(config.flow, config.viewport).catch(async (error) => {
|
|
21
21
|
throw await addDiagnostics(error, { flow: config.flow })
|
|
22
22
|
})
|
|
@@ -31,7 +31,7 @@ export async function recordPage(options = {}) {
|
|
|
31
31
|
if (activePages.has(page)) throw new TraceframeError('This page is already being recorded by Traceframe.', { code: 'PAGE_RECORDING_ACTIVE' })
|
|
32
32
|
activePages.add(page)
|
|
33
33
|
try {
|
|
34
|
-
const ffmpeg = await probeFfmpeg()
|
|
34
|
+
const ffmpeg = await probeFfmpeg({ audio: Boolean(config.audio) })
|
|
35
35
|
return await recordFlow({ config, flow, ffmpeg, signal, page })
|
|
36
36
|
} finally {
|
|
37
37
|
activePages.delete(page)
|
|
@@ -39,6 +39,8 @@ export async function recordPage(options = {}) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
async function recordFlow({ config, flow, ffmpeg, signal, page }) {
|
|
42
|
+
let audio = typeof config.audio === 'function' ? undefined : config.audio
|
|
43
|
+
if (audio) await probeAudio(audio.path, signal)
|
|
42
44
|
await mkdir(dirname(config.output), { recursive: true })
|
|
43
45
|
const directory = config.runDirectory ?? await mkdtemp(join(tmpdir(), 'traceframe-'))
|
|
44
46
|
await mkdir(directory, { recursive: true })
|
|
@@ -48,7 +50,13 @@ async function recordFlow({ config, flow, ffmpeg, signal, page }) {
|
|
|
48
50
|
try {
|
|
49
51
|
capture = await captureFlow({ config, flow, directory, signal, page })
|
|
50
52
|
const { rawPath, trace } = capture
|
|
51
|
-
|
|
53
|
+
if (typeof config.audio === 'function') {
|
|
54
|
+
signal?.throwIfAborted()
|
|
55
|
+
audio = createAudioConfig(await config.audio({ startedAt: capture.startedAt, signal }), config.output, directory)
|
|
56
|
+
if (!audio) throw new TraceframeError('The audio function must return { path, offset }.', { code: 'INVALID_CONFIG' })
|
|
57
|
+
await probeAudio(audio.path, signal)
|
|
58
|
+
}
|
|
59
|
+
const duration = await renderVideo({ rawPath, trace, directory, config, ffmpeg, signal, audio })
|
|
52
60
|
return {
|
|
53
61
|
output: config.output,
|
|
54
62
|
width: config.video.width,
|
|
@@ -58,6 +66,7 @@ async function recordFlow({ config, flow, ffmpeg, signal, page }) {
|
|
|
58
66
|
moveCount: trace.moves.length,
|
|
59
67
|
clickCount: trace.clicks.length,
|
|
60
68
|
runDirectory: config.runDirectory,
|
|
69
|
+
...(audio ? { audio: true } : {}),
|
|
61
70
|
}
|
|
62
71
|
} catch (error) {
|
|
63
72
|
failed = true
|
package/src/render.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { rename, rm, writeFile } from 'node:fs/promises'
|
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { probeVideoDuration, runCommand } from './ffmpeg.mjs'
|
|
6
6
|
import { TraceframeError } from './errors.mjs'
|
|
7
7
|
|
|
8
8
|
const ASSETS = join(import.meta.dirname, 'assets')
|
|
@@ -13,11 +13,14 @@ const CLICK = { duration: 0.24, scaleStart: 0.7, scaleEnd: 1.25 }
|
|
|
13
13
|
// format=yuv444 places overlays on whole pixels. The default yuv420 snaps them to even pixels, and RGB costs more for the same result.
|
|
14
14
|
const OVERLAY = 'eof_action=pass:format=yuv444'
|
|
15
15
|
|
|
16
|
-
export async function renderVideo({ rawPath, trace, directory, config, ffmpeg, signal }) {
|
|
16
|
+
export async function renderVideo({ rawPath, trace, directory, config, ffmpeg, signal, audio }) {
|
|
17
17
|
const { fps } = config.video
|
|
18
18
|
const outputDuration = Math.max(trace.end + config.postRoll, ...trace.clicks.map((click) => click.at + CLICK.duration))
|
|
19
|
+
if (audio && audio.offset >= outputDuration) {
|
|
20
|
+
throw new TraceframeError(`Audio offset ${audio.offset} seconds starts at or after the video's end (${number(outputDuration)} seconds). Reduce its offset.`, { code: 'AUDIO_OUTSIDE_VIDEO' })
|
|
21
|
+
}
|
|
19
22
|
const filterPath = join(directory, 'render.filter')
|
|
20
|
-
await writeFile(filterPath, buildFilterScript({ trace, scale: config.scale }))
|
|
23
|
+
await writeFile(filterPath, buildFilterScript({ trace, scale: config.scale, audio }))
|
|
21
24
|
const outputTemp = join(dirname(config.output), `.traceframe-${randomUUID()}.tmp.mp4`)
|
|
22
25
|
const args = [
|
|
23
26
|
'-y', '-hide_banner', '-loglevel', 'error',
|
|
@@ -25,9 +28,13 @@ export async function renderVideo({ rawPath, trace, directory, config, ffmpeg, s
|
|
|
25
28
|
'-loop', '1', '-framerate', String(fps), '-i', join(ASSETS, `cursor-${config.scale}x.png`),
|
|
26
29
|
]
|
|
27
30
|
if (trace.clicks.length) args.push('-loop', '1', '-framerate', String(fps), '-t', String(CLICK.duration + 1 / fps), '-i', join(ASSETS, `click-pulse-${config.scale}x.png`))
|
|
31
|
+
if (audio) args.push('-i', audio.path)
|
|
28
32
|
args.push(
|
|
29
33
|
ffmpeg.filterComplexFileOption, filterPath,
|
|
30
34
|
'-map', '[final]',
|
|
35
|
+
)
|
|
36
|
+
if (audio) args.push('-map', '[finalAudio]', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2')
|
|
37
|
+
args.push(
|
|
31
38
|
'-t', String(number(outputDuration)),
|
|
32
39
|
'-r', String(fps),
|
|
33
40
|
'-c:v', 'libx264', '-preset', 'fast', '-crf', '18',
|
|
@@ -37,7 +44,7 @@ export async function renderVideo({ rawPath, trace, directory, config, ffmpeg, s
|
|
|
37
44
|
)
|
|
38
45
|
try {
|
|
39
46
|
await runCommand('ffmpeg', args, { signal })
|
|
40
|
-
const duration = await
|
|
47
|
+
const duration = await probeVideoDuration(outputTemp, signal)
|
|
41
48
|
if (!(Math.abs(duration - outputDuration) <= 1.5 / fps)) {
|
|
42
49
|
throw new TraceframeError(`The rendered video is ${duration.toFixed(2)} seconds long, but the recording is ${outputDuration.toFixed(2)} seconds.`, { code: 'OUTPUT_MISMATCH' })
|
|
43
50
|
}
|
|
@@ -51,7 +58,7 @@ export async function renderVideo({ rawPath, trace, directory, config, ffmpeg, s
|
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
// Event times are seconds from the first frame of the raw video.
|
|
54
|
-
export function buildFilterScript({ trace, scale }) {
|
|
61
|
+
export function buildFilterScript({ trace, scale, audio }) {
|
|
55
62
|
const { clicks, moves } = trace
|
|
56
63
|
// Playwright ends the raw video by the time it received the last frame, so a page that stays still can end it a little before the post-roll. The page did not change, so repeat its last frame.
|
|
57
64
|
const filters = [`[0:v]setpts=PTS-STARTPTS,tpad=stop_mode=clone:stop_duration=1[video0]`]
|
|
@@ -75,6 +82,14 @@ export function buildFilterScript({ trace, scale }) {
|
|
|
75
82
|
filters.push(`[cursorVideo${index}][cursor${index + 1}]overlay=x='${x}':y='${y}':${OVERLAY}:enable='${enable}'[cursorVideo${index + 1}]`)
|
|
76
83
|
})
|
|
77
84
|
filters.push(`[cursorVideo${moves.length}]colorspace=ispace=bt470bg:iprimaries=bt709:itrc=bt709:all=bt709:format=yuv420p[final]`)
|
|
85
|
+
if (audio) {
|
|
86
|
+
const input = clicks.length ? 3 : 2
|
|
87
|
+
const trim = number(Math.max(0, -audio.offset))
|
|
88
|
+
const delay = number(Math.max(0, audio.offset) * 1000)
|
|
89
|
+
// Reset the selected stream before trimming: its first sample may start after the container's video.
|
|
90
|
+
// Preserve real gaps before sample-count filters, tolerating normal recorder timestamp jitter.
|
|
91
|
+
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]`)
|
|
92
|
+
}
|
|
78
93
|
return filters.join(';')
|
|
79
94
|
}
|
|
80
95
|
|