middlewright 0.1.0 → 0.1.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.
@@ -1,21 +1,151 @@
1
1
  /**
2
- * video-mode: slow down and highlight actions so recorded videos are watchable.
2
+ * video-mode: record action timings and render watchable videos after the run.
3
3
  *
4
4
  * Extracted from the iterate monorepo's internal Playwright test
5
5
  * infrastructure (github.com/iterate/iterate, private). Modification from the
6
6
  * original: the hardcoded skip for iterate's test-helpers file is now the
7
7
  * `skipStackFrames` option.
8
8
  */
9
- import type { Locator } from "@playwright/test";
10
- import type { Plugin, OverrideableMethod } from "../plugin-system.ts";
9
+ import { execFile as execFileCallback } from "node:child_process";
10
+ import { createHash } from "node:crypto";
11
+ import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
12
+ import { extname, join } from "node:path";
13
+ import { promisify } from "node:util";
14
+ import type { Locator, TestInfo } from "@playwright/test";
15
+ import type { ActionTiming, Plugin, OverrideableMethod } from "../plugin-system.ts";
16
+
17
+ const execFile = promisify(execFileCallback);
18
+ const VIDEO_MODE_METADATA_FILE = "video-mode.json";
19
+ const VIDEO_MODE_PLAYER_FILE = "video-mode.html";
20
+ const VIDEO_MODE_RAW_FILE = "video-raw.webm";
21
+ const VIDEO_MODE_RENDERED_FILE = "video-rendered.webm";
22
+ const VIDEO_MODE_REPORT_PLAYER_FILE = "video-mode-report.html";
23
+ const VIDEO_MODE_POINTER_FILE = "video-mode-pointer.png";
24
+ const VIDEO_MODE_CLICK_POINTER_FILE = "video-mode-click-pointer.png";
25
+ const VIDEO_MODE_TEXT_POINTER_FILE = "video-mode-text-pointer.png";
26
+ // Pointer assets adapted from Pictogrammers Material Design Icons:
27
+ // cursor-default.svg, cursor-pointer.svg, and cursor-text.svg.
28
+ // Source: https://github.com/Templarian/MaterialDesign
29
+ // Icons are distributed under the Pictogrammers Free License / Apache 2.0.
30
+ const VIDEO_MODE_POINTER_PNG =
31
+ "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";
32
+ const VIDEO_MODE_POINTER_SOURCE_SIZE = 64;
33
+ const VIDEO_MODE_POINTER_SIZE = 28;
34
+ const VIDEO_MODE_POINTER_HOTSPOT = { x: 18, y: 6 };
35
+ const VIDEO_MODE_CLICK_POINTER_PNG =
36
+ "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";
37
+ const VIDEO_MODE_CLICK_POINTER_SOURCE_SIZE = 64;
38
+ const VIDEO_MODE_CLICK_POINTER_SIZE = 28;
39
+ const VIDEO_MODE_CLICK_POINTER_HOTSPOT = { x: 28, y: 7 };
40
+ const VIDEO_MODE_TEXT_POINTER_PNG =
41
+ "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFGklEQVR4nOxbS0scWRT+Wk3PTDIOzEJm48LxgathZJwHoszoZsBNEERBFDdCIBBXLsSliIu48Se4EcFlNro0voKBCHkICcFHC+4kkMTEmLajuafqnsqt6mr7VnnLB1UfHOrR99y696tzH1R/pwQxRwlijjJEh1SBcxWn0PMv5HOWvxZSMA+qs0XYDXntjbJ3wp7K81PP8XdhPyv1qDgW9kiWPfX4hibCdARQo18Lq9UoOy3svvQZEdaj4fNS2J/CTjxGCEWCyQiguv4Q9iSAz3t5/EnfBf8Jey4spxiRECoSTEZASq2vvLwcjY2NeYU2Nzext7fHl66OV1ZWorY2P3jW19dxcHDgVC3sprDP8pneIREIpgjgSHLGe0VFBRYWFnwLT09PY2RkxCGCOj4xMYGeHv9RUFNToxJwSxo9izr9BVcoArSGVF9fH7LZLAYGBqzrsbGxgp33wY+wCaDOqsOA2xCIhMgI2N/fR1tbm6tAVVWVda+/vx/d3d04OjpCOp1GV1eX9fvU1BQWFxexu7vr8qO6FKSFfS8sC3ulKZEWuPPcaBOgekqF/QV7qToTzc3NWFlZcd1ramrC2toaNHAP9iT4ThqNjU+wCeGhoA3TO8Fnwj4WK7S6uorx8XHnenR0VLfz1NEMAgy3YohiJ/ibsL/xbbb+AXbYUrj+L+wfKrS8vOw4LC0tqf6PhT2EPbmpMzyN80153zvxXYmNEDWCGvUW9jAgAnjC+g42AR8gCdjZ2XEcM5mMWs883B3lzhIBtBs8gr0EZuHeA4SC6QjgZYkaRw0tlfd4wvrABXO5XKE6yJ87m5XHnOfeIWwSjpEfKYFgigB+OL8paiSv03Sels8qOj/IOvhN05hX3/axtM/yd7rPERIKUQwBaigvSXR+Qz4nDAGHcL9tNRKOlXuXvhNk8BAgMBnU+FLoE0D+/JY/SR/1bXst9C4QMB8BDG4YHSkaaDgQCVnNepgEKs9EMAEcaae4YqsAgRvD6/SJ5zynWc8J3DM/H71v+1ydJ0T1RUhtWEoxncaqnWMi/N68EVzEN8Ewn7D8OnnucPdDlN8EVRhttEnE/qtwQgBijoQAxBwJAYg5EgIQcyQEIOZICEDMkRCAmCMhADFHQgBijoQAxBwJAYg5EgIQcyRyeVwMolClG8FF5Auo/w47zysrKzvLr5Cx7Eb9B/lciIKAQvkCpBS7w4Xq6uoch/r6emxvb/Nlh6yDBJAkqmKJDIkkSF2p/k2uiqVDIYp8AdL01xcr2NnZ6Zx3dHRgfn6eL5ul+eGVMJKgX+98gdbW1jwleUtLi6Ug1cC/wl7guuYLNDQ0YHJy0jqfm5tDKpVCe3u7pR0eHBzExsZGno8nX4ByDIzlC5gUS5ORCtQSS1dXV2Nra6ugw8zMDHp7e63z2dlZSz1eCJQvoMwRpKtfhz0n0PxACjIWTAbWC5rcB2gLmClhYnh42LkeGhqy7mmCtMckvyXJPAsw1RUnEEzL5SkCLB28ZsqMC5opM3dhZ51RvtGBNI6CwLrhS8kXUHAojzcD+BjNFzC9DHK+wC2NsovCHsAm7zbsbLBiMJ4vYDoCaEz+Av98gVJZjt56Bv66v1/xLSGK62XQJEcy+jewN0g0BNQIuPQhQI2m3R91mPIEvPkC6mSlqstVErgePqqTNCtHabxTlPH4Zy1xqNyBi8wX4Ajgcn67OQLripkI1YfF1yyjvzb5ApzddRYBagQwCV4CVCW5kXyBrwAAAP//qVxqLgAAAAZJREFUAwCpkwfomMTHxQAAAABJRU5ErkJggg==";
42
+ const VIDEO_MODE_TEXT_POINTER_SOURCE_SIZE = 64;
43
+ const VIDEO_MODE_TEXT_POINTER_SIZE = 28;
44
+ const VIDEO_MODE_TEXT_POINTER_HOTSPOT = { x: 32, y: 32 };
45
+
46
+ export type VideoModeSpan = {
47
+ start: number;
48
+ end: number;
49
+ };
50
+
51
+ export type VideoModeOutputs = {
52
+ player?: string;
53
+ raw?: string;
54
+ rendered?: string;
55
+ };
56
+
57
+ export type VideoModeOutputPaths = {
58
+ metadata: string;
59
+ player: string;
60
+ raw: string;
61
+ rendered: string;
62
+ reportPlayer: string;
63
+ };
64
+
65
+ export type VideoModeSourceRange = {
66
+ start?: number;
67
+ end?: number;
68
+ };
69
+
70
+ export type VideoModeRect = {
71
+ x: number;
72
+ y: number;
73
+ width: number;
74
+ height: number;
75
+ };
76
+
77
+ export type VideoModeViewport = {
78
+ width: number;
79
+ height: number;
80
+ };
81
+
82
+ export type VideoModeHighlight = VideoModeSpan & {
83
+ actionEnd?: number;
84
+ color: string;
85
+ image?: string;
86
+ method?: OverrideableMethod;
87
+ rect: VideoModeRect;
88
+ thickness: number;
89
+ viewport: VideoModeViewport;
90
+ };
91
+
92
+ export type VideoModeMetadata = {
93
+ schemaVersion: 1;
94
+ timebase: "ms";
95
+ deadAir: VideoModeSpan[];
96
+ highlights: VideoModeHighlight[];
97
+ outputs: VideoModeOutputs;
98
+ sourceRange: VideoModeSourceRange;
99
+ };
100
+
101
+ export type VideoModeControls = {
102
+ /**
103
+ * Run invisible video bookkeeping and write the elapsed span as dead air.
104
+ */
105
+ deadAir<T>(action: () => Promise<T>): Promise<T>;
106
+ /** Milliseconds since video-mode started recording metadata for this test. */
107
+ getVideoTimestamp(): number;
108
+ /** Parsed video-mode metadata JSON after the test, or the current in-memory snapshot during the test. */
109
+ metadata(): Promise<VideoModeMetadata>;
110
+ /** Absolute artifact paths for the current test's video-mode outputs. */
111
+ outputPaths(): VideoModeOutputPaths;
112
+ /** Render the video from this source timestamp, in ms. Defaults to the current video timestamp. */
113
+ setStartTime(ms?: number): void;
114
+ /** Render the video until this source timestamp, in ms. Defaults to the current video timestamp. */
115
+ setEndTime(ms?: number): void;
116
+ };
117
+
118
+ export type VideoModePageExtension = {
119
+ videoMode: VideoModeControls;
120
+ };
121
+
122
+ export type VideoModePlugin = Plugin<VideoModePageExtension> & VideoModeControls;
123
+
124
+ export type VideoModeOutlineHighlightStyle = `${number}px solid ${string}`;
125
+
126
+ export type VideoModeHighlightOptions =
127
+ | boolean
128
+ | {
129
+ /** Highlight duration in the rendered video (ms). Default: 1000 */
130
+ duration?: number;
131
+ mode: "pointer";
132
+ }
133
+ | {
134
+ /** Highlight duration in the rendered video (ms). Default: 1000 */
135
+ duration?: number;
136
+ mode: "outline";
137
+ /** Outline style for the rendered video. Default: '3px solid gold' */
138
+ style?: VideoModeOutlineHighlightStyle;
139
+ };
11
140
 
