@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 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
- Recordings are silent; audio is not captured.
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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milkstraw/traceframe",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Record scripted browser flows and render smooth cursor overlays into video.",
5
5
  "keywords": [
6
6
  "playwright",
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: options.runDirectory ? resolve(options.runDirectory) : undefined,
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 probeDuration(path, signal) {
23
- const { stdout } = await runCommand('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path], { signal })
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
- const duration = await renderVideo({ rawPath, trace, directory, config, ffmpeg, signal })
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 { probeDuration, runCommand } from './ffmpeg.mjs'
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 probeDuration(outputTemp, signal)
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