playwriter 0.0.63 → 0.0.89

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.
Files changed (223) hide show
  1. package/dist/a11y-client.js +18 -8
  2. package/dist/aria-snapshot.d.ts +41 -3
  3. package/dist/aria-snapshot.d.ts.map +1 -1
  4. package/dist/aria-snapshot.js +134 -55
  5. package/dist/aria-snapshot.js.map +1 -1
  6. package/dist/aria-snapshot.test.js +5 -2
  7. package/dist/aria-snapshot.test.js.map +1 -1
  8. package/dist/aria-snapshot.unit.test.js +83 -41
  9. package/dist/aria-snapshot.unit.test.js.map +1 -1
  10. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts +5 -0
  11. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.d.ts.map +1 -0
  12. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js +5 -0
  13. package/dist/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.js.map +1 -0
  14. package/dist/bippy.js +1 -1
  15. package/dist/cdp-log.d.ts +1 -1
  16. package/dist/cdp-log.d.ts.map +1 -1
  17. package/dist/cdp-log.js +1 -1
  18. package/dist/cdp-log.js.map +1 -1
  19. package/dist/cdp-relay.d.ts.map +1 -1
  20. package/dist/cdp-relay.js +492 -298
  21. package/dist/cdp-relay.js.map +1 -1
  22. package/dist/cdp-session.d.ts.map +1 -1
  23. package/dist/cdp-session.js.map +1 -1
  24. package/dist/cdp-types.d.ts.map +1 -1
  25. package/dist/cdp-types.js +7 -7
  26. package/dist/cdp-types.js.map +1 -1
  27. package/dist/clean-html.d.ts.map +1 -1
  28. package/dist/clean-html.js +4 -5
  29. package/dist/clean-html.js.map +1 -1
  30. package/dist/cli.js +45 -27
  31. package/dist/cli.js.map +1 -1
  32. package/dist/create-logger.d.ts.map +1 -1
  33. package/dist/create-logger.js +3 -1
  34. package/dist/create-logger.js.map +1 -1
  35. package/dist/debugger-examples-types.d.ts.map +1 -1
  36. package/dist/debugger.d.ts.map +1 -1
  37. package/dist/debugger.js +1 -3
  38. package/dist/debugger.js.map +1 -1
  39. package/dist/diff-utils.d.ts.map +1 -1
  40. package/dist/diff-utils.js +1 -4
  41. package/dist/diff-utils.js.map +1 -1
  42. package/dist/editor-api.md +12 -2
  43. package/dist/editor-examples.d.ts +1 -1
  44. package/dist/editor-examples.d.ts.map +1 -1
  45. package/dist/editor-examples.js +1 -1
  46. package/dist/editor-examples.js.map +1 -1
  47. package/dist/editor.d.ts +1 -1
  48. package/dist/editor.d.ts.map +1 -1
  49. package/dist/editor.js +1 -1
  50. package/dist/editor.js.map +1 -1
  51. package/dist/executor.d.ts +26 -3
  52. package/dist/executor.d.ts.map +1 -1
  53. package/dist/executor.js +297 -64
  54. package/dist/executor.js.map +1 -1
  55. package/dist/executor.unit.test.js +38 -1
  56. package/dist/executor.unit.test.js.map +1 -1
  57. package/dist/extension-connection.test.js +139 -36
  58. package/dist/extension-connection.test.js.map +1 -1
  59. package/dist/ffmpeg.d.ts +148 -0
  60. package/dist/ffmpeg.d.ts.map +1 -0
  61. package/dist/ffmpeg.js +523 -0
  62. package/dist/ffmpeg.js.map +1 -0
  63. package/dist/ghost-browser.d.ts.map +1 -1
  64. package/dist/ghost-browser.js.map +1 -1
  65. package/dist/ghost-cursor-client.js +287 -0
  66. package/dist/ghost-cursor.d.ts +27 -0
  67. package/dist/ghost-cursor.d.ts.map +1 -0
  68. package/dist/ghost-cursor.js +63 -0
  69. package/dist/ghost-cursor.js.map +1 -0
  70. package/dist/htmlrewrite.d.ts.map +1 -1
  71. package/dist/htmlrewrite.js +17 -55
  72. package/dist/htmlrewrite.js.map +1 -1
  73. package/dist/htmlrewrite.test.js.map +1 -1
  74. package/dist/kill-port.d.ts.map +1 -1
  75. package/dist/kill-port.js +1 -3
  76. package/dist/kill-port.js.map +1 -1
  77. package/dist/locator-selector.test.d.ts +2 -0
  78. package/dist/locator-selector.test.d.ts.map +1 -0
  79. package/dist/locator-selector.test.js +96 -0
  80. package/dist/locator-selector.test.js.map +1 -0
  81. package/dist/mcp-client.js.map +1 -1
  82. package/dist/mcp.d.ts.map +1 -1
  83. package/dist/mcp.js +8 -3
  84. package/dist/mcp.js.map +1 -1
  85. package/dist/on-mouse-action.test.d.ts +2 -0
  86. package/dist/on-mouse-action.test.d.ts.map +1 -0
  87. package/dist/on-mouse-action.test.js +155 -0
  88. package/dist/on-mouse-action.test.js.map +1 -0
  89. package/dist/page-markdown.js +4 -4
  90. package/dist/page-markdown.js.map +1 -1
  91. package/dist/prompt.md +450 -377
  92. package/dist/protocol.d.ts +4 -0
  93. package/dist/protocol.d.ts.map +1 -1
  94. package/dist/readability.js +16 -2
  95. package/dist/recording-ghost-cursor.d.ts +41 -0
  96. package/dist/recording-ghost-cursor.d.ts.map +1 -0
  97. package/dist/recording-ghost-cursor.js +79 -0
  98. package/dist/recording-ghost-cursor.js.map +1 -0
  99. package/dist/recording-relay.d.ts.map +1 -1
  100. package/dist/recording-relay.js +8 -8
  101. package/dist/recording-relay.js.map +1 -1
  102. package/dist/relay-client.d.ts +17 -4
  103. package/dist/relay-client.d.ts.map +1 -1
  104. package/dist/relay-client.js +45 -11
  105. package/dist/relay-client.js.map +1 -1
  106. package/dist/relay-core.test.d.ts.map +1 -1
  107. package/dist/relay-core.test.js +515 -26
  108. package/dist/relay-core.test.js.map +1 -1
  109. package/dist/relay-navigation.test.d.ts.map +1 -1
  110. package/dist/relay-navigation.test.js +169 -31
  111. package/dist/relay-navigation.test.js.map +1 -1
  112. package/dist/relay-session.test.d.ts.map +1 -1
  113. package/dist/relay-session.test.js +113 -65
  114. package/dist/relay-session.test.js.map +1 -1
  115. package/dist/relay-state.d.ts +158 -0
  116. package/dist/relay-state.d.ts.map +1 -0
  117. package/dist/relay-state.js +306 -0
  118. package/dist/relay-state.js.map +1 -0
  119. package/dist/relay-state.test.d.ts +2 -0
  120. package/dist/relay-state.test.d.ts.map +1 -0
  121. package/dist/relay-state.test.js +472 -0
  122. package/dist/relay-state.test.js.map +1 -0
  123. package/dist/scoped-fs.d.ts.map +1 -1
  124. package/dist/scoped-fs.js.map +1 -1
  125. package/dist/screen-recording.d.ts +66 -4
  126. package/dist/screen-recording.d.ts.map +1 -1
  127. package/dist/screen-recording.js +150 -13
  128. package/dist/screen-recording.js.map +1 -1
  129. package/dist/screen-recording.test.d.ts +2 -0
  130. package/dist/screen-recording.test.d.ts.map +1 -0
  131. package/dist/screen-recording.test.js +102 -0
  132. package/dist/screen-recording.test.js.map +1 -0
  133. package/dist/selector-generator.js +1 -1
  134. package/dist/snapshot-tools.test.js +71 -28
  135. package/dist/snapshot-tools.test.js.map +1 -1
  136. package/dist/start-relay-server.d.ts +1 -1
  137. package/dist/start-relay-server.d.ts.map +1 -1
  138. package/dist/start-relay-server.js +1 -1
  139. package/dist/start-relay-server.js.map +1 -1
  140. package/dist/styles-api.md +8 -1
  141. package/dist/styles-examples.d.ts +1 -1
  142. package/dist/styles-examples.d.ts.map +1 -1
  143. package/dist/styles-examples.js +1 -1
  144. package/dist/styles-examples.js.map +1 -1
  145. package/dist/styles.d.ts.map +1 -1
  146. package/dist/styles.js +1 -3
  147. package/dist/styles.js.map +1 -1
  148. package/dist/test-declarations.d.ts.map +1 -1
  149. package/dist/test-utils.d.ts +1 -1
  150. package/dist/test-utils.d.ts.map +1 -1
  151. package/dist/test-utils.js +7 -5
  152. package/dist/test-utils.js.map +1 -1
  153. package/dist/utils.d.ts.map +1 -1
  154. package/dist/utils.js.map +1 -1
  155. package/dist/wait-for-page-load.d.ts.map +1 -1
  156. package/dist/wait-for-page-load.js +1 -1
  157. package/dist/wait-for-page-load.js.map +1 -1
  158. package/package.json +4 -3
  159. package/src/a11y-client.ts +5 -4
  160. package/src/aria-snapshot.test.ts +5 -2
  161. package/src/aria-snapshot.ts +306 -117
  162. package/src/aria-snapshot.unit.test.ts +199 -141
  163. package/src/aria-snapshots/github-interactive.txt +2 -0
  164. package/src/aria-snapshots/github-raw.txt +5 -1
  165. package/src/aria-snapshots/hackernews-interactive.txt +238 -241
  166. package/src/aria-snapshots/hackernews-raw.txt +265 -269
  167. package/src/assets/aria-labels-example.png +0 -0
  168. package/src/assets/aria-labels-github.png +0 -0
  169. package/src/assets/aria-labels-hacker-news.png +0 -0
  170. package/src/assets/aria-labels-old-reddit.png +0 -0
  171. package/src/assets/cursors/screen-studio/pointer-macos-tahoe-data-url.ts +5 -0
  172. package/src/assets/cursors/screen-studio/pointer-macos-tahoe.svg +18 -0
  173. package/src/cdp-log.ts +4 -1
  174. package/src/cdp-relay.ts +1059 -737
  175. package/src/cdp-session.ts +12 -3
  176. package/src/cdp-types.ts +51 -51
  177. package/src/clean-html.ts +4 -5
  178. package/src/cli.ts +82 -55
  179. package/src/create-logger.ts +5 -3
  180. package/src/debugger-examples-types.ts +4 -1
  181. package/src/debugger.ts +1 -5
  182. package/src/diff-utils.ts +2 -5
  183. package/src/editor-examples.ts +11 -1
  184. package/src/editor.ts +10 -2
  185. package/src/executor.ts +374 -73
  186. package/src/executor.unit.test.ts +48 -1
  187. package/src/extension-connection.test.ts +612 -488
  188. package/src/ffmpeg.ts +769 -0
  189. package/src/ghost-browser.ts +4 -6
  190. package/src/ghost-cursor-client.ts +369 -0
  191. package/src/ghost-cursor.ts +110 -0
  192. package/src/htmlrewrite.test.ts +6 -2
  193. package/src/htmlrewrite.ts +348 -386
  194. package/src/kill-port.ts +1 -3
  195. package/src/locator-selector.test.ts +115 -0
  196. package/src/mcp-client.ts +1 -1
  197. package/src/mcp.ts +21 -15
  198. package/src/on-mouse-action.test.ts +196 -0
  199. package/src/page-markdown.ts +7 -7
  200. package/src/protocol.ts +73 -57
  201. package/src/recording-ghost-cursor.ts +113 -0
  202. package/src/recording-relay.ts +20 -12
  203. package/src/relay-client.ts +85 -18
  204. package/src/relay-core.test.ts +1117 -578
  205. package/src/relay-navigation.test.ts +648 -483
  206. package/src/relay-session.test.ts +984 -929
  207. package/src/relay-state.test.ts +570 -0
  208. package/src/relay-state.ts +497 -0
  209. package/src/resource.md +21 -49
  210. package/src/scoped-fs.ts +9 -3
  211. package/src/screen-recording.test.ts +111 -0
  212. package/src/screen-recording.ts +256 -31
  213. package/src/skill.md +476 -396
  214. package/src/snapshot-tools.test.ts +580 -528
  215. package/src/snapshots/shadcn-ui-accessibility-full.md +8 -8
  216. package/src/snapshots/shadcn-ui-accessibility-interactive.md +8 -8
  217. package/src/start-relay-server.ts +14 -11
  218. package/src/styles-examples.ts +8 -1
  219. package/src/styles.ts +20 -21
  220. package/src/test-declarations.ts +6 -6
  221. package/src/test-utils.ts +104 -91
  222. package/src/utils.ts +2 -1
  223. package/src/wait-for-page-load.ts +6 -1