12
141
  export type VideoModeOptions = {
13
- /** Pause duration before action (ms). Default: 1000 */
14
- pauseBefore?: number;
15
- /** Pause duration after test (ms). Default: 3000 */
16
- pauseAfterTest?: number;
17
- /** Highlight style. Default: '3px solid gold' */
18
- highlightStyle?: string;
142
+ /**
143
+ * Render action annotations. `true` uses pointer mode with default options.
144
+ * Default: true
145
+ */
146
+ highlight?: VideoModeHighlightOptions;
147
+ /** Final hold duration in the rendered video (ms). Default: 3000 */
148
+ finalHold?: number;
19
149
  /** Methods to skip highlighting. Default: ['waitFor'] */
20
150
  skipMethods?: OverrideableMethod[];
21
151
  /**
@@ -24,58 +154,1740 @@ export type VideoModeOptions = {
24
154
  * flows that shouldn't be slowed down. Default: []
25
155
  */
26
156
  skipStackFrames?: string[];
157
+ /**
158
+ * Maximum rendered duration for each dead-air span. Longer spans are sped up
159
+ * so they fit within this duration.
160
+ */
161
+ deadAirThreshold?: number;
27
162
  };
28
163
 
29
- /** Highlight element, pause, return disposable that unhighlights */
30
- const setupHighlight = async (locator: Locator, style: string, pauseMs: number) => {
164
+ type VideoModeState = {
165
+ deadAirDepth: number;
166
+ deadAirSpans: VideoModeSpan[];
167
+ highlights: VideoModeHighlight[];
168
+ highlightImageIndex: number;
169
+ outputs: VideoModeOutputs;
170
+ sourceRange: VideoModeSourceRange;
171
+ startedAt?: number;
172
+ };
173
+
174
+ type RenderVideoSegment = {
175
+ start: number;
176
+ end: number;
177
+ speed: number;
178
+ };
179
+
180
+ type VideoInfo = {
181
+ width: number;
182
+ height: number;
183
+ durationMs: number;
184
+ };
185
+
186
+ type VideoFilter = {
187
+ outputLabel: string;
188
+ value: string;
189
+ };
190
+
191
+ type VideoPiece = {
192
+ end: number;
193
+ highlight?: VideoModeHighlight;
194
+ speed: number;
195
+ start: number;
196
+ };
197
+
198
+ type RenderedVideoPiece = VideoPiece & {
199
+ outputEnd: number;
200
+ outputStart: number;
201
+ };
202
+
203
+ type CursorWaypoint = {
204
+ at: number;
205
+ x: number;
206
+ y: number;
207
+ };
208
+
209
+ type CursorTarget = {
210
+ method?: OverrideableMethod;
211
+ outputEnd: number;
212
+ outputStart: number;
213
+ point: { x: number; y: number };
214
+ };
215
+
216
+ type PlannedCursorTarget = CursorTarget & {
217
+ arriveAt: number;
218
+ };
219
+
220
+ type CursorPlan = {
221
+ targets: PlannedCursorTarget[];
222
+ waypoints: CursorWaypoint[];
223
+ };
224
+
225
+ const CURSOR_MOVEMENT_MIN_MS = 200;
226
+ const CURSOR_MOVEMENT_MAX_MS = 1000;
227
+ const CURSOR_MOVEMENT_MIN_IDEAL_MS = 300;
228
+ const CURSOR_MOVEMENT_MAX_IDEAL_MS = 700;
229
+ const CURSOR_MOVEMENT_SPEED_PX_PER_SECOND = 600;
230
+ const CURSOR_REST_BEFORE_ACTION_MS = 200;
231
+ const CURSOR_TARGET_HOLD_IDEAL_MS = 1000;
232
+ const TEXT_CURSOR_HOLD_IDEAL_MS = 800;
233
+ const TEXT_CURSOR_POINTER_TAIL_MS = 200;
234
+
235
+ type HighlightInput = {
236
+ durationMs: number;
237
+ image: string;
238
+ inputIndex: number;
239
+ path: string;
240
+ };
241
+
242
+ type PointerInput = {
243
+ hotspot: { x: number; y: number };
244
+ inputIndex: number;
245
+ path: string;
246
+ size: number;
247
+ sourceSize: number;
248
+ };
249
+
250
+ type ResolvedVideoModeHighlight =
251
+ | {
252
+ color: string;
253
+ durationMs: number;
254
+ mode: "outline";
255
+ thickness: number;
256
+ }
257
+ | {
258
+ color: string;
259
+ durationMs: number;
260
+ mode: "pointer";
261
+ thickness: number;
262
+ }
263
+ | {
264
+ mode: "off";
265
+ };
266
+
267
+ const resolveDeadAirThreshold = (thresholdMs: number | undefined) => {
268
+ if (thresholdMs === undefined) {
269
+ return undefined;
270
+ }
271
+
272
+ if (!Number.isFinite(thresholdMs) || thresholdMs < 0) {
273
+ throw new Error("videoMode deadAirThreshold must be a non-negative number");
274
+ }
275
+
276
+ return thresholdMs;
277
+ };
278
+
279
+ const resolveNonNegativeNumber = (options: {
280
+ defaultValue: number;
281
+ name: string;
282
+ value: number | undefined;
283
+ }) => {
284
+ const value = options.value === undefined ? options.defaultValue : options.value;
285
+
286
+ if (!Number.isFinite(value) || value < 0) {
287
+ throw new Error(`${options.name} must be a non-negative number`);
288
+ }
289
+
290
+ return value;
291
+ };
292
+
293
+ const parseOutlineHighlightStyle = (style: VideoModeOutlineHighlightStyle) => {
294
+ const match = /^([0-9]+(?:\.[0-9]+)?)px solid (.+)$/.exec(style.trim());
295
+
296
+ if (!match) {
297
+ throw new Error("videoMode highlight.style must look like '1px solid yellow'");
298
+ }
299
+
300
+ const thickness = Number(match[1]);
301
+ const color = match[2].trim();
302
+
303
+ if (!Number.isFinite(thickness) || thickness < 0 || color.length === 0) {
304
+ throw new Error("videoMode highlight.style must look like '1px solid yellow'");
305
+ }
306
+
307
+ return { color, thickness };
308
+ };
309
+
310
+ const resolveVideoModeHighlight = (options: VideoModeOptions): ResolvedVideoModeHighlight => {
311
+ const rawHighlight = options.highlight === undefined ? true : options.highlight;
312
+
313
+ if (rawHighlight === false) {
314
+ return { mode: "off" };
315
+ }
316
+
317
+ if (rawHighlight === true) {
318
+ return {
319
+ color: "gold",
320
+ durationMs: resolveNonNegativeNumber({
321
+ defaultValue: 1000,
322
+ name: "videoMode highlight.duration",
323
+ value: undefined,
324
+ }),
325
+ mode: "pointer",
326
+ thickness: 3,
327
+ };
328
+ }
329
+
330
+ if (!rawHighlight || typeof rawHighlight !== "object" || !("mode" in rawHighlight)) {
331
+ throw new Error("videoMode highlight must be true, false, or a highlight options object");
332
+ }
333
+
334
+ const durationMs = resolveNonNegativeNumber({
335
+ defaultValue: 1000,
336
+ name: "videoMode highlight.duration",
337
+ value: rawHighlight.duration,
338
+ });
339
+
340
+ if (rawHighlight.mode === "pointer") {
341
+ return {
342
+ color: "gold",
343
+ durationMs,
344
+ mode: "pointer",
345
+ thickness: 3,
346
+ };
347
+ }
348
+
349
+ if (rawHighlight.mode === "outline") {
350
+ const parsed = parseOutlineHighlightStyle(rawHighlight.style || "3px solid gold");
351
+
352
+ return {
353
+ color: parsed.color,
354
+ durationMs,
355
+ mode: "outline",
356
+ thickness: parsed.thickness,
357
+ };
358
+ }
359
+
360
+ throw new Error("videoMode highlight.mode must be 'pointer' or 'outline'");
361
+ };
362
+
363
+ const resolveVideoTimestamp = (name: string, ms: number) => {
364
+ if (!Number.isFinite(ms) || ms < 0) {
365
+ throw new Error(`videoMode.${name}() requires a non-negative timestamp`);
366
+ }
367
+
368
+ return Math.round(ms);
369
+ };
370
+
371
+ const normalizeSourceRange = (sourceRange: VideoModeSourceRange): VideoModeSourceRange => {
372
+ const normalized: VideoModeSourceRange = {};
373
+
374
+ if (sourceRange.start !== undefined) {
375
+ normalized.start = Math.round(sourceRange.start);
376
+ }
377
+
378
+ if (sourceRange.end !== undefined) {
379
+ normalized.end = Math.round(sourceRange.end);
380
+ }
381
+
382
+ return normalized;
383
+ };
384
+
385
+ const sourceRangeIsSet = (sourceRange: VideoModeSourceRange) => {
386
+ return sourceRange.start !== undefined || sourceRange.end !== undefined;
387
+ };
388
+
389
+ const metadataFor = (state: VideoModeState): VideoModeMetadata => {
390
+ return {
391
+ deadAir: mergeVideoSpans(state.deadAirSpans),
392
+ highlights: normalizeVideoHighlights(state.highlights),
393
+ outputs: state.outputs,
394
+ schemaVersion: 1,
395
+ sourceRange: normalizeSourceRange(state.sourceRange),
396
+ timebase: "ms",
397
+ };
398
+ };
399
+
400
+ const videoModeOutputPaths = (testInfo: TestInfo): VideoModeOutputPaths => {
401
+ return {
402
+ metadata: join(testInfo.outputDir, VIDEO_MODE_METADATA_FILE),
403
+ player: join(testInfo.outputDir, VIDEO_MODE_PLAYER_FILE),
404
+ raw: join(testInfo.outputDir, VIDEO_MODE_RAW_FILE),
405
+ rendered: join(testInfo.outputDir, VIDEO_MODE_RENDERED_FILE),
406
+ reportPlayer: join(testInfo.outputDir, VIDEO_MODE_REPORT_PLAYER_FILE),
407
+ };
408
+ };
409
+
410
+ const readVideoModeMetadata = async (
411
+ path: string,
412
+ fallback: () => VideoModeMetadata,
413
+ ): Promise<VideoModeMetadata> => {
31
414
  try {
32
- await locator.evaluate((el, s) => {
33
- const prev = el.getAttribute("style") || "";
34
- el.setAttribute("data-video-prev-style", prev);
35
- el.setAttribute(
36
- "style",
37
- `${prev}; outline: ${s} !important; outline-offset: 2px !important;`,
38
- );
39
- }, style);
415
+ return JSON.parse(await readFile(path, "utf8")) as VideoModeMetadata;
416
+ } catch (error) {
417
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
418
+ return fallback();
419
+ }
420
+
421
+ throw error;
422
+ }
423
+ };
424
+
425
+ const recordHighlight = async (options: {
426
+ color: string;
427
+ durationMs: number;
428
+ locator: Locator;
429
+ method: OverrideableMethod;
430
+ state: VideoModeState;
431
+ testInfo: TestInfo;
432
+ thickness: number;
433
+ }) => {
434
+ if (options.state.startedAt === undefined || options.durationMs <= 0) {
435
+ return;
436
+ }
437
+
438
+ if (!(await locatorIsAttached(options.locator))) {
439
+ return;
440
+ }
441
+
442
+ try {
443
+ const snapshot = await options.locator.evaluate((element) => {
444
+ const rect = element.getBoundingClientRect();
445
+ return {
446
+ rect: {
447
+ height: rect.height,
448
+ width: rect.width,
449
+ x: rect.left,
450
+ y: rect.top,
451
+ },
452
+ viewport: {
453
+ height: window.innerHeight,
454
+ width: window.innerWidth,
455
+ },
456
+ };
457
+ });
458
+
459
+ if (
460
+ snapshot.rect.width <= 0 ||
461
+ snapshot.rect.height <= 0 ||
462
+ snapshot.viewport.width <= 0 ||
463
+ snapshot.viewport.height <= 0
464
+ ) {
465
+ return;
466
+ }
467
+
468
+ const image = `video-mode-highlight-${options.state.highlightImageIndex}.png`;
469
+ options.state.highlightImageIndex += 1;
470
+ const imagePath = join(options.testInfo.outputDir, image);
471
+ await mkdir(options.testInfo.outputDir, { recursive: true });
472
+ await options.locator.page().screenshot({ path: imagePath, scale: "css" });
473
+
474
+ const start = Math.round(performance.now() - options.state.startedAt);
475
+ const highlight: VideoModeHighlight = {
476
+ color: options.color,
477
+ end: start + Math.round(options.durationMs),
478
+ image,
479
+ method: options.method,
480
+ rect: snapshot.rect,
481
+ start,
482
+ thickness: options.thickness,
483
+ viewport: snapshot.viewport,
484
+ };
485
+ options.state.highlights.push(highlight);
486
+ return highlight;
40
487
  } catch {
41
- // Element may not be ready yet, ignore
488
+ // Element may disappear between the actionability wait and the snapshot.
489
+ }
490
+ };
491
+
492
+ const recordDeadAirSpan = (state: VideoModeState, span: VideoModeSpan) => {
493
+ const start = Math.round(span.start);
494
+ const end = Math.round(span.end);
495
+
496
+ if (end > start) {
497
+ state.deadAirSpans.push({ end, start });
498
+ }
499
+ };
500
+
501
+ const recordDeadAir = async <T>(
502
+ state: VideoModeState,
503
+ action: () => Promise<T>,
504
+ ) => {
505
+ if (state.startedAt === undefined || state.deadAirDepth > 0) {
506
+ return await action();
507
+ }
508
+
509
+ const start = performance.now() - state.startedAt;
510
+ state.deadAirDepth += 1;
511
+
512
+ try {
513
+ return await action();
514
+ } finally {
515
+ state.deadAirDepth -= 1;
516
+ const end = performance.now() - state.startedAt;
517
+ recordDeadAirSpan(state, {
518
+ end: Math.round(end),
519
+ start: Math.round(start),
520
+ });
42
521
  }
43
- await new Promise((resolve) => setTimeout(resolve, pauseMs));
522
+ };
523
+
524
+ const mergeVideoSpans = (spans: VideoModeSpan[]) => {
525
+ const sorted = spans
526
+ .map((span) => ({
527
+ end: Math.round(span.end),
528
+ start: Math.round(span.start),
529
+ }))
530
+ .filter((span) => span.end > span.start)
531
+ .sort((left, right) => left.start - right.start || left.end - right.end);
532
+
533
+ const merged: VideoModeSpan[] = [];
534
+
535
+ for (const span of sorted) {
536
+ const previous = merged[merged.length - 1];
537
+
538
+ if (previous && span.start <= previous.end) {
539
+ previous.end = Math.max(previous.end, span.end);
540
+ continue;
541
+ }
542
+
543
+ merged.push(span);
544
+ }
545
+
546
+ return merged;
547
+ };
548
+
549
+ const normalizeVideoHighlights = (highlights: VideoModeHighlight[]) => {
550
+ return highlights
551
+ .map((highlight) => ({
552
+ ...highlight,
553
+ actionEnd:
554
+ highlight.actionEnd === undefined
555
+ ? undefined
556
+ : Math.max(Math.round(highlight.actionEnd), Math.round(highlight.start)),
557
+ end: Math.round(highlight.end),
558
+ rect: {
559
+ height: Math.round(highlight.rect.height),
560
+ width: Math.round(highlight.rect.width),
561
+ x: Math.round(highlight.rect.x),
562
+ y: Math.round(highlight.rect.y),
563
+ },
564
+ start: Math.round(highlight.start),
565
+ thickness: Math.round(highlight.thickness),
566
+ viewport: {
567
+ height: Math.round(highlight.viewport.height),
568
+ width: Math.round(highlight.viewport.width),
569
+ },
570
+ }))
571
+ .filter((highlight) => highlight.end > highlight.start)
572
+ .filter((highlight) => highlight.rect.width > 0 && highlight.rect.height > 0)
573
+ .filter((highlight) => highlight.viewport.width > 0 && highlight.viewport.height > 0)
574
+ .sort((left, right) => left.start - right.start || left.end - right.end);
575
+ };
576
+
577
+ const formatSeconds = (ms: number) => {
578
+ const value = (ms / 1000).toFixed(3).replace(/\.?0+$/, "");
579
+ return value || "0";
580
+ };
581
+
582
+ const formatFilterNumber = (value: number) => {
583
+ return Number(value.toFixed(6)).toString();
584
+ };
585
+
586
+ const clipVideoSpan = (span: VideoModeSpan, range: VideoModeSpan): VideoModeSpan | undefined => {
587
+ const start = Math.max(range.start, Math.min(Math.round(span.start), range.end));
588
+ const end = Math.max(range.start, Math.min(Math.round(span.end), range.end));
589
+
590
+ if (end <= start) {
591
+ return undefined;
592
+ }
593
+
594
+ return { end, start };
595
+ };
596
+
597
+ const videoSpansOverlap = (left: VideoModeSpan, right: VideoModeSpan) => {
598
+ return left.start < right.end && right.start < left.end;
599
+ };
600
+
601
+ const deadAirSpeed = (span: VideoModeSpan, thresholdMs: number) => {
602
+ const duration = span.end - span.start;
603
+
604
+ if (duration <= thresholdMs) {
605
+ return 1;
606
+ }
607
+
608
+ if (thresholdMs === 0) {
609
+ return Infinity;
610
+ }
611
+
612
+ return duration / thresholdMs;
613
+ };
614
+
615
+ const locatorIsAttached = async (locator: Locator) => {
616
+ try {
617
+ return (await locator.count()) > 0;
618
+ } catch {
619
+ return false;
620
+ }
621
+ };
622
+
623
+ const recordAttachedWaitFromTiming = (
624
+ state: VideoModeState,
625
+ timing: { actionStartedAt: number; attachedAt?: number; attachedAtStart: boolean },
626
+ ) => {
627
+ if (state.startedAt === undefined || timing.attachedAtStart) {
628
+ return;
629
+ }
630
+
631
+ if (timing.attachedAt === undefined) {
632
+ return;
633
+ }
634
+
635
+ const start = Math.round(timing.actionStartedAt - state.startedAt);
636
+ const end = Math.round(timing.attachedAt - state.startedAt);
637
+
638
+ recordDeadAirSpan(state, { end, start });
639
+ };
640
+
641
+ const recordActionElapsedDeadAirFromTiming = (
642
+ state: VideoModeState,
643
+ timing: Pick<ActionTiming, "actionStartedAt">,
644
+ options: { minimumMs: number },
645
+ ) => {
646
+ if (state.startedAt === undefined) {
647
+ return;
648
+ }
649
+
650
+ const start = Math.round(timing.actionStartedAt - state.startedAt);
651
+ const end = Math.round(performance.now() - state.startedAt);
652
+
653
+ if (end - start < options.minimumMs) {
654
+ return;
655
+ }
656
+
657
+ recordDeadAirSpan(state, { end, start });
658
+ };
659
+
660
+ const recordMiddlewareWaitBeforeVideoMode = (
661
+ state: VideoModeState,
662
+ timing: ActionTiming,
663
+ ) => {
664
+ if (state.startedAt === undefined) {
665
+ return;
666
+ }
667
+
668
+ const currentMiddleware = [...timing.middlewares]
669
+ .reverse()
670
+ .find((middleware) => middleware.name === "video-mode" && middleware.endedAt === undefined);
671
+
672
+ if (!currentMiddleware) {
673
+ return;
674
+ }
675
+
676
+ const start = Math.round(timing.actionStartedAt - state.startedAt);
677
+ const end = Math.round(currentMiddleware.startedAt - state.startedAt);
678
+
679
+ recordDeadAirSpan(state, { end, start });
680
+ };
681
+
682
+ const renderVideoSegments = (options: {
683
+ deadAir: VideoModeSpan[];
684
+ finalEnd: number;
685
+ start: number;
686
+ thresholdMs?: number;
687
+ }): RenderVideoSegment[] => {
688
+ const finalEnd = Math.max(0, Math.round(options.finalEnd));
689
+ const start = Math.max(0, Math.min(Math.round(options.start), finalEnd));
690
+
691
+ if (finalEnd <= start) {
692
+ return [];
693
+ }
694
+
695
+ if (options.thresholdMs === undefined) {
696
+ return [{ end: finalEnd, speed: 1, start }];
697
+ }
698
+
699
+ const thresholdMs = options.thresholdMs;
700
+ const deadAir = mergeVideoSpans(
701
+ options.deadAir
702
+ .map((span) => clipVideoSpan(span, { end: finalEnd, start }))
703
+ .filter((span): span is VideoModeSpan => Boolean(span)),
704
+ );
705
+ const boundaries = new Set([start, finalEnd]);
706
+
707
+ for (const span of deadAir) {
708
+ boundaries.add(span.start);
709
+ boundaries.add(span.end);
710
+ }
711
+
712
+ const sortedBoundaries = [...boundaries].sort((left, right) => left - right);
713
+ const segments: RenderVideoSegment[] = [];
714
+
715
+ for (let index = 0; index < sortedBoundaries.length - 1; index += 1) {
716
+ const start = sortedBoundaries[index];
717
+ const end = sortedBoundaries[index + 1];
718
+
719
+ if (end <= start) {
720
+ continue;
721
+ }
722
+
723
+ const deadAirSpan = deadAir.find((span) => videoSpansOverlap(span, { end, start }));
724
+ const speed = deadAirSpan ? deadAirSpeed(deadAirSpan, thresholdMs) : 1;
725
+
726
+ if (!Number.isFinite(speed)) {
727
+ continue;
728
+ }
729
+
730
+ const previous = segments[segments.length - 1];
731
+
732
+ if (previous && previous.end === start && previous.speed === speed) {
733
+ previous.end = end;
734
+ continue;
735
+ }
736
+
737
+ segments.push({ end, speed, start });
738
+ }
739
+
740
+ return segments;
741
+ };
742
+
743
+ const videoPieces = (options: {
744
+ highlights: VideoModeHighlight[];
745
+ segments: RenderVideoSegment[];
746
+ }): VideoPiece[] => {
747
+ const pieces: VideoPiece[] = [];
748
+ const frameMs = 50;
749
+
750
+ for (const segment of options.segments) {
751
+ let cursor = segment.start;
752
+ const highlights = options.highlights.filter(
753
+ (highlight) => highlight.start >= segment.start && highlight.start < segment.end,
754
+ );
755
+
756
+ for (let index = 0; index < highlights.length; index += 1) {
757
+ const highlight = highlights[index];
758
+ const nextHighlight = highlights[index + 1];
759
+
760
+ if (highlight.start > cursor) {
761
+ pieces.push({ end: highlight.start, speed: segment.speed, start: cursor });
762
+ }
763
+
764
+ let frameStart = Math.max(segment.start, highlight.start - frameMs);
765
+ let frameEnd = highlight.start;
766
+
767
+ if (frameEnd <= frameStart) {
768
+ frameStart = highlight.start;
769
+ frameEnd = Math.min(segment.end, frameStart + frameMs);
770
+ }
771
+
772
+ if (frameEnd > frameStart) {
773
+ pieces.push({ end: frameEnd, highlight, speed: segment.speed, start: frameStart });
774
+ }
775
+
776
+ let nextCursor = Math.max(highlight.start, highlight.actionEnd || highlight.start);
777
+
778
+ if (nextHighlight && highlight.end > nextHighlight.start) {
779
+ nextCursor = Math.max(nextCursor, nextHighlight.start);
780
+ }
781
+
782
+ cursor = Math.min(segment.end, nextCursor);
783
+ }
784
+
785
+ if (segment.end > cursor) {
786
+ pieces.push({ end: segment.end, speed: segment.speed, start: cursor });
787
+ }
788
+ }
789
+
790
+ return pieces.filter((piece) => piece.end > piece.start);
791
+ };
792
+
793
+ const scaleHighlight = (highlight: VideoModeHighlight, video: { width: number; height: number }) => {
794
+ const scale = Math.min(
795
+ video.width / highlight.viewport.width,
796
+ video.height / highlight.viewport.height,
797
+ );
798
+ const x = Math.max(0, Math.round(highlight.rect.x * scale));
799
+ const y = Math.max(0, Math.round(highlight.rect.y * scale));
800
+ const width = Math.max(1, Math.round(highlight.rect.width * scale));
801
+ const height = Math.max(1, Math.round(highlight.rect.height * scale));
44
802
 
45
803
  return {
46
- [Symbol.dispose]: () => {
47
- // Fire-and-forget cleanup - don't wait for it
48
- locator
49
- .evaluate((el) => {
50
- const prev = el.getAttribute("data-video-prev-style");
51
- if (typeof prev === "string") {
52
- el.setAttribute("style", prev);
53
- el.removeAttribute("data-video-prev-style");
54
- }
55
- })
56
- .catch(() => {
57
- // Element may be gone or not actionable, ignore
58
- });
59
- },
804
+ height: Math.min(height, Math.max(1, video.height - y)),
805
+ width: Math.min(width, Math.max(1, video.width - x)),
806
+ x,
807
+ y,
808
+ };
809
+ };
810
+
811
+ const scaledViewportSize = (
812
+ viewport: VideoModeViewport,
813
+ video: { width: number; height: number },
814
+ ) => {
815
+ const scale = Math.min(video.width / viewport.width, video.height / viewport.height);
816
+
817
+ return {
818
+ height: Math.max(1, Math.round(viewport.height * scale)),
819
+ width: Math.max(1, Math.round(viewport.width * scale)),
820
+ };
821
+ };
822
+
823
+ const drawboxFilter = (highlight: VideoModeHighlight, video: { width: number; height: number }) => {
824
+ const rect = scaleHighlight(highlight, video);
825
+ return [
826
+ `drawbox=x=${rect.x}`,
827
+ `y=${rect.y}`,
828
+ `w=${rect.width}`,
829
+ `h=${rect.height}`,
830
+ `color=${highlight.color}`,
831
+ `t=${Math.max(1, Math.round(highlight.thickness))}`,
832
+ ].join(":");
833
+ };
834
+
835
+ const renderedPieceDuration = (piece: VideoPiece) => {
836
+ const sourceDuration = (piece.end - piece.start) / piece.speed;
837
+
838
+ if (!piece.highlight) {
839
+ return sourceDuration;
840
+ }
841
+
842
+ const highlightDuration = piece.highlight.end - piece.highlight.start;
843
+
844
+ if (piece.highlight.image) {
845
+ return highlightDuration;
846
+ }
847
+
848
+ return Math.max(sourceDuration, highlightDuration);
849
+ };
850
+
851
+ const renderedVideoPieces = (pieces: VideoPiece[]) => {
852
+ let cursor = 0;
853
+ const rendered: RenderedVideoPiece[] = [];
854
+
855
+ for (const piece of pieces) {
856
+ const duration = renderedPieceDuration(piece);
857
+ rendered.push({
858
+ ...piece,
859
+ outputEnd: cursor + duration,
860
+ outputStart: cursor,
861
+ });
862
+ cursor += duration;
863
+ }
864
+
865
+ return rendered;
866
+ };
867
+
868
+ const highlightCursorPoint = (
869
+ highlight: VideoModeHighlight,
870
+ video: { width: number; height: number },
871
+ ) => {
872
+ const rect = scaleHighlight(highlight, video);
873
+
874
+ return {
875
+ x: Math.max(0, Math.min(video.width - 1, rect.x + rect.width / 2)),
876
+ y: Math.max(0, Math.min(video.height - 1, rect.y + rect.height / 2)),
877
+ };
878
+ };
879
+
880
+ const pushCursorWaypoint = (waypoints: CursorWaypoint[], waypoint: CursorWaypoint) => {
881
+ const rounded = {
882
+ at: Math.round(waypoint.at),
883
+ x: Math.round(waypoint.x),
884
+ y: Math.round(waypoint.y),
885
+ };
886
+ const previous = waypoints[waypoints.length - 1];
887
+
888
+ if (previous && previous.at === rounded.at) {
889
+ previous.x = rounded.x;
890
+ previous.y = rounded.y;
891
+ return;
892
+ }
893
+
894
+ waypoints.push(rounded);
895
+ };
896
+
897
+ const cursorTargets = (options: {
898
+ highlights: VideoModeHighlight[];
899
+ pieces: RenderedVideoPiece[];
900
+ video: { width: number; height: number };
901
+ }) => {
902
+ const targets: CursorTarget[] = [];
903
+
904
+ for (const highlight of options.highlights) {
905
+ const piece = options.pieces.find((candidate) => candidate.highlight === highlight);
906
+
907
+ if (!piece) {
908
+ continue;
909
+ }
910
+
911
+ targets.push({
912
+ method: highlight.method,
913
+ outputEnd: piece.outputEnd,
914
+ outputStart: piece.outputStart,
915
+ point: highlightCursorPoint(highlight, options.video),
916
+ });
917
+ }
918
+
919
+ return targets;
920
+ };
921
+
922
+ const cursorMovementIdealDuration = (options: {
923
+ currentPoint: { x: number; y: number };
924
+ target: CursorTarget;
925
+ }) => {
926
+ const distance = Math.hypot(
927
+ options.target.point.x - options.currentPoint.x,
928
+ options.target.point.y - options.currentPoint.y,
929
+ );
930
+ const duration = (distance / CURSOR_MOVEMENT_SPEED_PX_PER_SECOND) * 1000;
931
+
932
+ return Math.max(
933
+ CURSOR_MOVEMENT_MIN_IDEAL_MS,
934
+ Math.min(CURSOR_MOVEMENT_MAX_IDEAL_MS, duration),
935
+ );
936
+ };
937
+
938
+ const cursorArrivalDeadline = (options: {
939
+ currentPoint: { x: number; y: number };
940
+ earliestStart: number;
941
+ target: CursorTarget;
942
+ }) => {
943
+ const target = options.target;
944
+ const latestArrivalWithMinimumRest = Math.max(
945
+ target.outputStart,
946
+ target.outputEnd - CURSOR_REST_BEFORE_ACTION_MS,
947
+ );
948
+ const idealHoldArrival = Math.max(
949
+ target.outputStart,
950
+ target.outputEnd - CURSOR_TARGET_HOLD_IDEAL_MS,
951
+ );
952
+ const readableMovementArrival =
953
+ options.earliestStart + cursorMovementIdealDuration(options);
954
+
955
+ return Math.min(
956
+ latestArrivalWithMinimumRest,
957
+ Math.max(idealHoldArrival, readableMovementArrival),
958
+ );
959
+ };
960
+
961
+ const cursorMovementTiming = (options: {
962
+ currentPoint: { x: number; y: number };
963
+ earliestStart: number;
964
+ target: CursorTarget;
965
+ }) => {
966
+ const deadline = cursorArrivalDeadline(options);
967
+ const available = Math.max(0, deadline - options.earliestStart);
968
+ const idealDuration = Math.min(cursorMovementIdealDuration(options), CURSOR_MOVEMENT_MAX_MS);
969
+ const duration =
970
+ available < CURSOR_MOVEMENT_MIN_MS ? available : Math.min(idealDuration, available);
971
+ const startAt = Math.max(options.earliestStart, deadline - duration);
972
+
973
+ return {
974
+ arriveAt: startAt + duration,
975
+ startAt,
60
976
  };
61
977
  };
62
978
 
63
979
  /**
64
- * Highlights elements before actions and pauses for video recording.
65
- * Also pauses after tests complete for better video endings.
980
+ * Plan cursor motion backwards from each action's click/commit moment.
981
+ *
982
+ * The cursor starts in the center and switches to a target-specific shape only
983
+ * after arriving. Movement aims for a readable distance-based speed, clamped to
984
+ * a short 300-700ms range, while still compressing toward 200ms when the
985
+ * existing video timeline has no room. The arrival point preserves an ideal 1s
986
+ * target hold when possible and otherwise gives the hand/text cursor most of
987
+ * the configured highlight hold without extending product interaction time.
66
988
  */
67
- export const videoMode = (options: VideoModeOptions = {}): Plugin => {
68
- const pauseBefore = options.pauseBefore || 1000;
69
- const pauseAfterTest = options.pauseAfterTest || 3000;
70
- const highlightStyle = options.highlightStyle || "3px solid gold";
989
+ const cursorPlan = (
990
+ targets: CursorTarget[],
991
+ video: { width: number; height: number },
992
+ ): CursorPlan => {
993
+ const waypoints: CursorWaypoint[] = [];
994
+ const plannedTargets: PlannedCursorTarget[] = [];
995
+ let currentPoint = {
996
+ x: video.width / 2,
997
+ y: video.height / 2,
998
+ };
999
+ let earliestStart = 0;
1000
+
1001
+ if (targets.length === 0) {
1002
+ return { targets: plannedTargets, waypoints };
1003
+ }
1004
+
1005
+ pushCursorWaypoint(waypoints, {
1006
+ at: 0,
1007
+ x: currentPoint.x,
1008
+ y: currentPoint.y,
1009
+ });
1010
+
1011
+ for (let index = 0; index < targets.length; index += 1) {
1012
+ const target = targets[index];
1013
+ const nextTarget = targets[index + 1];
1014
+ const movement = cursorMovementTiming({ currentPoint, earliestStart, target });
1015
+ const holdEnd = nextTarget
1016
+ ? Math.min(target.outputEnd, nextTarget.outputStart)
1017
+ : target.outputEnd;
1018
+
1019
+ pushCursorWaypoint(waypoints, {
1020
+ at: movement.startAt,
1021
+ x: currentPoint.x,
1022
+ y: currentPoint.y,
1023
+ });
1024
+ pushCursorWaypoint(waypoints, {
1025
+ at: movement.arriveAt,
1026
+ x: target.point.x,
1027
+ y: target.point.y,
1028
+ });
1029
+ pushCursorWaypoint(waypoints, {
1030
+ at: Math.max(holdEnd, movement.arriveAt),
1031
+ x: target.point.x,
1032
+ y: target.point.y,
1033
+ });
1034
+ plannedTargets.push({
1035
+ ...target,
1036
+ arriveAt: movement.arriveAt,
1037
+ });
1038
+
1039
+ currentPoint = target.point;
1040
+ earliestStart = Math.max(earliestStart, target.outputEnd);
1041
+ }
1042
+
1043
+ return { targets: plannedTargets, waypoints };
1044
+ };
1045
+
1046
+ const methodCursorSpans = (targets: PlannedCursorTarget[], methods: OverrideableMethod[]) => {
1047
+ return targets
1048
+ .filter((target) => target.method !== undefined && methods.includes(target.method))
1049
+ .map((target) => ({
1050
+ end: target.outputEnd,
1051
+ start: target.arriveAt,
1052
+ }))
1053
+ .filter((span) => span.end > span.start);
1054
+ };
1055
+
1056
+ const textCursorSpans = (targets: PlannedCursorTarget[]) => {
1057
+ return targets
1058
+ .filter((target) => target.method === "fill" || target.method === "type")
1059
+ .map((target) => ({
1060
+ end: Math.min(
1061
+ target.arriveAt + TEXT_CURSOR_HOLD_IDEAL_MS,
1062
+ Math.max(target.arriveAt, target.outputEnd - TEXT_CURSOR_POINTER_TAIL_MS),
1063
+ ),
1064
+ start: target.arriveAt,
1065
+ }))
1066
+ .filter((span) => span.end > span.start);
1067
+ };
1068
+
1069
+ const cursorActivitySpan = (
1070
+ targets: CursorTarget[],
1071
+ waypoints: CursorWaypoint[],
1072
+ ): VideoModeSpan | undefined => {
1073
+ if (targets.length === 0 || waypoints.length === 0) {
1074
+ return undefined;
1075
+ }
1076
+
1077
+ const lastTarget = targets[targets.length - 1];
1078
+ const tailDuration = Math.max(0, lastTarget.outputEnd - lastTarget.outputStart);
1079
+
1080
+ return {
1081
+ end: lastTarget.outputEnd + tailDuration,
1082
+ start: waypoints[0].at,
1083
+ };
1084
+ };
1085
+
1086
+ const cursorExpression = (waypoints: CursorWaypoint[], property: "x" | "y") => {
1087
+ let expression = formatFilterNumber(waypoints[waypoints.length - 1][property]);
1088
+
1089
+ for (let index = waypoints.length - 2; index >= 0; index -= 1) {
1090
+ const from = waypoints[index];
1091
+ const to = waypoints[index + 1];
1092
+
1093
+ if (to.at <= from.at) {
1094
+ continue;
1095
+ }
1096
+
1097
+ const start = formatSeconds(from.at);
1098
+ const end = formatSeconds(to.at);
1099
+ const progress = `((t-${start})/(${end}-${start}))`;
1100
+ const eased = `((${progress})*(${progress})*(3-2*(${progress})))`;
1101
+ const delta = to[property] - from[property];
1102
+ const value =
1103
+ delta === 0
1104
+ ? formatFilterNumber(from[property])
1105
+ : `(${formatFilterNumber(from[property])}+(${formatFilterNumber(delta)})*${eased})`;
1106
+
1107
+ expression = `if(between(t\\,${start}\\,${end})\\,${value}\\,${expression})`;
1108
+ }
1109
+
1110
+ return expression;
1111
+ };
1112
+
1113
+ const cursorOverlayFilters = (options: {
1114
+ enable: string;
1115
+ inputLabel: string;
1116
+ outputLabel: string;
1117
+ pointerInput: PointerInput;
1118
+ waypoints: CursorWaypoint[];
1119
+ }) => {
1120
+ if (options.waypoints.length === 0) {
1121
+ return `[${options.inputLabel}]null[${options.outputLabel}]`;
1122
+ }
1123
+
1124
+ const pointerScale = options.pointerInput.size / options.pointerInput.sourceSize;
1125
+ const x = `(${cursorExpression(options.waypoints, "x")})-${formatFilterNumber(
1126
+ options.pointerInput.hotspot.x * pointerScale,
1127
+ )}`;
1128
+ const y = `(${cursorExpression(options.waypoints, "y")})-${formatFilterNumber(
1129
+ options.pointerInput.hotspot.y * pointerScale,
1130
+ )}`;
1131
+
1132
+ return [
1133
+ `[${options.pointerInput.inputIndex}:v]scale=w=${options.pointerInput.size}:h=${options.pointerInput.size},format=rgba[pointercursor]`,
1134
+ [
1135
+ `[${options.inputLabel}][pointercursor]overlay=x='${x}'`,
1136
+ `y='${y}'`,
1137
+ "eval=frame",
1138
+ "eof_action=pass",
1139
+ `enable='${options.enable}'[${options.outputLabel}]`,
1140
+ ].join(":"),
1141
+ ].join(";");
1142
+ };
1143
+
1144
+ const videoSpanExpression = (spans: VideoModeSpan[]) => {
1145
+ return spans
1146
+ .map((span) => `between(t\\,${formatSeconds(span.start)}\\,${formatSeconds(span.end)})`)
1147
+ .join("+");
1148
+ };
1149
+
1150
+ const renderedVideoFilter = (options: {
1151
+ clickPointerInput?: PointerInput;
1152
+ cursorPointerInput?: PointerInput;
1153
+ finalHoldMs: number;
1154
+ highlightMode: "outline" | "pointer";
1155
+ highlightInputs: HighlightInput[];
1156
+ highlights: VideoModeHighlight[];
1157
+ segments: RenderVideoSegment[];
1158
+ textPointerInput?: PointerInput;
1159
+ video: { width: number; height: number };
1160
+ }): VideoFilter | undefined => {
1161
+ const highlightInputByImage = new Map(
1162
+ options.highlightInputs.map((input) => [input.image, input]),
1163
+ );
1164
+ const pieces = videoPieces({
1165
+ highlights: options.highlights,
1166
+ segments: options.segments,
1167
+ });
1168
+ const renderedPieces = renderedVideoPieces(pieces);
1169
+ const targets = cursorTargets({
1170
+ highlights: options.highlights,
1171
+ pieces: renderedPieces,
1172
+ video: options.video,
1173
+ });
1174
+ const plan = cursorPlan(targets, options.video);
1175
+ const clickSpans = methodCursorSpans(plan.targets, ["click"]);
1176
+ const textSpans = textCursorSpans(plan.targets);
1177
+ const activitySpan = cursorActivitySpan(targets, plan.waypoints);
1178
+ const clickSpanExpression = videoSpanExpression(clickSpans);
1179
+ const textSpanExpression = videoSpanExpression(textSpans);
1180
+
1181
+ if (pieces.length === 0) {
1182
+ return undefined;
1183
+ }
1184
+
1185
+ const filters: string[] = [];
1186
+ const labels: string[] = [];
1187
+
1188
+ for (let index = 0; index < pieces.length; index += 1) {
1189
+ const piece = pieces[index];
1190
+ const label = `render${index}`;
1191
+ labels.push(`[${label}]`);
1192
+
1193
+ const operations: string[] = [];
1194
+
1195
+ if (piece.highlight?.image && highlightInputByImage.has(piece.highlight.image)) {
1196
+ const input = highlightInputByImage.get(piece.highlight.image)!;
1197
+ const scaledViewport = scaledViewportSize(piece.highlight.viewport, options.video);
1198
+ operations.push(
1199
+ `[${input.inputIndex}:v]scale=w=${scaledViewport.width}:h=${scaledViewport.height}`,
1200
+ );
1201
+ operations.push(
1202
+ `pad=w=${options.video.width}:h=${options.video.height}:x=0:y=0:color=gray`,
1203
+ );
1204
+ if (options.highlightMode === "outline") {
1205
+ operations.push(drawboxFilter(piece.highlight, options.video));
1206
+ }
1207
+ operations.push(
1208
+ `trim=start=0:end=${formatSeconds(piece.highlight.end - piece.highlight.start)}`,
1209
+ );
1210
+ operations.push("setpts=PTS-STARTPTS");
1211
+ } else {
1212
+ operations.push(
1213
+ `[0:v]trim=start=${formatSeconds(piece.start)}:end=${formatSeconds(piece.end)}`,
1214
+ );
1215
+ operations.push(`setpts=(PTS-STARTPTS)/${formatFilterNumber(piece.speed)}`);
1216
+ }
1217
+
1218
+ if (piece.highlight && !piece.highlight.image) {
1219
+ const sourceDuration = (piece.end - piece.start) / piece.speed;
1220
+ if (options.highlightMode === "outline") {
1221
+ operations.push(drawboxFilter(piece.highlight, options.video));
1222
+ }
1223
+ operations.push(
1224
+ `tpad=stop_mode=clone:stop_duration=${formatSeconds(
1225
+ Math.max(0, piece.highlight.end - piece.highlight.start - sourceDuration),
1226
+ )}`,
1227
+ );
1228
+ }
1229
+
1230
+ filters.push(`${operations.join(",")}[${label}]`);
1231
+ }
1232
+
1233
+ const concatLabel = labels.length === 1 ? labels[0].slice(1, -1) : "renderconcat";
1234
+
1235
+ if (labels.length > 1) {
1236
+ filters.push(`${labels.join("")}concat=n=${labels.length}:v=1:a=0[${concatLabel}]`);
1237
+ }
1238
+
1239
+ const finalHoldMs = Math.max(0, Math.round(options.finalHoldMs));
1240
+ const outputLabel = finalHoldMs > 0 ? "renderout" : concatLabel;
1241
+
1242
+ if (finalHoldMs > 0) {
1243
+ filters.push(
1244
+ `[${concatLabel}]tpad=stop_mode=clone:stop_duration=${formatSeconds(finalHoldMs)}[${outputLabel}]`,
1245
+ );
1246
+ }
1247
+
1248
+ if (
1249
+ options.highlightMode === "pointer" &&
1250
+ options.cursorPointerInput &&
1251
+ plan.waypoints.length > 0 &&
1252
+ activitySpan
1253
+ ) {
1254
+ const cursorOutputLabel = "renderpointer";
1255
+ const cursorActivityExpression = `between(t\\,${formatSeconds(activitySpan.start)}\\,${formatSeconds(activitySpan.end)})`;
1256
+ const specialCursorExpression = [clickSpanExpression, textSpanExpression]
1257
+ .filter(Boolean)
1258
+ .join("+");
1259
+ const cursorEnable = specialCursorExpression
1260
+ ? `${cursorActivityExpression}*not(${specialCursorExpression})`
1261
+ : cursorActivityExpression;
1262
+ filters.push(
1263
+ cursorOverlayFilters({
1264
+ enable: cursorEnable,
1265
+ inputLabel: outputLabel,
1266
+ outputLabel: cursorOutputLabel,
1267
+ pointerInput: options.cursorPointerInput,
1268
+ waypoints: plan.waypoints,
1269
+ }),
1270
+ );
1271
+
1272
+ let pointerOutputLabel = cursorOutputLabel;
1273
+
1274
+ if (options.textPointerInput && textSpanExpression) {
1275
+ const textPointerOutputLabel = "rendertextpointer";
1276
+ filters.push(
1277
+ cursorOverlayFilters({
1278
+ enable: textSpanExpression,
1279
+ inputLabel: pointerOutputLabel,
1280
+ outputLabel: textPointerOutputLabel,
1281
+ pointerInput: options.textPointerInput,
1282
+ waypoints: plan.waypoints,
1283
+ }),
1284
+ );
1285
+ pointerOutputLabel = textPointerOutputLabel;
1286
+ }
1287
+
1288
+ if (options.clickPointerInput && clickSpanExpression) {
1289
+ const clickPointerOutputLabel = "renderclickpointer";
1290
+ filters.push(
1291
+ cursorOverlayFilters({
1292
+ enable: clickSpanExpression,
1293
+ inputLabel: pointerOutputLabel,
1294
+ outputLabel: clickPointerOutputLabel,
1295
+ pointerInput: options.clickPointerInput,
1296
+ waypoints: plan.waypoints,
1297
+ }),
1298
+ );
1299
+
1300
+ return {
1301
+ outputLabel: clickPointerOutputLabel,
1302
+ value: filters.join(";"),
1303
+ };
1304
+ }
1305
+
1306
+ return {
1307
+ outputLabel: pointerOutputLabel,
1308
+ value: filters.join(";"),
1309
+ };
1310
+ }
1311
+
1312
+ return {
1313
+ outputLabel,
1314
+ value: filters.join(";"),
1315
+ };
1316
+ };
1317
+
1318
+ const videoInfo = async (path: string): Promise<VideoInfo> => {
1319
+ const { stdout } = await execFile(
1320
+ "ffprobe",
1321
+ [
1322
+ "-v",
1323
+ "error",
1324
+ "-show_entries",
1325
+ "format=duration:stream=width,height",
1326
+ "-of",
1327
+ "json",
1328
+ path,
1329
+ ],
1330
+ { maxBuffer: 1024 * 1024 },
1331
+ );
1332
+ const payload = JSON.parse(stdout);
1333
+ const seconds = Number(payload.format?.duration);
1334
+ const stream = payload.streams?.find((candidate: any) => candidate.width && candidate.height);
1335
+
1336
+ if (!Number.isFinite(seconds) || seconds <= 0 || !stream) {
1337
+ throw new Error(`Could not read video duration from ffprobe output: ${stdout}`);
1338
+ }
1339
+
1340
+ return {
1341
+ durationMs: Math.round(seconds * 1000),
1342
+ height: Number(stream.height),
1343
+ width: Number(stream.width),
1344
+ };
1345
+ };
1346
+
1347
+ const waitForNonEmptyFile = async (path: string, timeoutMs = 5000) => {
1348
+ const start = Date.now();
1349
+ let lastError: unknown;
1350
+
1351
+ while (Date.now() - start < timeoutMs) {
1352
+ try {
1353
+ const stats = await stat(path);
1354
+ if (stats.size > 0) {
1355
+ return;
1356
+ }
1357
+ lastError = new Error(`File exists but is empty: ${path}`);
1358
+ } catch (error) {
1359
+ lastError = error;
1360
+ }
1361
+
1362
+ await new Promise((resolve) => setTimeout(resolve, 100));
1363
+ }
1364
+
1365
+ throw lastError instanceof Error
1366
+ ? lastError
1367
+ : new Error(`Timed out waiting for non-empty file: ${path}`);
1368
+ };
1369
+
1370
+ const escapeHtml = (value: string) => {
1371
+ const entities: Record<string, string> = {
1372
+ "&": "&amp;",
1373
+ '"': "&quot;",
1374
+ "'": "&#39;",
1375
+ "<": "&lt;",
1376
+ ">": "&gt;",
1377
+ };
1378
+
1379
+ return value.replace(/[&"'<>]/g, (character) => entities[character]);
1380
+ };
1381
+
1382
+ const videoElementHtml = (options: {
1383
+ activeKey: "raw" | "rendered";
1384
+ label: string;
1385
+ source: string;
1386
+ }) => {
1387
+ const activeKey = escapeHtml(options.activeKey);
1388
+ const label = escapeHtml(options.label);
1389
+ const source = escapeHtml(options.source);
1390
+
1391
+ return `
1392
+ <section class="video-section" data-active-key="${activeKey}">
1393
+ <div class="section-title">${label}</div>
1394
+ <video controls preload="metadata" tabindex="0" data-active-key="${activeKey}">
1395
+ <source src="${source}" type="video/webm" />
1396
+ </video>
1397
+ </section>`;
1398
+ };
1399
+
1400
+ const playwrightReportAttachmentName = async (path: string) => {
1401
+ const data = await readFile(path);
1402
+ return `${createHash("sha1").update(data).digest("hex")}${extname(path)}`;
1403
+ };
1404
+
1405
+ const videoModePlayerHtml = (options: { raw: string; rendered?: string }) => {
1406
+ const primary = options.rendered || options.raw;
1407
+ const primaryLabel = options.rendered ? "Rendered video" : "Raw video";
1408
+ const primaryActiveKey = options.rendered ? "rendered" : "raw";
1409
+ const rawDetails = options.rendered
1410
+ ? `
1411
+ <details>
1412
+ <summary>Raw video</summary>
1413
+ ${videoElementHtml({ activeKey: "raw", label: "Raw video", source: options.raw })}
1414
+ </details>`
1415
+ : "";
1416
+
1417
+ return `<!doctype html>
1418
+ <html lang="en">
1419
+ <head>
1420
+ <meta charset="utf-8" />
1421
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
1422
+ <title>video-mode player</title>
1423
+ <style>
1424
+ :root {
1425
+ color-scheme: dark;
1426
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1427
+ background: #111;
1428
+ color: #eee;
1429
+ }
1430
+
1431
+ body {
1432
+ margin: 0;
1433
+ min-height: 100vh;
1434
+ background: #111;
1435
+ }
1436
+
1437
+ header {
1438
+ align-items: center;
1439
+ background: #1d1d1d;
1440
+ border-bottom: 1px solid #333;
1441
+ display: grid;
1442
+ gap: 12px;
1443
+ grid-template-columns: 1fr auto auto auto;
1444
+ padding: 10px 12px;
1445
+ position: sticky;
1446
+ top: 0;
1447
+ z-index: 1;
1448
+ }
1449
+
1450
+ main {
1451
+ display: grid;
1452
+ gap: 14px;
1453
+ grid-template-columns: minmax(0, 1fr) 260px;
1454
+ padding: 12px;
1455
+ }
1456
+
1457
+ video {
1458
+ background: #000;
1459
+ max-height: calc(100vh - 92px);
1460
+ outline: 1px solid #333;
1461
+ width: 100%;
1462
+ }
1463
+
1464
+ label,
1465
+ button,
1466
+ input {
1467
+ font: inherit;
1468
+ }
1469
+
1470
+ input {
1471
+ background: #050505;
1472
+ border: 1px solid #555;
1473
+ color: #eee;
1474
+ padding: 6px 8px;
1475
+ width: 72px;
1476
+ }
1477
+
1478
+ button {
1479
+ background: #333;
1480
+ border: 1px solid #666;
1481
+ color: #eee;
1482
+ cursor: pointer;
1483
+ padding: 6px 10px;
1484
+ }
1485
+
1486
+ aside {
1487
+ color: #ddd;
1488
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1489
+ font-size: 13px;
1490
+ line-height: 1.5;
1491
+ }
1492
+
1493
+ details {
1494
+ border-top: 1px solid #333;
1495
+ margin-top: 14px;
1496
+ padding-top: 12px;
1497
+ }
1498
+
1499
+ summary {
1500
+ cursor: pointer;
1501
+ margin-bottom: 12px;
1502
+ }
1503
+
1504
+ a {
1505
+ color: #9ecbff;
1506
+ }
1507
+
1508
+ .section-title {
1509
+ color: #aaa;
1510
+ font-size: 13px;
1511
+ margin-bottom: 8px;
1512
+ }
1513
+
1514
+ .hint {
1515
+ color: #aaa;
1516
+ margin-top: 12px;
1517
+ }
1518
+
1519
+ @media (max-width: 820px) {
1520
+ header,
1521
+ main {
1522
+ grid-template-columns: 1fr;
1523
+ }
1524
+
1525
+ video {
1526
+ max-height: none;
1527
+ }
1528
+ }
1529
+ </style>
1530
+ </head>
1531
+ <body>
1532
+ <header>
1533
+ <div>video-mode player</div>
1534
+ <label>fps <input id="fps" type="number" min="1" step="1" value="25" /></label>
1535
+ <button id="back" type="button">-1 frame</button>
1536
+ <button id="forward" type="button">+1 frame</button>
1537
+ </header>
1538
+ <main>
1539
+ <div>
1540
+ ${videoElementHtml({ activeKey: primaryActiveKey, label: primaryLabel, source: primary })}
1541
+ ${rawDetails}
1542
+ </div>
1543
+ <aside>
1544
+ <div>active: <span id="active">-</span></div>
1545
+ <div>time: <span id="time">0.000</span>s</div>
1546
+ <div>frame: <span id="frame">0</span></div>
1547
+ <div>duration: <span id="duration">?</span>s</div>
1548
+ <div class="hint">Left/right steps one frame. Shift+left/right steps ten. Space toggles play.</div>
1549
+ <div class="hint"><a href="${VIDEO_MODE_METADATA_FILE}">${VIDEO_MODE_METADATA_FILE}</a></div>
1550
+ </aside>
1551
+ </main>
1552
+ <script>
1553
+ const videos = Array.from(document.querySelectorAll("video"));
1554
+ const fps = document.querySelector("#fps");
1555
+ const active = document.querySelector("#active");
1556
+ const time = document.querySelector("#time");
1557
+ const frame = document.querySelector("#frame");
1558
+ const duration = document.querySelector("#duration");
1559
+ const videoByActiveKey = new Map(videos.map((video) => [video.dataset.activeKey, video]));
1560
+ let activeVideo = videos[0];
1561
+
1562
+ const rate = () => Number(fps.value) || 25;
1563
+ const currentFrame = () => Math.round(activeVideo.currentTime * rate());
1564
+ const activeKeyFor = (video) => video.dataset.activeKey || "rendered";
1565
+ const revealVideo = (video) => {
1566
+ const details = video.closest("details");
1567
+ if (details) details.open = true;
1568
+ };
1569
+ const writeStateToUrl = () => {
1570
+ const next = new URL(location.href);
1571
+ next.searchParams.set("active", activeKeyFor(activeVideo));
1572
+ next.searchParams.set("frame", String(currentFrame()));
1573
+ history.replaceState(null, "", next.href);
1574
+ };
1575
+
1576
+ const setActiveVideo = (video, writeUrl) => {
1577
+ activeVideo = video;
1578
+ revealVideo(video);
1579
+ update(writeUrl);
1580
+ };
1581
+
1582
+ const update = (writeUrl) => {
1583
+ const title = activeVideo.closest(".video-section").querySelector(".section-title").textContent;
1584
+ active.textContent = title;
1585
+ time.textContent = activeVideo.currentTime.toFixed(3);
1586
+ frame.textContent = String(currentFrame());
1587
+ duration.textContent = Number.isFinite(activeVideo.duration) ? activeVideo.duration.toFixed(3) : "?";
1588
+ if (writeUrl) writeStateToUrl();
1589
+ };
1590
+
1591
+ const seekToFrame = (frameNumber, writeUrl) => {
1592
+ if (!Number.isFinite(frameNumber)) return;
1593
+ if (!Number.isFinite(activeVideo.duration)) {
1594
+ activeVideo.addEventListener("loadedmetadata", () => seekToFrame(frameNumber, writeUrl), {
1595
+ once: true,
1596
+ });
1597
+ return;
1598
+ }
1599
+
1600
+ activeVideo.pause();
1601
+ activeVideo.currentTime = Math.max(
1602
+ 0,
1603
+ Math.min(activeVideo.duration, Math.round(frameNumber) / rate()),
1604
+ );
1605
+ update(writeUrl);
1606
+ };
1607
+
1608
+ const stepFrames = (count) => {
1609
+ seekToFrame(currentFrame() + count, true);
1610
+ };
1611
+
1612
+ for (const video of videos) {
1613
+ video.addEventListener("focus", () => setActiveVideo(video, true));
1614
+ video.addEventListener("pointerdown", () => setActiveVideo(video, true));
1615
+ video.addEventListener("mouseenter", () => setActiveVideo(video, true));
1616
+ video.addEventListener("loadedmetadata", () => update(false));
1617
+ video.addEventListener("seeked", () => update(true));
1618
+ video.addEventListener("timeupdate", () => update(true));
1619
+ }
1620
+
1621
+ document.querySelector("#back").addEventListener("click", () => stepFrames(-1));
1622
+ document.querySelector("#forward").addEventListener("click", () => stepFrames(1));
1623
+ fps.addEventListener("input", () => update(true));
1624
+ window.addEventListener("keydown", (event) => {
1625
+ if (event.target instanceof HTMLInputElement) return;
1626
+ let handled = true;
1627
+ if (event.key === "ArrowRight") {
1628
+ stepFrames(event.shiftKey ? 10 : 1);
1629
+ } else if (event.key === "ArrowLeft") {
1630
+ stepFrames(event.shiftKey ? -10 : -1);
1631
+ } else if (event.key === " ") {
1632
+ if (activeVideo.paused) activeVideo.play();
1633
+ else activeVideo.pause();
1634
+ } else {
1635
+ handled = false;
1636
+ }
1637
+ if (!handled) return;
1638
+ event.preventDefault();
1639
+ event.stopPropagation();
1640
+ }, { capture: true });
1641
+
1642
+ const applyUrlState = () => {
1643
+ const params = new URL(location.href).searchParams;
1644
+ const requestedVideo = videoByActiveKey.get(params.get("active"));
1645
+ setActiveVideo(requestedVideo || activeVideo, false);
1646
+ const requestedFrame = Number(params.get("frame"));
1647
+ if (Number.isFinite(requestedFrame)) {
1648
+ seekToFrame(requestedFrame, false);
1649
+ return;
1650
+ }
1651
+ update(false);
1652
+ };
1653
+
1654
+ document.body.tabIndex = -1;
1655
+ document.body.focus();
1656
+ applyUrlState();
1657
+ </script>
1658
+ </body>
1659
+ </html>
1660
+ `;
1661
+ };
1662
+
1663
+ const renderVideo = async (options: {
1664
+ finalHoldMs: number;
1665
+ highlightMode: "outline" | "pointer";
1666
+ highlights: VideoModeHighlight[];
1667
+ inputPath: string;
1668
+ outputDir: string;
1669
+ outputPath: string;
1670
+ deadAir: VideoModeSpan[];
1671
+ sourceRange: VideoModeSourceRange;
1672
+ thresholdMs: number | undefined;
1673
+ }) => {
1674
+ const info = await videoInfo(options.inputPath);
1675
+ const sourceRangeStart = options.sourceRange.start === undefined ? 0 : options.sourceRange.start;
1676
+ const sourceRangeEnd =
1677
+ options.sourceRange.end === undefined ? info.durationMs : options.sourceRange.end;
1678
+ const rangeStart = Math.max(0, Math.min(Math.round(sourceRangeStart), info.durationMs));
1679
+ const rangeEnd = Math.max(
1680
+ 0,
1681
+ Math.min(Math.round(sourceRangeEnd), info.durationMs),
1682
+ );
1683
+
1684
+ if (rangeEnd <= rangeStart) {
1685
+ console.warn(
1686
+ `videoMode source range is empty: start ${rangeStart}ms must be before end ${rangeEnd}ms`,
1687
+ );
1688
+ return false;
1689
+ }
1690
+
1691
+ const highlightInputs = options.highlights
1692
+ .filter((highlight) => highlight.image)
1693
+ .map((highlight, index) => ({
1694
+ durationMs: highlight.end - highlight.start,
1695
+ image: highlight.image!,
1696
+ inputIndex: index + 1,
1697
+ path: join(options.outputDir, highlight.image!),
1698
+ }));
1699
+ const shouldRenderPointer = options.highlightMode === "pointer" && options.highlights.length > 0;
1700
+ const cursorPointerInput: PointerInput | undefined = shouldRenderPointer
1701
+ ? {
1702
+ hotspot: VIDEO_MODE_POINTER_HOTSPOT,
1703
+ inputIndex: highlightInputs.length + 1,
1704
+ path: join(options.outputDir, VIDEO_MODE_POINTER_FILE),
1705
+ size: VIDEO_MODE_POINTER_SIZE,
1706
+ sourceSize: VIDEO_MODE_POINTER_SOURCE_SIZE,
1707
+ }
1708
+ : undefined;
1709
+ const clickPointerInput: PointerInput | undefined = shouldRenderPointer
1710
+ ? {
1711
+ hotspot: VIDEO_MODE_CLICK_POINTER_HOTSPOT,
1712
+ inputIndex: highlightInputs.length + 2,
1713
+ path: join(options.outputDir, VIDEO_MODE_CLICK_POINTER_FILE),
1714
+ size: VIDEO_MODE_CLICK_POINTER_SIZE,
1715
+ sourceSize: VIDEO_MODE_CLICK_POINTER_SOURCE_SIZE,
1716
+ }
1717
+ : undefined;
1718
+ const textPointerInput: PointerInput | undefined = shouldRenderPointer
1719
+ ? {
1720
+ hotspot: VIDEO_MODE_TEXT_POINTER_HOTSPOT,
1721
+ inputIndex: highlightInputs.length + 3,
1722
+ path: join(options.outputDir, VIDEO_MODE_TEXT_POINTER_FILE),
1723
+ size: VIDEO_MODE_TEXT_POINTER_SIZE,
1724
+ sourceSize: VIDEO_MODE_TEXT_POINTER_SOURCE_SIZE,
1725
+ }
1726
+ : undefined;
1727
+ if (cursorPointerInput) {
1728
+ await writeFile(cursorPointerInput.path, Buffer.from(VIDEO_MODE_POINTER_PNG, "base64"));
1729
+ }
1730
+ if (clickPointerInput) {
1731
+ await writeFile(clickPointerInput.path, Buffer.from(VIDEO_MODE_CLICK_POINTER_PNG, "base64"));
1732
+ }
1733
+ if (textPointerInput) {
1734
+ await writeFile(textPointerInput.path, Buffer.from(VIDEO_MODE_TEXT_POINTER_PNG, "base64"));
1735
+ }
1736
+ const segments = renderVideoSegments({
1737
+ deadAir: options.deadAir,
1738
+ finalEnd: rangeEnd,
1739
+ start: rangeStart,
1740
+ thresholdMs: options.thresholdMs,
1741
+ });
1742
+ const filter = renderedVideoFilter({
1743
+ clickPointerInput,
1744
+ cursorPointerInput,
1745
+ finalHoldMs: options.finalHoldMs,
1746
+ highlightMode: options.highlightMode,
1747
+ highlightInputs,
1748
+ highlights: options.highlights,
1749
+ segments,
1750
+ textPointerInput,
1751
+ video: info,
1752
+ });
1753
+
1754
+ if (!filter) {
1755
+ return false;
1756
+ }
1757
+
1758
+ await execFile(
1759
+ "ffmpeg",
1760
+ [
1761
+ "-hide_banner",
1762
+ "-loglevel",
1763
+ "error",
1764
+ "-y",
1765
+ "-i",
1766
+ options.inputPath,
1767
+ ...highlightInputs.flatMap((input) => [
1768
+ "-loop",
1769
+ "1",
1770
+ "-t",
1771
+ formatSeconds(input.durationMs),
1772
+ "-i",
1773
+ input.path,
1774
+ ]),
1775
+ ...(cursorPointerInput ? ["-loop", "1", "-i", cursorPointerInput.path] : []),
1776
+ ...(clickPointerInput ? ["-loop", "1", "-i", clickPointerInput.path] : []),
1777
+ ...(textPointerInput ? ["-loop", "1", "-i", textPointerInput.path] : []),
1778
+ "-filter_complex",
1779
+ filter.value,
1780
+ "-map",
1781
+ `[${filter.outputLabel}]`,
1782
+ "-an",
1783
+ options.outputPath,
1784
+ ],
1785
+ { maxBuffer: 10 * 1024 * 1024 },
1786
+ );
1787
+
1788
+ return true;
1789
+ };
1790
+
1791
+ /** Records video-mode facts and renders annotations into the recorded video. */
1792
+ export const videoMode = (options: VideoModeOptions = {}): VideoModePlugin => {
1793
+ if (process.env.PWDEBUG) {
1794
+ let testInfoForOutputPaths: TestInfo | undefined;
1795
+ const controls: VideoModeControls = {
1796
+ deadAir: async (action) => {
1797
+ return await action();
1798
+ },
1799
+ getVideoTimestamp: () => 0,
1800
+ metadata: async () => ({
1801
+ deadAir: [],
1802
+ highlights: [],
1803
+ outputs: {},
1804
+ schemaVersion: 1,
1805
+ sourceRange: {},
1806
+ timebase: "ms",
1807
+ }),
1808
+ outputPaths: () => {
1809
+ if (!testInfoForOutputPaths) {
1810
+ throw new Error("videoMode.outputPaths() is only available after addPlugins registers videoMode");
1811
+ }
1812
+
1813
+ return videoModeOutputPaths(testInfoForOutputPaths);
1814
+ },
1815
+ setEndTime: () => {},
1816
+ setStartTime: () => {},
1817
+ };
1818
+
1819
+ return {
1820
+ ...controls,
1821
+ name: "video-mode",
1822
+ pageExtension: ({ testInfo }) => {
1823
+ testInfoForOutputPaths = testInfo;
1824
+ return { videoMode: controls };
1825
+ },
1826
+ };
1827
+ }
1828
+
1829
+ const finalHold = resolveNonNegativeNumber({
1830
+ defaultValue: 3000,
1831
+ name: "videoMode finalHold",
1832
+ value: options.finalHold,
1833
+ });
1834
+ const highlight = resolveVideoModeHighlight(options);
71
1835
  const skipMethods = options.skipMethods || ["waitFor"];
72
1836
  const skipStackFrames = options.skipStackFrames || [];
1837
+ const deadAirThreshold = resolveDeadAirThreshold(options.deadAirThreshold);
1838
+ const state: VideoModeState = {
1839
+ deadAirDepth: 0,
1840
+ deadAirSpans: [],
1841
+ highlightImageIndex: 0,
1842
+ highlights: [],
1843
+ outputs: {},
1844
+ sourceRange: {},
1845
+ startedAt: performance.now(),
1846
+ };
1847
+ let testInfoForOutputPaths: TestInfo | undefined;
1848
+ const getVideoTimestamp = () => {
1849
+ const now = performance.now();
1850
+ return Math.round(now - (state.startedAt || now));
1851
+ };
1852
+ const controls: VideoModeControls = {
1853
+ deadAir: async (action) => {
1854
+ return await recordDeadAir(state, action);
1855
+ },
1856
+ getVideoTimestamp,
1857
+ metadata: async () => {
1858
+ if (!testInfoForOutputPaths) {
1859
+ return metadataFor(state);
1860
+ }
1861
+
1862
+ return await readVideoModeMetadata(videoModeOutputPaths(testInfoForOutputPaths).metadata, () =>
1863
+ metadataFor(state),
1864
+ );
1865
+ },
1866
+ outputPaths: () => {
1867
+ if (!testInfoForOutputPaths) {
1868
+ throw new Error("videoMode.outputPaths() is only available after addPlugins registers videoMode");
1869
+ }
1870
+
1871
+ return videoModeOutputPaths(testInfoForOutputPaths);
1872
+ },
1873
+ setEndTime: (ms = getVideoTimestamp()) => {
1874
+ state.sourceRange.end = resolveVideoTimestamp("setEndTime", ms);
1875
+ },
1876
+ setStartTime: (ms = getVideoTimestamp()) => {
1877
+ state.sourceRange.start = resolveVideoTimestamp("setStartTime", ms);
1878
+ },
1879
+ };
73
1880
 
74
1881
  return {
1882
+ ...controls,
75
1883
  name: "video-mode",
1884
+ pageExtension: ({ testInfo }) => {
1885
+ testInfoForOutputPaths = testInfo;
1886
+ return { videoMode: controls };
1887
+ },
76
1888
 
77
- middleware: async ({ locator, method }, next) => {
78
- if (skipMethods.includes(method)) return next();
1889
+ middleware: async ({ locator, method, testInfo, timing }, next) => {
1890
+ if (state.deadAirDepth > 0) return next();
79
1891
 
80
1892
  // Skip if called from internal helpers (navigation, login flows etc)
81
1893
  if (skipStackFrames.length > 0) {
@@ -83,15 +1895,176 @@ export const videoMode = (options: VideoModeOptions = {}): Plugin => {
83
1895
  if (skipStackFrames.some((frame) => stack.includes(frame))) return next();
84
1896
  }
85
1897
 
86
- using _ = await setupHighlight(locator, highlightStyle, pauseBefore);
87
- return await next();
1898
+ if (method === "waitFor") {
1899
+ try {
1900
+ return await next();
1901
+ } finally {
1902
+ recordActionElapsedDeadAirFromTiming(state, timing, { minimumMs: 0 });
1903
+ }
1904
+ }
1905
+
1906
+ recordMiddlewareWaitBeforeVideoMode(state, timing);
1907
+
1908
+ if (skipMethods.includes(method)) {
1909
+ try {
1910
+ return await next();
1911
+ } finally {
1912
+ if (timing.attachedAtStart) {
1913
+ recordActionElapsedDeadAirFromTiming(state, timing, { minimumMs: 50 });
1914
+ }
1915
+ recordAttachedWaitFromTiming(state, timing);
1916
+ }
1917
+ }
1918
+
1919
+ const recordedHighlight =
1920
+ highlight.mode === "off"
1921
+ ? undefined
1922
+ : await recordHighlight({
1923
+ color: highlight.color,
1924
+ durationMs: highlight.durationMs,
1925
+ locator,
1926
+ method,
1927
+ state,
1928
+ testInfo,
1929
+ thickness: highlight.thickness,
1930
+ });
1931
+
1932
+ try {
1933
+ return await next();
1934
+ } finally {
1935
+ if (recordedHighlight && state.startedAt !== undefined) {
1936
+ recordedHighlight.actionEnd = Math.max(
1937
+ recordedHighlight.start,
1938
+ Math.round(performance.now() - state.startedAt),
1939
+ );
1940
+ }
1941
+ if (
1942
+ !recordedHighlight &&
1943
+ (timing.attachedAtStart || timing.attachedAt === undefined)
1944
+ ) {
1945
+ recordActionElapsedDeadAirFromTiming(state, timing, { minimumMs: 50 });
1946
+ }
1947
+ recordAttachedWaitFromTiming(state, timing);
1948
+ }
88
1949
  },
89
1950
 
90
1951
  testLifecycle: (emitter) => {
91
- return emitter.on("afterTest", async ({ testInfo }) => {
92
- await new Promise((resolve) => setTimeout(resolve, pauseAfterTest));
93
- console.log(`video will be written to ${testInfo.outputDir}/video.webm`);
1952
+ const offBeforeTest = emitter.on("beforeTest", () => {
1953
+ state.deadAirDepth = 0;
1954
+ state.deadAirSpans = [];
1955
+ state.highlightImageIndex = 0;
1956
+ state.highlights = [];
1957
+ state.outputs = {};
1958
+ state.sourceRange = {};
1959
+ if (state.startedAt) {
1960
+ recordDeadAirSpan(state, {
1961
+ end: Math.round(performance.now() - state.startedAt),
1962
+ start: 0,
1963
+ });
1964
+ } else {
1965
+ state.startedAt = performance.now();
1966
+ }
94
1967
  });
1968
+
1969
+ const offAfterTestFinalize = emitter.on("afterTestFinalize", async ({ page, testInfo }) => {
1970
+ const metadataBeforeVideo = metadataFor(state);
1971
+ const deadAir = metadataBeforeVideo.deadAir;
1972
+ const highlights = metadataBeforeVideo.highlights;
1973
+ const sourceRange = metadataBeforeVideo.sourceRange;
1974
+ const video = page.video();
1975
+
1976
+ if (video) {
1977
+ const paths = videoModeOutputPaths(testInfo);
1978
+ await mkdir(testInfo.outputDir, { recursive: true });
1979
+
1980
+ if (!page.isClosed()) {
1981
+ await page.close({ runBeforeUnload: false });
1982
+ }
1983
+
1984
+ const recordedVideoPath = await video.path();
1985
+ await waitForNonEmptyFile(recordedVideoPath);
1986
+ await copyFile(recordedVideoPath, paths.raw);
1987
+ state.outputs.raw = VIDEO_MODE_RAW_FILE;
1988
+ await testInfo.attach("video-raw", {
1989
+ contentType: "video/webm",
1990
+ path: paths.raw,
1991
+ });
1992
+
1993
+ if (
1994
+ highlights.length > 0 ||
1995
+ deadAirThreshold !== undefined ||
1996
+ finalHold > 0 ||
1997
+ sourceRangeIsSet(sourceRange)
1998
+ ) {
1999
+ const wroteRenderedVideo = await renderVideo({
2000
+ deadAir,
2001
+ finalHoldMs: finalHold,
2002
+ highlightMode: highlight.mode === "pointer" ? "pointer" : "outline",
2003
+ highlights,
2004
+ inputPath: paths.raw,
2005
+ outputDir: testInfo.outputDir,
2006
+ outputPath: paths.rendered,
2007
+ sourceRange,
2008
+ thresholdMs: deadAirThreshold,
2009
+ });
2010
+
2011
+ if (wroteRenderedVideo) {
2012
+ state.outputs.rendered = VIDEO_MODE_RENDERED_FILE;
2013
+ await testInfo.attach("video-rendered", {
2014
+ contentType: "video/webm",
2015
+ path: paths.rendered,
2016
+ });
2017
+ }
2018
+ }
2019
+
2020
+ state.outputs.player = VIDEO_MODE_PLAYER_FILE;
2021
+ await writeFile(
2022
+ paths.player,
2023
+ videoModePlayerHtml({
2024
+ raw: state.outputs.raw,
2025
+ rendered: state.outputs.rendered,
2026
+ }),
2027
+ );
2028
+
2029
+ const reportPlayerHtml = videoModePlayerHtml({
2030
+ raw: await playwrightReportAttachmentName(paths.raw),
2031
+ rendered: state.outputs.rendered
2032
+ ? await playwrightReportAttachmentName(paths.rendered)
2033
+ : undefined,
2034
+ });
2035
+ await writeFile(paths.reportPlayer, reportPlayerHtml);
2036
+ await testInfo.attach("video-mode-player", {
2037
+ contentType: "text/html",
2038
+ path: paths.reportPlayer,
2039
+ });
2040
+ }
2041
+
2042
+ const metadata = metadataFor(state);
2043
+ if (
2044
+ metadata.deadAir.length > 0 ||
2045
+ metadata.highlights.length > 0 ||
2046
+ metadata.outputs.player ||
2047
+ metadata.outputs.raw ||
2048
+ metadata.outputs.rendered ||
2049
+ sourceRangeIsSet(metadata.sourceRange)
2050
+ ) {
2051
+ const path = videoModeOutputPaths(testInfo).metadata;
2052
+ await mkdir(testInfo.outputDir, { recursive: true });
2053
+ await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`);
2054
+ await testInfo.attach("video-mode", {
2055
+ contentType: "application/json",
2056
+ path,
2057
+ });
2058
+ }
2059
+
2060
+ state.startedAt = undefined;
2061
+ console.log(`video-mode metadata written to ${videoModeOutputPaths(testInfo).metadata}`);
2062
+ });
2063
+
2064
+ return () => {
2065
+ offBeforeTest();
2066
+ offAfterTestFinalize();
2067
+ };
95
2068
  },
96
2069
  };
97
2070
  };