package/src/ffmpeg.ts ADDED
@@ -0,0 +1,769 @@
1
+ /**
2
+ * FFmpeg utilities for video concatenation and section-based speed manipulation.
3
+ *
4
+ * Both functions use a single ffmpeg filter_complex pass: trim segments from
5
+ * the input, apply setpts for speed, normalize fps/scale, then concat.
6
+ * No intermediate files, no multi-pass.
7
+ */
8
+
9
+ import { spawn } from 'node:child_process'
10
+ import os from 'node:os'
11
+ import path from 'node:path'
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Constants
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Seconds of normal-speed buffer kept before and after each execution (0.5s each side = 1s total) */
18
+ export const INTERACTION_BUFFER_SECONDS = 0.5
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Hardware encoder detection
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Preferred hardware encoders by platform, in priority order.
26
+ * Each is tried via `ffmpeg -f lavfi -i nullsrc -t 0.01 -c:v <encoder> -f null -`
27
+ * to confirm the encoder actually works (drivers present, GPU available, etc).
28
+ */
29
+ const HW_ENCODER_CANDIDATES: Record<string, string[]> = {
30
+ darwin: ['h264_videotoolbox'],
31
+ win32: ['h264_nvenc', 'h264_qsv', 'h264_amf'],
32
+ // h264_vaapi excluded: requires -vaapi_device and format=nv12,hwupload in the
33
+ // filter graph, which our filter_complex pipeline doesn't set up
34
+ linux: ['h264_nvenc', 'h264_qsv'],
35
+ }
36
+
37
+ interface EncoderInfo {
38
+ /** Encoder name for `-c:v`, e.g. "h264_videotoolbox" or "libx264" */
39
+ codec: string
40
+ /** true when using a hardware encoder */
41
+ isHardware: boolean
42
+ }
43
+
44
+ /** Cache so we only probe once per process */
45
+ let cachedEncoder: EncoderInfo | undefined
46
+
47
+ /**
48
+ * Detect the best available H.264 encoder.
49
+ * Tries platform-specific hardware encoders first, falls back to libx264.
50
+ * Result is cached for the lifetime of the process.
51
+ */
52
+ export async function detectEncoder(): Promise<EncoderInfo> {
53
+ if (cachedEncoder) {
54
+ return cachedEncoder
55
+ }
56
+
57
+ const platform = os.platform()
58
+ const candidates = HW_ENCODER_CANDIDATES[platform] ?? []
59
+
60
+ for (const codec of candidates) {
61
+ const works = await testEncoder(codec)
62
+ if (works) {
63
+ cachedEncoder = { codec, isHardware: true }
64
+ return cachedEncoder
65
+ }
66
+ }
67
+
68
+ cachedEncoder = { codec: 'libx264', isHardware: false }
69
+ return cachedEncoder
70
+ }
71
+
72
+ /**
73
+ * Quick probe: can this encoder produce even a single frame?
74
+ * Runs `ffmpeg -f lavfi -i nullsrc=s=64x64:d=0.01 -c:v <encoder> -f null -`
75
+ * and checks exit code. Timeout 5s to avoid hanging on broken drivers.
76
+ */
77
+ function testEncoder(codec: string): Promise<boolean> {
78
+ return new Promise((resolve) => {
79
+ const child = spawn('ffmpeg', [
80
+ '-hide_banner', '-loglevel', 'error',
81
+ '-f', 'lavfi', '-i', 'nullsrc=s=64x64:d=0.01',
82
+ '-c:v', codec,
83
+ '-f', 'null', '-',
84
+ ], { stdio: 'ignore' })
85
+
86
+ const timeout = setTimeout(() => {
87
+ child.kill()
88
+ resolve(false)
89
+ }, 5000)
90
+
91
+ child.on('close', (code) => {
92
+ clearTimeout(timeout)
93
+ resolve(code === 0)
94
+ })
95
+
96
+ child.on('error', () => {
97
+ clearTimeout(timeout)
98
+ resolve(false)
99
+ })
100
+ })
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Encoding quality parameters
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Build encoding args optimized for screen recordings on social media.
109
+ *
110
+ * Quality rationale (screen recordings = sharp text, flat colors, UI):
111
+ * - libx264 CRF 18 = near visually lossless, prevents text compression artifacts
112
+ * - `-preset fast` = ~4x faster than default `medium`, minimal quality loss on
113
+ * screen content. NOT `ultrafast` which compresses so poorly that platforms
114
+ * (X.com, YouTube) re-encode more aggressively, making it look worse.
115
+ * - `-x264opts deblock=-1,-1` = less deblocking preserves sharp text edges
116
+ * - No `-tune` flag: `animation` blurs text edges, `zerolatency` is for
117
+ * real-time capture only
118
+ * - h264_videotoolbox `-q:v 80` ≈ CRF 18 equivalent for screen content
119
+ * - `-maxrate 25M` = X.com (Twitter) max bitrate cap
120
+ * - `-movflags +faststart` = metadata at front for web streaming
121
+ * - `-pix_fmt yuv420p` = universal compatibility across all platforms
122
+ */
123
+ function buildEncodingArgs(encoder: EncoderInfo): string[] {
124
+ const common = [
125
+ '-pix_fmt', 'yuv420p',
126
+ '-maxrate', '25M',
127
+ '-bufsize', '50M',
128
+ '-movflags', '+faststart',
129
+ ]
130
+
131
+ if (encoder.isHardware) {
132
+ // Hardware encoders: use quality-based mode where supported
133
+ // h264_videotoolbox uses -q:v (1-100, 100=best), others use -b:v
134
+ const qualityArgs = encoder.codec === 'h264_videotoolbox'
135
+ ? ['-q:v', '80']
136
+ : ['-b:v', '15M'] // high bitrate for other HW encoders
137
+ return ['-c:v', encoder.codec, ...qualityArgs, ...common]
138
+ }
139
+
140
+ // Software: libx264 with screen-recording-optimized settings
141
+ return [
142
+ '-c:v', 'libx264',
143
+ '-crf', '18',
144
+ '-preset', 'fast',
145
+ '-x264opts', 'deblock=-1,-1',
146
+ ...common,
147
+ ]
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Types
152
+ // ---------------------------------------------------------------------------
153
+
154
+ export interface InputFile {
155
+ path: string
156
+ start?: number
157
+ end?: number
158
+ }
159
+
160
+ export interface ConcatenateOptions {
161
+ inputFiles: InputFile[]
162
+ outputFile: string
163
+ outputDimensions: { width: number; height: number }
164
+ frameRate: number
165
+ signal?: AbortSignal
166
+ }
167
+
168
+ export interface SpeedSection {
169
+ /** Start time in seconds */
170
+ start: number
171
+ /** End time in seconds */
172
+ end: number
173
+ /** Speed multiplier, e.g. 2 = 2x faster, 0.5 = 2x slower */
174
+ speed: number
175
+ }
176
+
177
+ export interface SpeedUpSectionsOptions {
178
+ inputFile: string
179
+ /** Defaults to inputFile with `-fast` suffix before extension */
180
+ outputFile?: string
181
+ sections: SpeedSection[]
182
+ /** Defaults to input video dimensions (probed via ffprobe) */
183
+ outputDimensions?: { width: number; height: number }
184
+ /** Defaults to input video frame rate (probed via ffprobe) */
185
+ frameRate?: number
186
+ signal?: AbortSignal
187
+ }
188
+
189
+ export interface VideoInfo {
190
+ width: number
191
+ height: number
192
+ frameRate: number
193
+ }
194
+
195
+ function parseFrameRate(value: string | undefined): number | null {
196
+ if (!value) {
197
+ return null
198
+ }
199
+
200
+ const [numRaw, denRaw] = value.split('/').map(Number)
201
+ if (!Number.isFinite(numRaw) || !Number.isFinite(denRaw) || denRaw === 0) {
202
+ return null
203
+ }
204
+
205
+ const frameRate = numRaw / denRaw
206
+ if (!Number.isFinite(frameRate) || frameRate <= 0) {
207
+ return null
208
+ }
209
+
210
+ return frameRate
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Helpers
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /** Probe input video for dimensions and frame rate via ffprobe. */
218
+ export async function probeVideo(filePath: string): Promise<VideoInfo> {
219
+ const stdout = await runCommand({
220
+ bin: 'ffprobe',
221
+ args: [
222
+ '-v', 'error',
223
+ '-select_streams', 'v:0',
224
+ '-show_entries', 'stream=width,height,r_frame_rate,avg_frame_rate',
225
+ '-of', 'json',
226
+ filePath,
227
+ ],
228
+ })
229
+
230
+ const parsed = JSON.parse(stdout)
231
+ const stream = parsed.streams?.[0]
232
+ if (!stream) {
233
+ throw new Error(`No video stream found in ${filePath}`)
234
+ }
235
+
236
+ // Prefer avg_frame_rate for VFR recordings. r_frame_rate can report
237
+ // high timebase-like values (e.g. 30000/1) that are not usable output FPS.
238
+ const avgFrameRate = parseFrameRate(stream.avg_frame_rate as string | undefined)
239
+ const rawFrameRate = parseFrameRate(stream.r_frame_rate as string | undefined)
240
+ const selectedFrameRate = (() => {
241
+ if (avgFrameRate && avgFrameRate <= 120) {
242
+ return avgFrameRate
243
+ }
244
+ if (rawFrameRate && rawFrameRate <= 120) {
245
+ return rawFrameRate
246
+ }
247
+ if (avgFrameRate) {
248
+ return avgFrameRate
249
+ }
250
+ if (rawFrameRate) {
251
+ return rawFrameRate
252
+ }
253
+ return 30
254
+ })()
255
+ const normalizedFrameRate = Math.min(120, Math.max(1, Math.round(selectedFrameRate)))
256
+
257
+ return {
258
+ width: stream.width as number,
259
+ height: stream.height as number,
260
+ frameRate: normalizedFrameRate,
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Run a process with argv (no shell). Returns stdout as string.
266
+ * Avoids shell injection by never passing through a shell interpreter.
267
+ */
268
+ function runCommand({
269
+ bin,
270
+ args,
271
+ signal,
272
+ }: {
273
+ bin: string
274
+ args: string[]
275
+ signal?: AbortSignal
276
+ }): Promise<string> {
277
+ return new Promise((resolve, reject) => {
278
+ const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] })
279
+ let stdout = ''
280
+ let stderr = ''
281
+
282
+ child.stdout.on('data', (data: Buffer) => {
283
+ stdout += data.toString()
284
+ })
285
+ child.stderr.on('data', (data: Buffer) => {
286
+ stderr += data.toString()
287
+ })
288
+
289
+ child.on('close', (code) => {
290
+ if (code === 0) {
291
+ resolve(stdout)
292
+ } else {
293
+ reject(new Error(`FFmpeg error (exit ${code}): ${stderr}`))
294
+ }
295
+ })
296
+
297
+ child.on('error', (err) => {
298
+ reject(new Error(`Failed to start ${bin}`, { cause: err }))
299
+ })
300
+
301
+ if (signal) {
302
+ signal.addEventListener(
303
+ 'abort',
304
+ () => {
305
+ child.kill()
306
+ reject(
307
+ signal.reason instanceof Error
308
+ ? signal.reason
309
+ : new Error('Operation aborted'),
310
+ )
311
+ },
312
+ { once: true },
313
+ )
314
+ }
315
+ })
316
+ }
317
+
318
+ /** Build default output path: `/dir/name-fast.ext` */
319
+ function defaultOutputPath(inputFile: string): string {
320
+ const ext = path.extname(inputFile)
321
+ const base = path.basename(inputFile, ext)
322
+ const dir = path.dirname(inputFile)
323
+ return path.join(dir, `${base}-fast${ext}`)
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Internal segment types
328
+ // ---------------------------------------------------------------------------
329
+
330
+ interface Segment {
331
+ start: number
332
+ /** undefined = until end of video */
333
+ end: number | undefined
334
+ /** 1 = normal speed */
335
+ speed: number
336
+ }
337
+
338
+ /**
339
+ * Given sorted, non-overlapping SpeedSections, fill gaps with normal-speed
340
+ * segments so the entire video is covered.
341
+ */
342
+ function buildSegments(sections: SpeedSection[]): Segment[] {
343
+ const sorted = [...sections].sort((a, b) => {
344
+ return a.start - b.start
345
+ })
346
+
347
+ // Validate: no overlaps
348
+ for (let i = 1; i < sorted.length; i++) {
349
+ if (sorted[i].start < sorted[i - 1].end) {
350
+ throw new Error(
351
+ `Sections overlap: [${sorted[i - 1].start}-${sorted[i - 1].end}] and [${sorted[i].start}-${sorted[i].end}]`,
352
+ )
353
+ }
354
+ }
355
+
356
+ const segments: Segment[] = []
357
+ let cursor = 0
358
+
359
+ for (const section of sorted) {
360
+ // Gap before this section → normal speed
361
+ if (section.start > cursor) {
362
+ segments.push({ start: cursor, end: section.start, speed: 1 })
363
+ }
364
+ // The speed section itself
365
+ segments.push({
366
+ start: section.start,
367
+ end: section.end,
368
+ speed: section.speed,
369
+ })
370
+ cursor = section.end
371
+ }
372
+
373
+ // Trailing normal-speed segment (no end bound → until EOF)
374
+ segments.push({ start: cursor, end: undefined, speed: 1 })
375
+
376
+ return segments
377
+ }
378
+
379
+ /**
380
+ * Build the filter string for a single segment.
381
+ *
382
+ * For sped-up segments: `[0:v]trim=...,setpts=...,fps=...,scale=...[vN]`
383
+ * For normal-speed segments where dimensions/fps match input: just
384
+ * `[0:v]trim=...,setpts=PTS-STARTPTS[vN]` — skips fps and scale filters
385
+ * to avoid unnecessary pixel processing and frame-rate checking.
386
+ */
387
+ function buildSegmentFilter({
388
+ segment,
389
+ index,
390
+ frameRate,
391
+ width,
392
+ height,
393
+ inputWidth,
394
+ inputHeight,
395
+ inputFrameRate,
396
+ }: {
397
+ segment: Segment
398
+ index: number
399
+ frameRate: number
400
+ width: number
401
+ height: number
402
+ /** Original input dimensions — when they match output, scale is skipped on passthrough */
403
+ inputWidth?: number
404
+ inputHeight?: number
405
+ /** Original input fps — when it matches output, fps filter is skipped on passthrough */
406
+ inputFrameRate?: number
407
+ }): string {
408
+ const trimParts = [`start=${segment.start}`]
409
+ if (segment.end !== undefined) {
410
+ trimParts.push(`end=${segment.end}`)
411
+ }
412
+ const trim = `trim=${trimParts.join(':')}`
413
+
414
+ // setpts=PTS-STARTPTS resets timestamps after trim.
415
+ // Dividing by speed makes it faster (speed>1) or slower (speed<1).
416
+ const setpts =
417
+ segment.speed === 1
418
+ ? 'setpts=PTS-STARTPTS'
419
+ : `setpts=(PTS-STARTPTS)/${segment.speed}`
420
+
421
+ // For normal-speed segments where output matches input: skip fps/scale
422
+ // to avoid unnecessary pixel processing and frame-rate filtering
423
+ const isPassthrough = segment.speed === 1
424
+ && inputWidth === width
425
+ && inputHeight === height
426
+ && inputFrameRate === frameRate
427
+
428
+ if (isPassthrough) {
429
+ return `[0:v]${trim},${setpts}[v${index}]`
430
+ }
431
+
432
+ // Cap output FPS to the probed source FPS. This prevents sped-up sections
433
+ // from producing excessive frame rates when timestamps are compressed.
434
+ const fps = `fps=fps=${frameRate}:round=down`
435
+
436
+ return `[0:v]${trim},${setpts},${fps},scale=${width}:${height}[v${index}]`
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Public API
441
+ // ---------------------------------------------------------------------------
442
+
443
+ export async function concatenateVideos(
444
+ options: ConcatenateOptions,
445
+ ): Promise<void> {
446
+ const { outputDimensions, frameRate, inputFiles, outputFile, signal } =
447
+ options
448
+
449
+ if (!outputDimensions || !frameRate || !inputFiles || !outputFile) {
450
+ throw new Error('Missing required parameters')
451
+ }
452
+
453
+ const timerId = `concat-${inputFiles.length}-videos-${path.basename(outputFile)}`
454
+ console.time(timerId)
455
+
456
+ const encoder = await detectEncoder()
457
+ const encodingArgs = buildEncodingArgs(encoder)
458
+
459
+ // Build argv: -i file1 -i file2 ... -filter_complex "..." -map "[v_out]" output
460
+ const inputArgs = inputFiles.flatMap((file) => {
461
+ return ['-i', file.path]
462
+ })
463
+
464
+ const filterComplexParts: string[] = []
465
+ const videoStreamParts: string[] = []
466
+
467
+ inputFiles.forEach((file, index) => {
468
+ const videoStream = `[${index}:v:0]`
469
+ let trimmedVideo = videoStream
470
+
471
+ if (file.start !== undefined || file.end !== undefined) {
472
+ const start = file.start ?? 0
473
+ const end = file.end ? `end=${file.end}` : ''
474
+ trimmedVideo = `${videoStream}trim=start=${start}:${end},setpts=PTS-STARTPTS`
475
+ }
476
+
477
+ filterComplexParts.push(
478
+ `${trimmedVideo},fps=${frameRate},scale=${outputDimensions.width}:${outputDimensions.height}[v${index}]`,
479
+ )
480
+ videoStreamParts.push(`[v${index}]`)
481
+ })
482
+
483
+ filterComplexParts.push(
484
+ `${videoStreamParts.join('')}concat=n=${inputFiles.length}:v=1:a=0[v_out]`,
485
+ )
486
+
487
+ const filterComplex = filterComplexParts.join('; ')
488
+ const args = [
489
+ ...inputArgs,
490
+ '-filter_complex', filterComplex,
491
+ '-map', '[v_out]',
492
+ ...encodingArgs,
493
+ outputFile,
494
+ ]
495
+
496
+ console.log('Running FFmpeg concat:', args.join(' '))
497
+
498
+ try {
499
+ await runCommand({ bin: 'ffmpeg', args, signal })
500
+ } finally {
501
+ console.timeEnd(timerId)
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Speed up (or slow down) sections of a video by timestamp ranges.
507
+ *
508
+ * Sections not covered by any SpeedSection play at normal speed.
509
+ * Uses a single ffmpeg filter_complex: trim each segment, apply setpts
510
+ * speed, normalize fps/scale, then concat — no intermediate files.
511
+ *
512
+ * @example
513
+ * ```ts
514
+ * await speedUpSections({
515
+ * inputFile: 'recording.mp4',
516
+ * sections: [
517
+ * { start: 10, end: 20, speed: 4 }, // 4x between 10s-20s
518
+ * { start: 30, end: 40, speed: 2 }, // 2x between 30s-40s
519
+ * ],
520
+ * })
521
+ * // → outputs recording-fast.mp4
522
+ * ```
523
+ */
524
+ export async function speedUpSections(
525
+ options: SpeedUpSectionsOptions,
526
+ ): Promise<string> {
527
+ const { inputFile, sections, signal } = options
528
+
529
+ if (sections.length === 0) {
530
+ throw new Error('At least one speed section is required')
531
+ }
532
+ for (const s of sections) {
533
+ if (s.speed <= 0) {
534
+ throw new Error(`Speed must be > 0, got ${s.speed}`)
535
+ }
536
+ if (s.end <= s.start) {
537
+ throw new Error(
538
+ `Section end (${s.end}) must be greater than start (${s.start})`,
539
+ )
540
+ }
541
+ }
542
+
543
+ const outputFile = options.outputFile ?? defaultOutputPath(inputFile)
544
+
545
+ // Probe input when needed for defaults or for passthrough optimization
546
+ const dims = options.outputDimensions
547
+ const fps = options.frameRate
548
+ const probed = (!dims || !fps) ? await probeVideo(inputFile) : undefined
549
+
550
+ const width = dims?.width ?? probed!.width
551
+ const height = dims?.height ?? probed!.height
552
+ const frameRate = fps ?? probed!.frameRate
553
+
554
+ const encoder = await detectEncoder()
555
+ const encodingArgs = buildEncodingArgs(encoder)
556
+
557
+ const timerId = `speedup-${sections.length}-sections-${path.basename(outputFile)}`
558
+ console.time(timerId)
559
+
560
+ const segments = buildSegments(sections)
561
+
562
+ const filterParts = segments.map((segment, index) => {
563
+ return buildSegmentFilter({
564
+ segment,
565
+ index,
566
+ frameRate,
567
+ width,
568
+ height,
569
+ inputWidth: probed?.width,
570
+ inputHeight: probed?.height,
571
+ inputFrameRate: probed?.frameRate,
572
+ })
573
+ })
574
+
575
+ const streamLabels = segments.map((_, i) => {
576
+ return `[v${i}]`
577
+ }).join('')
578
+
579
+ filterParts.push(
580
+ `${streamLabels}concat=n=${segments.length}:v=1:a=0[v_out]`,
581
+ )
582
+
583
+ const filterComplex = filterParts.join('; ')
584
+ const args = [
585
+ '-i', inputFile,
586
+ '-filter_complex', filterComplex,
587
+ '-map', '[v_out]',
588
+ '-r', String(frameRate),
589
+ ...encodingArgs,
590
+ outputFile,
591
+ ]
592
+
593
+ console.log('Running FFmpeg speedup:', args.join(' '))
594
+
595
+ try {
596
+ await runCommand({ bin: 'ffmpeg', args, signal })
597
+ } finally {
598
+ console.timeEnd(timerId)
599
+ }
600
+
601
+ return outputFile
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // Idle section computation
606
+ // ---------------------------------------------------------------------------
607
+
608
+ export interface ExecutionTimestamp {
609
+ /** Start time in seconds relative to recording start */
610
+ start: number
611
+ /** End time in seconds relative to recording start */
612
+ end: number
613
+ }
614
+
615
+ /**
616
+ * Compute which parts of a recording are "idle" (no execute() calls)
617
+ * and return them as SpeedSections that can be passed to speedUpSections().
618
+ *
619
+ * A buffer of INTERACTION_BUFFER_SECONDS is kept around each execution
620
+ * at normal speed so the viewer sees context before/after each action.
621
+ *
622
+ * @example
623
+ * ```ts
624
+ * const { executionTimestamps, duration } = await stopRecording()
625
+ * const idleSections = computeIdleSections({
626
+ * executionTimestamps,
627
+ * totalDurationMs: duration,
628
+ * })
629
+ * await speedUpSections({
630
+ * inputFile: recordingPath,
631
+ * sections: idleSections,
632
+ * })
633
+ * ```
634
+ */
635
+ export function computeIdleSections({
636
+ executionTimestamps,
637
+ totalDurationMs,
638
+ speed = 6,
639
+ bufferSeconds = INTERACTION_BUFFER_SECONDS,
640
+ }: {
641
+ executionTimestamps: ExecutionTimestamp[]
642
+ /** Total recording duration in milliseconds (from stopRecording result) */
643
+ totalDurationMs: number
644
+ /** Speed multiplier for idle sections (default 6) */
645
+ speed?: number
646
+ /** Override the default buffer around each execution (seconds) */
647
+ bufferSeconds?: number
648
+ }): SpeedSection[] {
649
+ const totalDuration = totalDurationMs / 1000
650
+
651
+ if (executionTimestamps.length === 0) {
652
+ // No execute() boundaries were captured. This commonly happens when
653
+ // recording starts and stops inside a single execute() call.
654
+ // In this case we cannot infer idle gaps safely, so keep original speed.
655
+ return []
656
+ }
657
+
658
+ // Apply buffer: expand each execution range by bufferSeconds on each side,
659
+ // clamp to video bounds, then filter out any ranges that become invalid
660
+ // (e.g. timestamps that exceed the video duration).
661
+ const buffered = executionTimestamps
662
+ .map((t) => ({
663
+ start: Math.max(0, t.start - bufferSeconds),
664
+ end: Math.min(totalDuration, t.end + bufferSeconds),
665
+ }))
666
+ .filter((r) => {
667
+ return Number.isFinite(r.start) && Number.isFinite(r.end) && r.end > r.start
668
+ })
669
+ .sort((a, b) => {
670
+ return a.start - b.start
671
+ })
672
+
673
+ // Merge overlapping/adjacent buffered ranges
674
+ const merged: Array<{ start: number; end: number }> = []
675
+ for (const range of buffered) {
676
+ const last = merged[merged.length - 1]
677
+ if (last && range.start <= last.end) {
678
+ last.end = Math.max(last.end, range.end)
679
+ } else {
680
+ merged.push({ ...range })
681
+ }
682
+ }
683
+
684
+ // Gaps between merged active ranges are idle sections to speed up
685
+ const idle: SpeedSection[] = []
686
+ let cursor = 0
687
+
688
+ for (const active of merged) {
689
+ if (active.start > cursor) {
690
+ idle.push({ start: cursor, end: active.start, speed })
691
+ }
692
+ cursor = active.end
693
+ }
694
+
695
+ // Trailing idle after last execution
696
+ if (cursor < totalDuration) {
697
+ idle.push({ start: cursor, end: totalDuration, speed })
698
+ }
699
+
700
+ return idle
701
+ }
702
+
703
+ // ---------------------------------------------------------------------------
704
+ // High-level demo video creation
705
+ // ---------------------------------------------------------------------------
706
+
707
+ export interface CreateDemoVideoOptions {
708
+ /** Path to the raw recording file */
709
+ recordingPath: string
710
+ /** Total recording duration in milliseconds (from stopRecording result) */
711
+ durationMs: number
712
+ /** Execution timestamps (from stopRecording result) */
713
+ executionTimestamps: ExecutionTimestamp[]
714
+ /** Speed multiplier for idle sections (default 6) */
715
+ speed?: number
716
+ /** Output file path (defaults to recordingPath with `-demo` suffix) */
717
+ outputFile?: string
718
+ signal?: AbortSignal
719
+ }
720
+
721
+ /**
722
+ * Create a demo video from a recording by speeding up idle sections
723
+ * (gaps between execute() calls) while keeping interactions at normal speed.
724
+ *
725
+ * A 0.5-second buffer (INTERACTION_BUFFER_SECONDS) is preserved on each side of
726
+ * an interaction (1 second total) so viewers see context before and after each action.
727
+ *
728
+ * Requires `ffmpeg` and `ffprobe` installed on the system.
729
+ *
730
+ * @returns The output file path
731
+ */
732
+ export async function createDemoVideo(
733
+ options: CreateDemoVideoOptions,
734
+ ): Promise<string> {
735
+ const {
736
+ recordingPath,
737
+ durationMs,
738
+ executionTimestamps,
739
+ speed = 6,
740
+ signal,
741
+ } = options
742
+
743
+ const outputFile = options.outputFile ?? (() => {
744
+ const ext = path.extname(recordingPath)
745
+ const base = path.basename(recordingPath, ext)
746
+ const dir = path.dirname(recordingPath)
747
+ return path.join(dir, `${base}-demo${ext}`)
748
+ })()
749
+
750
+ const idleSections = computeIdleSections({
751
+ executionTimestamps,
752
+ totalDurationMs: durationMs,
753
+ speed,
754
+ })
755
+
756
+ if (idleSections.length === 0) {
757
+ // No idle sections, nothing to speed up — copy as-is
758
+ const { copyFile } = await import('node:fs/promises')
759
+ await copyFile(recordingPath, outputFile)
760
+ return outputFile
761
+ }
762
+
763
+ return speedUpSections({
764
+ inputFile: recordingPath,
765
+ outputFile,
766
+ sections: idleSections,
767
+ signal,
768
+ })
769
+ }