middlewright 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -9
- package/dist/index.d.ts +1 -1
- package/dist/plugin-system.d.ts +40 -7
- package/dist/plugin-system.js +84 -6
- package/dist/plugins/hydration-waiter.js +3 -0
- package/dist/plugins/index.d.ts +1 -1
- package/dist/plugins/index.js +1 -1
- package/dist/plugins/llm-recover.js +3 -0
- package/dist/plugins/spinner-waiter.js +68 -22
- package/dist/plugins/ui-error-reporter.js +3 -0
- package/dist/plugins/video-mode.d.ts +111 -11
- package/dist/plugins/video-mode.js +1571 -93
- package/package.json +7 -3
- package/src/index.ts +3 -0
- package/src/plugin-system.ts +152 -14
- package/src/plugins/hydration-waiter.ts +4 -0
- package/src/plugins/index.ts +15 -1
- package/src/plugins/llm-recover.ts +4 -0
- package/src/plugins/spinner-waiter.ts +78 -23
- package/src/plugins/ui-error-reporter.ts +4 -0
- package/src/plugins/video-mode.ts +2203 -48
|
@@ -1,124 +1,1602 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
/**
|
|
2
|
+
* video-mode: record action timings and render watchable videos after the run.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the iterate monorepo's internal Playwright test
|
|
5
|
+
* infrastructure (github.com/iterate/iterate, private). Modification from the
|
|
6
|
+
* original: the hardcoded skip for iterate's test-helpers file is now the
|
|
7
|
+
* `skipStackFrames` option.
|
|
8
|
+
*/
|
|
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
|
+
const execFile = promisify(execFileCallback);
|
|
15
|
+
const VIDEO_MODE_METADATA_FILE = "video-mode.json";
|
|
16
|
+
const VIDEO_MODE_PLAYER_FILE = "video-mode.html";
|
|
17
|
+
const VIDEO_MODE_RAW_FILE = "video-raw.webm";
|
|
18
|
+
const VIDEO_MODE_RENDERED_FILE = "video-rendered.webm";
|
|
19
|
+
const VIDEO_MODE_REPORT_PLAYER_FILE = "video-mode-report.html";
|
|
20
|
+
const VIDEO_MODE_POINTER_FILE = "video-mode-pointer.png";
|
|
21
|
+
const VIDEO_MODE_CLICK_POINTER_FILE = "video-mode-click-pointer.png";
|
|
22
|
+
const VIDEO_MODE_TEXT_POINTER_FILE = "video-mode-text-pointer.png";
|
|
23
|
+
// Pointer assets adapted from Pictogrammers Material Design Icons:
|
|
24
|
+
// cursor-default.svg, cursor-pointer.svg, and cursor-text.svg.
|
|
25
|
+
// Source: https://github.com/Templarian/MaterialDesign
|
|
26
|
+
// Icons are distributed under the Pictogrammers Free License / Apache 2.0.
|
|
27
|
+
const VIDEO_MODE_POINTER_PNG = "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";
|
|
28
|
+
const VIDEO_MODE_POINTER_SOURCE_SIZE = 64;
|
|
29
|
+
const VIDEO_MODE_POINTER_SIZE = 28;
|
|
30
|
+
const VIDEO_MODE_POINTER_HOTSPOT = { x: 18, y: 6 };
|
|
31
|
+
const VIDEO_MODE_CLICK_POINTER_PNG = "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";
|
|
32
|
+
const VIDEO_MODE_CLICK_POINTER_SOURCE_SIZE = 64;
|
|
33
|
+
const VIDEO_MODE_CLICK_POINTER_SIZE = 28;
|
|
34
|
+
const VIDEO_MODE_CLICK_POINTER_HOTSPOT = { x: 28, y: 7 };
|
|
35
|
+
const VIDEO_MODE_TEXT_POINTER_PNG = "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==";
|
|
36
|
+
const VIDEO_MODE_TEXT_POINTER_SOURCE_SIZE = 64;
|
|
37
|
+
const VIDEO_MODE_TEXT_POINTER_SIZE = 28;
|
|
38
|
+
const VIDEO_MODE_TEXT_POINTER_HOTSPOT = { x: 32, y: 32 };
|
|
39
|
+
const CURSOR_MOVEMENT_MIN_MS = 200;
|
|
40
|
+
const CURSOR_MOVEMENT_MAX_MS = 1000;
|
|
41
|
+
const CURSOR_MOVEMENT_MIN_IDEAL_MS = 300;
|
|
42
|
+
const CURSOR_MOVEMENT_MAX_IDEAL_MS = 700;
|
|
43
|
+
const CURSOR_MOVEMENT_SPEED_PX_PER_SECOND = 600;
|
|
44
|
+
const CURSOR_REST_BEFORE_ACTION_MS = 200;
|
|
45
|
+
const CURSOR_TARGET_HOLD_IDEAL_MS = 1000;
|
|
46
|
+
const TEXT_CURSOR_HOLD_IDEAL_MS = 800;
|
|
47
|
+
const TEXT_CURSOR_POINTER_TAIL_MS = 200;
|
|
48
|
+
const resolveDeadAirThreshold = (thresholdMs) => {
|
|
49
|
+
if (thresholdMs === undefined) {
|
|
50
|
+
return undefined;
|
|
20
51
|
}
|
|
21
|
-
|
|
52
|
+
if (!Number.isFinite(thresholdMs) || thresholdMs < 0) {
|
|
53
|
+
throw new Error("videoMode deadAirThreshold must be a non-negative number");
|
|
54
|
+
}
|
|
55
|
+
return thresholdMs;
|
|
22
56
|
};
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
57
|
+
// A `selector` falls back to blank detection if it never shows, so a bad
|
|
58
|
+
// selector can't leave the video opening on the blank lead-in.
|
|
59
|
+
const TRIM_START_SELECTOR_TIMEOUT_MS = 30_000;
|
|
60
|
+
// Only trim when the detected blank lead-in is at least this long, so a video
|
|
61
|
+
// that was never really blank isn't nudged.
|
|
62
|
+
const TRIM_START_MIN_LEAD_IN_MS = 1000;
|
|
63
|
+
const resolveTrimStart = (trimStart) => {
|
|
64
|
+
const value = trimStart === undefined ? "auto" : trimStart;
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
const [kind, selector] = value;
|
|
67
|
+
if (kind !== "selector" || typeof selector !== "string" || selector.length === 0) {
|
|
68
|
+
throw new Error('videoMode trimStart tuple must be ["selector", "<css>"]');
|
|
28
69
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
70
|
+
return { selector, detectBlank: true };
|
|
71
|
+
}
|
|
72
|
+
switch (value) {
|
|
73
|
+
case "never":
|
|
74
|
+
return { detectBlank: false };
|
|
75
|
+
case "auto":
|
|
76
|
+
case "detect-blank":
|
|
77
|
+
return { detectBlank: true };
|
|
78
|
+
default:
|
|
79
|
+
throw new Error('videoMode trimStart must be "auto", "detect-blank", "never", or ["selector", "<css>"]');
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
// Blank-lead-in detection tuning. The recorded startup is a run of *identical*
|
|
83
|
+
// frames — the browser paints nothing new (about:blank, then a static loading
|
|
84
|
+
// shell) until content arrives. So the signal isn't how "busy" a frame is (a
|
|
85
|
+
// letterbox bar or a solid-but-dark shell would fool that); it's the first frame
|
|
86
|
+
// that *differs* from the opening frame. We decode a coarse, tiny greyscale strip
|
|
87
|
+
// of the opening seconds and find where it first changes and stays changed.
|
|
88
|
+
const AUTO_START_SAMPLE_FPS = 5;
|
|
89
|
+
const AUTO_START_SAMPLE_SIZE = 48;
|
|
90
|
+
const AUTO_START_MAX_SCAN_MS = 30_000;
|
|
91
|
+
// Mean per-pixel greyscale delta (0-255) above which a frame counts as "changed"
|
|
92
|
+
// from the opening frame. Comfortably above VP8 quantisation noise on a static
|
|
93
|
+
// scene (which stays ~0) and below the jump when real content paints.
|
|
94
|
+
const AUTO_START_DIFF_THRESHOLD = 1.5;
|
|
95
|
+
const frameMeanAbsDiff = (frame, reference) => {
|
|
96
|
+
let sum = 0;
|
|
97
|
+
for (let index = 0; index < frame.length; index += 1) {
|
|
98
|
+
sum += Math.abs(frame[index] - reference[index]);
|
|
99
|
+
}
|
|
100
|
+
return sum / frame.length;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Return the timestamp (ms) where the leading blank frames end and the screen
|
|
104
|
+
* first changes (content paints), or undefined when the video never opens with a
|
|
105
|
+
* static lead-in (nothing to trim).
|
|
106
|
+
*/
|
|
107
|
+
const detectBlankLeadInEndMs = async (inputPath) => {
|
|
108
|
+
const size = AUTO_START_SAMPLE_SIZE;
|
|
109
|
+
const frameSize = size * size;
|
|
110
|
+
let stdout;
|
|
111
|
+
try {
|
|
112
|
+
const result = await execFile("ffmpeg", [
|
|
113
|
+
"-hide_banner",
|
|
114
|
+
"-loglevel",
|
|
115
|
+
"error",
|
|
116
|
+
"-i",
|
|
117
|
+
inputPath,
|
|
118
|
+
"-vf",
|
|
119
|
+
`fps=${AUTO_START_SAMPLE_FPS},scale=${size}:${size},format=gray`,
|
|
120
|
+
"-t",
|
|
121
|
+
formatSeconds(AUTO_START_MAX_SCAN_MS),
|
|
122
|
+
"-f",
|
|
123
|
+
"rawvideo",
|
|
124
|
+
"-pix_fmt",
|
|
125
|
+
"gray",
|
|
126
|
+
"pipe:1",
|
|
127
|
+
], { encoding: "buffer", maxBuffer: 64 * 1024 * 1024 });
|
|
128
|
+
stdout = result.stdout;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Detection is best-effort; a decode failure just means "don't trim".
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
const frameCount = Math.floor(stdout.length / frameSize);
|
|
135
|
+
if (frameCount < 2) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const firstFrame = stdout.subarray(0, frameSize);
|
|
139
|
+
const hasChanged = (index) => frameMeanAbsDiff(stdout.subarray(index * frameSize, (index + 1) * frameSize), firstFrame) > AUTO_START_DIFF_THRESHOLD;
|
|
140
|
+
// First frame that differs from the opening frame *and* stays changed (two
|
|
141
|
+
// consecutive samples), so a single decode blip can't trip it.
|
|
142
|
+
for (let index = 1; index < frameCount - 1; index += 1) {
|
|
143
|
+
if (hasChanged(index) && hasChanged(index + 1)) {
|
|
144
|
+
return Math.round((index / AUTO_START_SAMPLE_FPS) * 1000);
|
|
46
145
|
}
|
|
47
|
-
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
};
|
|
149
|
+
const resolveNonNegativeNumber = (options) => {
|
|
150
|
+
const value = options.value === undefined ? options.defaultValue : options.value;
|
|
151
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
152
|
+
throw new Error(`${options.name} must be a non-negative number`);
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
};
|
|
156
|
+
const parseOutlineHighlightStyle = (style) => {
|
|
157
|
+
const match = /^([0-9]+(?:\.[0-9]+)?)px solid (.+)$/.exec(style.trim());
|
|
158
|
+
if (!match) {
|
|
159
|
+
throw new Error("videoMode highlight.style must look like '1px solid yellow'");
|
|
160
|
+
}
|
|
161
|
+
const thickness = Number(match[1]);
|
|
162
|
+
const color = match[2].trim();
|
|
163
|
+
if (!Number.isFinite(thickness) || thickness < 0 || color.length === 0) {
|
|
164
|
+
throw new Error("videoMode highlight.style must look like '1px solid yellow'");
|
|
165
|
+
}
|
|
166
|
+
return { color, thickness };
|
|
167
|
+
};
|
|
168
|
+
const resolveVideoModeHighlight = (options) => {
|
|
169
|
+
const rawHighlight = options.highlight === undefined ? true : options.highlight;
|
|
170
|
+
if (rawHighlight === false) {
|
|
171
|
+
return { mode: "off" };
|
|
172
|
+
}
|
|
173
|
+
if (rawHighlight === true) {
|
|
174
|
+
return {
|
|
175
|
+
color: "gold",
|
|
176
|
+
durationMs: resolveNonNegativeNumber({
|
|
177
|
+
defaultValue: 1000,
|
|
178
|
+
name: "videoMode highlight.duration",
|
|
179
|
+
value: undefined,
|
|
180
|
+
}),
|
|
181
|
+
mode: "pointer",
|
|
182
|
+
thickness: 3,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (!rawHighlight || typeof rawHighlight !== "object" || !("mode" in rawHighlight)) {
|
|
186
|
+
throw new Error("videoMode highlight must be true, false, or a highlight options object");
|
|
187
|
+
}
|
|
188
|
+
const durationMs = resolveNonNegativeNumber({
|
|
189
|
+
defaultValue: 1000,
|
|
190
|
+
name: "videoMode highlight.duration",
|
|
191
|
+
value: rawHighlight.duration,
|
|
192
|
+
});
|
|
193
|
+
if (rawHighlight.mode === "pointer") {
|
|
194
|
+
return {
|
|
195
|
+
color: "gold",
|
|
196
|
+
durationMs,
|
|
197
|
+
mode: "pointer",
|
|
198
|
+
thickness: 3,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (rawHighlight.mode === "outline") {
|
|
202
|
+
const parsed = parseOutlineHighlightStyle(rawHighlight.style || "3px solid gold");
|
|
203
|
+
return {
|
|
204
|
+
color: parsed.color,
|
|
205
|
+
durationMs,
|
|
206
|
+
mode: "outline",
|
|
207
|
+
thickness: parsed.thickness,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
throw new Error("videoMode highlight.mode must be 'pointer' or 'outline'");
|
|
211
|
+
};
|
|
212
|
+
const resolveVideoTimestamp = (name, ms) => {
|
|
213
|
+
if (!Number.isFinite(ms) || ms < 0) {
|
|
214
|
+
throw new Error(`videoMode.${name}() requires a non-negative timestamp`);
|
|
215
|
+
}
|
|
216
|
+
return Math.round(ms);
|
|
217
|
+
};
|
|
218
|
+
const normalizeSourceRange = (sourceRange) => {
|
|
219
|
+
const normalized = {};
|
|
220
|
+
if (sourceRange.start !== undefined) {
|
|
221
|
+
normalized.start = Math.round(sourceRange.start);
|
|
222
|
+
}
|
|
223
|
+
if (sourceRange.end !== undefined) {
|
|
224
|
+
normalized.end = Math.round(sourceRange.end);
|
|
225
|
+
}
|
|
226
|
+
return normalized;
|
|
227
|
+
};
|
|
228
|
+
const sourceRangeIsSet = (sourceRange) => {
|
|
229
|
+
return sourceRange.start !== undefined || sourceRange.end !== undefined;
|
|
230
|
+
};
|
|
231
|
+
const metadataFor = (state) => {
|
|
232
|
+
return {
|
|
233
|
+
deadAir: mergeVideoSpans(state.deadAirSpans),
|
|
234
|
+
highlights: normalizeVideoHighlights(state.highlights),
|
|
235
|
+
outputs: state.outputs,
|
|
236
|
+
schemaVersion: 1,
|
|
237
|
+
sourceRange: normalizeSourceRange(state.sourceRange),
|
|
238
|
+
timebase: "ms",
|
|
239
|
+
};
|
|
240
|
+
};
|
|
241
|
+
const videoModeOutputPaths = (testInfo) => {
|
|
242
|
+
return {
|
|
243
|
+
metadata: join(testInfo.outputDir, VIDEO_MODE_METADATA_FILE),
|
|
244
|
+
player: join(testInfo.outputDir, VIDEO_MODE_PLAYER_FILE),
|
|
245
|
+
raw: join(testInfo.outputDir, VIDEO_MODE_RAW_FILE),
|
|
246
|
+
rendered: join(testInfo.outputDir, VIDEO_MODE_RENDERED_FILE),
|
|
247
|
+
reportPlayer: join(testInfo.outputDir, VIDEO_MODE_REPORT_PLAYER_FILE),
|
|
48
248
|
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
|
-
});
|
|
53
|
-
/** Highlight element, pause, return disposable that unhighlights */
|
|
54
|
-
const setupHighlight = async (locator, style, pauseMs) => {
|
|
249
|
+
};
|
|
250
|
+
const readVideoModeMetadata = async (path, fallback) => {
|
|
55
251
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
252
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
256
|
+
return fallback();
|
|
257
|
+
}
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const recordHighlight = async (options) => {
|
|
262
|
+
if (options.state.startedAt === undefined || options.durationMs <= 0) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (!(await locatorIsAttached(options.locator))) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const snapshot = await options.locator.evaluate((element) => {
|
|
270
|
+
const rect = element.getBoundingClientRect();
|
|
271
|
+
return {
|
|
272
|
+
rect: {
|
|
273
|
+
height: rect.height,
|
|
274
|
+
width: rect.width,
|
|
275
|
+
x: rect.left,
|
|
276
|
+
y: rect.top,
|
|
277
|
+
},
|
|
278
|
+
viewport: {
|
|
279
|
+
height: window.innerHeight,
|
|
280
|
+
width: window.innerWidth,
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
if (snapshot.rect.width <= 0 ||
|
|
285
|
+
snapshot.rect.height <= 0 ||
|
|
286
|
+
snapshot.viewport.width <= 0 ||
|
|
287
|
+
snapshot.viewport.height <= 0) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const image = `video-mode-highlight-${options.state.highlightImageIndex}.png`;
|
|
291
|
+
options.state.highlightImageIndex += 1;
|
|
292
|
+
const imagePath = join(options.testInfo.outputDir, image);
|
|
293
|
+
await mkdir(options.testInfo.outputDir, { recursive: true });
|
|
294
|
+
await options.locator.page().screenshot({ path: imagePath, scale: "css" });
|
|
295
|
+
const start = Math.round(performance.now() - options.state.startedAt);
|
|
296
|
+
const highlight = {
|
|
297
|
+
color: options.color,
|
|
298
|
+
end: start + Math.round(options.durationMs),
|
|
299
|
+
image,
|
|
300
|
+
method: options.method,
|
|
301
|
+
rect: snapshot.rect,
|
|
302
|
+
start,
|
|
303
|
+
thickness: options.thickness,
|
|
304
|
+
viewport: snapshot.viewport,
|
|
305
|
+
};
|
|
306
|
+
options.state.highlights.push(highlight);
|
|
307
|
+
return highlight;
|
|
61
308
|
}
|
|
62
309
|
catch {
|
|
63
|
-
// Element may
|
|
310
|
+
// Element may disappear between the actionability wait and the snapshot.
|
|
64
311
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
312
|
+
};
|
|
313
|
+
const recordDeadAirSpan = (state, span) => {
|
|
314
|
+
const start = Math.round(span.start);
|
|
315
|
+
const end = Math.round(span.end);
|
|
316
|
+
if (end > start) {
|
|
317
|
+
state.deadAirSpans.push({ end, start });
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const recordDeadAir = async (state, action) => {
|
|
321
|
+
if (state.startedAt === undefined || state.deadAirDepth > 0) {
|
|
322
|
+
return await action();
|
|
323
|
+
}
|
|
324
|
+
const start = performance.now() - state.startedAt;
|
|
325
|
+
state.deadAirDepth += 1;
|
|
326
|
+
try {
|
|
327
|
+
return await action();
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
state.deadAirDepth -= 1;
|
|
331
|
+
const end = performance.now() - state.startedAt;
|
|
332
|
+
recordDeadAirSpan(state, {
|
|
333
|
+
end: Math.round(end),
|
|
334
|
+
start: Math.round(start),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
const mergeVideoSpans = (spans) => {
|
|
339
|
+
const sorted = spans
|
|
340
|
+
.map((span) => ({
|
|
341
|
+
end: Math.round(span.end),
|
|
342
|
+
start: Math.round(span.start),
|
|
343
|
+
}))
|
|
344
|
+
.filter((span) => span.end > span.start)
|
|
345
|
+
.sort((left, right) => left.start - right.start || left.end - right.end);
|
|
346
|
+
const merged = [];
|
|
347
|
+
for (const span of sorted) {
|
|
348
|
+
const previous = merged[merged.length - 1];
|
|
349
|
+
if (previous && span.start <= previous.end) {
|
|
350
|
+
previous.end = Math.max(previous.end, span.end);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
merged.push(span);
|
|
354
|
+
}
|
|
355
|
+
return merged;
|
|
356
|
+
};
|
|
357
|
+
const normalizeVideoHighlights = (highlights) => {
|
|
358
|
+
return highlights
|
|
359
|
+
.map((highlight) => ({
|
|
360
|
+
...highlight,
|
|
361
|
+
actionEnd: highlight.actionEnd === undefined
|
|
362
|
+
? undefined
|
|
363
|
+
: Math.max(Math.round(highlight.actionEnd), Math.round(highlight.start)),
|
|
364
|
+
end: Math.round(highlight.end),
|
|
365
|
+
rect: {
|
|
366
|
+
height: Math.round(highlight.rect.height),
|
|
367
|
+
width: Math.round(highlight.rect.width),
|
|
368
|
+
x: Math.round(highlight.rect.x),
|
|
369
|
+
y: Math.round(highlight.rect.y),
|
|
80
370
|
},
|
|
371
|
+
start: Math.round(highlight.start),
|
|
372
|
+
thickness: Math.round(highlight.thickness),
|
|
373
|
+
viewport: {
|
|
374
|
+
height: Math.round(highlight.viewport.height),
|
|
375
|
+
width: Math.round(highlight.viewport.width),
|
|
376
|
+
},
|
|
377
|
+
}))
|
|
378
|
+
.filter((highlight) => highlight.end > highlight.start)
|
|
379
|
+
.filter((highlight) => highlight.rect.width > 0 && highlight.rect.height > 0)
|
|
380
|
+
.filter((highlight) => highlight.viewport.width > 0 && highlight.viewport.height > 0)
|
|
381
|
+
.sort((left, right) => left.start - right.start || left.end - right.end);
|
|
382
|
+
};
|
|
383
|
+
const formatSeconds = (ms) => {
|
|
384
|
+
const value = (ms / 1000).toFixed(3).replace(/\.?0+$/, "");
|
|
385
|
+
return value || "0";
|
|
386
|
+
};
|
|
387
|
+
const formatFilterNumber = (value) => {
|
|
388
|
+
return Number(value.toFixed(6)).toString();
|
|
389
|
+
};
|
|
390
|
+
const clipVideoSpan = (span, range) => {
|
|
391
|
+
const start = Math.max(range.start, Math.min(Math.round(span.start), range.end));
|
|
392
|
+
const end = Math.max(range.start, Math.min(Math.round(span.end), range.end));
|
|
393
|
+
if (end <= start) {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
return { end, start };
|
|
397
|
+
};
|
|
398
|
+
const videoSpansOverlap = (left, right) => {
|
|
399
|
+
return left.start < right.end && right.start < left.end;
|
|
400
|
+
};
|
|
401
|
+
const deadAirSpeed = (span, thresholdMs) => {
|
|
402
|
+
const duration = span.end - span.start;
|
|
403
|
+
if (duration <= thresholdMs) {
|
|
404
|
+
return 1;
|
|
405
|
+
}
|
|
406
|
+
if (thresholdMs === 0) {
|
|
407
|
+
return Infinity;
|
|
408
|
+
}
|
|
409
|
+
return duration / thresholdMs;
|
|
410
|
+
};
|
|
411
|
+
const locatorIsAttached = async (locator) => {
|
|
412
|
+
try {
|
|
413
|
+
return (await locator.count()) > 0;
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const recordAttachedWaitFromTiming = (state, timing) => {
|
|
420
|
+
if (state.startedAt === undefined || timing.attachedAtStart) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (timing.attachedAt === undefined) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const start = Math.round(timing.actionStartedAt - state.startedAt);
|
|
427
|
+
const end = Math.round(timing.attachedAt - state.startedAt);
|
|
428
|
+
recordDeadAirSpan(state, { end, start });
|
|
429
|
+
};
|
|
430
|
+
const recordActionElapsedDeadAirFromTiming = (state, timing, options) => {
|
|
431
|
+
if (state.startedAt === undefined) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const start = Math.round(timing.actionStartedAt - state.startedAt);
|
|
435
|
+
const end = Math.round(performance.now() - state.startedAt);
|
|
436
|
+
if (end - start < options.minimumMs) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
recordDeadAirSpan(state, { end, start });
|
|
440
|
+
};
|
|
441
|
+
const recordMiddlewareWaitBeforeVideoMode = (state, timing) => {
|
|
442
|
+
if (state.startedAt === undefined) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const currentMiddleware = [...timing.middlewares]
|
|
446
|
+
.reverse()
|
|
447
|
+
.find((middleware) => middleware.name === "video-mode" && middleware.endedAt === undefined);
|
|
448
|
+
if (!currentMiddleware) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const start = Math.round(timing.actionStartedAt - state.startedAt);
|
|
452
|
+
const end = Math.round(currentMiddleware.startedAt - state.startedAt);
|
|
453
|
+
recordDeadAirSpan(state, { end, start });
|
|
454
|
+
};
|
|
455
|
+
const renderVideoSegments = (options) => {
|
|
456
|
+
const finalEnd = Math.max(0, Math.round(options.finalEnd));
|
|
457
|
+
const start = Math.max(0, Math.min(Math.round(options.start), finalEnd));
|
|
458
|
+
if (finalEnd <= start) {
|
|
459
|
+
return [];
|
|
460
|
+
}
|
|
461
|
+
if (options.thresholdMs === undefined) {
|
|
462
|
+
return [{ end: finalEnd, speed: 1, start }];
|
|
463
|
+
}
|
|
464
|
+
const thresholdMs = options.thresholdMs;
|
|
465
|
+
const deadAir = mergeVideoSpans(options.deadAir
|
|
466
|
+
.map((span) => clipVideoSpan(span, { end: finalEnd, start }))
|
|
467
|
+
.filter((span) => Boolean(span)));
|
|
468
|
+
const boundaries = new Set([start, finalEnd]);
|
|
469
|
+
for (const span of deadAir) {
|
|
470
|
+
boundaries.add(span.start);
|
|
471
|
+
boundaries.add(span.end);
|
|
472
|
+
}
|
|
473
|
+
const sortedBoundaries = [...boundaries].sort((left, right) => left - right);
|
|
474
|
+
const segments = [];
|
|
475
|
+
for (let index = 0; index < sortedBoundaries.length - 1; index += 1) {
|
|
476
|
+
const start = sortedBoundaries[index];
|
|
477
|
+
const end = sortedBoundaries[index + 1];
|
|
478
|
+
if (end <= start) {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const deadAirSpan = deadAir.find((span) => videoSpansOverlap(span, { end, start }));
|
|
482
|
+
const speed = deadAirSpan ? deadAirSpeed(deadAirSpan, thresholdMs) : 1;
|
|
483
|
+
if (!Number.isFinite(speed)) {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
const previous = segments[segments.length - 1];
|
|
487
|
+
if (previous && previous.end === start && previous.speed === speed) {
|
|
488
|
+
previous.end = end;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
segments.push({ end, speed, start });
|
|
492
|
+
}
|
|
493
|
+
return segments;
|
|
494
|
+
};
|
|
495
|
+
const videoPieces = (options) => {
|
|
496
|
+
const pieces = [];
|
|
497
|
+
const frameMs = 50;
|
|
498
|
+
for (const segment of options.segments) {
|
|
499
|
+
let cursor = segment.start;
|
|
500
|
+
const highlights = options.highlights.filter((highlight) => highlight.start >= segment.start && highlight.start < segment.end);
|
|
501
|
+
for (let index = 0; index < highlights.length; index += 1) {
|
|
502
|
+
const highlight = highlights[index];
|
|
503
|
+
const nextHighlight = highlights[index + 1];
|
|
504
|
+
if (highlight.start > cursor) {
|
|
505
|
+
pieces.push({ end: highlight.start, speed: segment.speed, start: cursor });
|
|
506
|
+
}
|
|
507
|
+
let frameStart = Math.max(segment.start, highlight.start - frameMs);
|
|
508
|
+
let frameEnd = highlight.start;
|
|
509
|
+
if (frameEnd <= frameStart) {
|
|
510
|
+
frameStart = highlight.start;
|
|
511
|
+
frameEnd = Math.min(segment.end, frameStart + frameMs);
|
|
512
|
+
}
|
|
513
|
+
if (frameEnd > frameStart) {
|
|
514
|
+
pieces.push({ end: frameEnd, highlight, speed: segment.speed, start: frameStart });
|
|
515
|
+
}
|
|
516
|
+
let nextCursor = Math.max(highlight.start, highlight.actionEnd || highlight.start);
|
|
517
|
+
if (nextHighlight && highlight.end > nextHighlight.start) {
|
|
518
|
+
nextCursor = Math.max(nextCursor, nextHighlight.start);
|
|
519
|
+
}
|
|
520
|
+
cursor = Math.min(segment.end, nextCursor);
|
|
521
|
+
}
|
|
522
|
+
if (segment.end > cursor) {
|
|
523
|
+
pieces.push({ end: segment.end, speed: segment.speed, start: cursor });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return pieces.filter((piece) => piece.end > piece.start);
|
|
527
|
+
};
|
|
528
|
+
const scaleHighlight = (highlight, video) => {
|
|
529
|
+
const scale = Math.min(video.width / highlight.viewport.width, video.height / highlight.viewport.height);
|
|
530
|
+
const x = Math.max(0, Math.round(highlight.rect.x * scale));
|
|
531
|
+
const y = Math.max(0, Math.round(highlight.rect.y * scale));
|
|
532
|
+
const width = Math.max(1, Math.round(highlight.rect.width * scale));
|
|
533
|
+
const height = Math.max(1, Math.round(highlight.rect.height * scale));
|
|
534
|
+
return {
|
|
535
|
+
height: Math.min(height, Math.max(1, video.height - y)),
|
|
536
|
+
width: Math.min(width, Math.max(1, video.width - x)),
|
|
537
|
+
x,
|
|
538
|
+
y,
|
|
539
|
+
};
|
|
540
|
+
};
|
|
541
|
+
const scaledViewportSize = (viewport, video) => {
|
|
542
|
+
const scale = Math.min(video.width / viewport.width, video.height / viewport.height);
|
|
543
|
+
return {
|
|
544
|
+
height: Math.max(1, Math.round(viewport.height * scale)),
|
|
545
|
+
width: Math.max(1, Math.round(viewport.width * scale)),
|
|
546
|
+
};
|
|
547
|
+
};
|
|
548
|
+
const drawboxFilter = (highlight, video) => {
|
|
549
|
+
const rect = scaleHighlight(highlight, video);
|
|
550
|
+
return [
|
|
551
|
+
`drawbox=x=${rect.x}`,
|
|
552
|
+
`y=${rect.y}`,
|
|
553
|
+
`w=${rect.width}`,
|
|
554
|
+
`h=${rect.height}`,
|
|
555
|
+
`color=${highlight.color}`,
|
|
556
|
+
`t=${Math.max(1, Math.round(highlight.thickness))}`,
|
|
557
|
+
].join(":");
|
|
558
|
+
};
|
|
559
|
+
const renderedPieceDuration = (piece) => {
|
|
560
|
+
const sourceDuration = (piece.end - piece.start) / piece.speed;
|
|
561
|
+
if (!piece.highlight) {
|
|
562
|
+
return sourceDuration;
|
|
563
|
+
}
|
|
564
|
+
const highlightDuration = piece.highlight.end - piece.highlight.start;
|
|
565
|
+
if (piece.highlight.image) {
|
|
566
|
+
return highlightDuration;
|
|
567
|
+
}
|
|
568
|
+
return Math.max(sourceDuration, highlightDuration);
|
|
569
|
+
};
|
|
570
|
+
const renderedVideoPieces = (pieces) => {
|
|
571
|
+
let cursor = 0;
|
|
572
|
+
const rendered = [];
|
|
573
|
+
for (const piece of pieces) {
|
|
574
|
+
const duration = renderedPieceDuration(piece);
|
|
575
|
+
rendered.push({
|
|
576
|
+
...piece,
|
|
577
|
+
outputEnd: cursor + duration,
|
|
578
|
+
outputStart: cursor,
|
|
579
|
+
});
|
|
580
|
+
cursor += duration;
|
|
581
|
+
}
|
|
582
|
+
return rendered;
|
|
583
|
+
};
|
|
584
|
+
const highlightCursorPoint = (highlight, video) => {
|
|
585
|
+
const rect = scaleHighlight(highlight, video);
|
|
586
|
+
return {
|
|
587
|
+
x: Math.max(0, Math.min(video.width - 1, rect.x + rect.width / 2)),
|
|
588
|
+
y: Math.max(0, Math.min(video.height - 1, rect.y + rect.height / 2)),
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
const pushCursorWaypoint = (waypoints, waypoint) => {
|
|
592
|
+
const rounded = {
|
|
593
|
+
at: Math.round(waypoint.at),
|
|
594
|
+
x: Math.round(waypoint.x),
|
|
595
|
+
y: Math.round(waypoint.y),
|
|
596
|
+
};
|
|
597
|
+
const previous = waypoints[waypoints.length - 1];
|
|
598
|
+
if (previous && previous.at === rounded.at) {
|
|
599
|
+
previous.x = rounded.x;
|
|
600
|
+
previous.y = rounded.y;
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
waypoints.push(rounded);
|
|
604
|
+
};
|
|
605
|
+
const cursorTargets = (options) => {
|
|
606
|
+
const targets = [];
|
|
607
|
+
for (const highlight of options.highlights) {
|
|
608
|
+
const piece = options.pieces.find((candidate) => candidate.highlight === highlight);
|
|
609
|
+
if (!piece) {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
targets.push({
|
|
613
|
+
method: highlight.method,
|
|
614
|
+
outputEnd: piece.outputEnd,
|
|
615
|
+
outputStart: piece.outputStart,
|
|
616
|
+
point: highlightCursorPoint(highlight, options.video),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
return targets;
|
|
620
|
+
};
|
|
621
|
+
const cursorMovementIdealDuration = (options) => {
|
|
622
|
+
const distance = Math.hypot(options.target.point.x - options.currentPoint.x, options.target.point.y - options.currentPoint.y);
|
|
623
|
+
const duration = (distance / CURSOR_MOVEMENT_SPEED_PX_PER_SECOND) * 1000;
|
|
624
|
+
return Math.max(CURSOR_MOVEMENT_MIN_IDEAL_MS, Math.min(CURSOR_MOVEMENT_MAX_IDEAL_MS, duration));
|
|
625
|
+
};
|
|
626
|
+
const cursorArrivalDeadline = (options) => {
|
|
627
|
+
const target = options.target;
|
|
628
|
+
const latestArrivalWithMinimumRest = Math.max(target.outputStart, target.outputEnd - CURSOR_REST_BEFORE_ACTION_MS);
|
|
629
|
+
const idealHoldArrival = Math.max(target.outputStart, target.outputEnd - CURSOR_TARGET_HOLD_IDEAL_MS);
|
|
630
|
+
const readableMovementArrival = options.earliestStart + cursorMovementIdealDuration(options);
|
|
631
|
+
return Math.min(latestArrivalWithMinimumRest, Math.max(idealHoldArrival, readableMovementArrival));
|
|
632
|
+
};
|
|
633
|
+
const cursorMovementTiming = (options) => {
|
|
634
|
+
const deadline = cursorArrivalDeadline(options);
|
|
635
|
+
const available = Math.max(0, deadline - options.earliestStart);
|
|
636
|
+
const idealDuration = Math.min(cursorMovementIdealDuration(options), CURSOR_MOVEMENT_MAX_MS);
|
|
637
|
+
const duration = available < CURSOR_MOVEMENT_MIN_MS ? available : Math.min(idealDuration, available);
|
|
638
|
+
const startAt = Math.max(options.earliestStart, deadline - duration);
|
|
639
|
+
return {
|
|
640
|
+
arriveAt: startAt + duration,
|
|
641
|
+
startAt,
|
|
81
642
|
};
|
|
82
643
|
};
|
|
83
644
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
645
|
+
* Plan cursor motion backwards from each action's click/commit moment.
|
|
646
|
+
*
|
|
647
|
+
* The cursor starts in the center and switches to a target-specific shape only
|
|
648
|
+
* after arriving. Movement aims for a readable distance-based speed, clamped to
|
|
649
|
+
* a short 300-700ms range, while still compressing toward 200ms when the
|
|
650
|
+
* existing video timeline has no room. The arrival point preserves an ideal 1s
|
|
651
|
+
* target hold when possible and otherwise gives the hand/text cursor most of
|
|
652
|
+
* the configured highlight hold without extending product interaction time.
|
|
86
653
|
*/
|
|
654
|
+
const cursorPlan = (targets, video) => {
|
|
655
|
+
const waypoints = [];
|
|
656
|
+
const plannedTargets = [];
|
|
657
|
+
let currentPoint = {
|
|
658
|
+
x: video.width / 2,
|
|
659
|
+
y: video.height / 2,
|
|
660
|
+
};
|
|
661
|
+
let earliestStart = 0;
|
|
662
|
+
if (targets.length === 0) {
|
|
663
|
+
return { targets: plannedTargets, waypoints };
|
|
664
|
+
}
|
|
665
|
+
pushCursorWaypoint(waypoints, {
|
|
666
|
+
at: 0,
|
|
667
|
+
x: currentPoint.x,
|
|
668
|
+
y: currentPoint.y,
|
|
669
|
+
});
|
|
670
|
+
for (let index = 0; index < targets.length; index += 1) {
|
|
671
|
+
const target = targets[index];
|
|
672
|
+
const nextTarget = targets[index + 1];
|
|
673
|
+
const movement = cursorMovementTiming({ currentPoint, earliestStart, target });
|
|
674
|
+
const holdEnd = nextTarget
|
|
675
|
+
? Math.min(target.outputEnd, nextTarget.outputStart)
|
|
676
|
+
: target.outputEnd;
|
|
677
|
+
pushCursorWaypoint(waypoints, {
|
|
678
|
+
at: movement.startAt,
|
|
679
|
+
x: currentPoint.x,
|
|
680
|
+
y: currentPoint.y,
|
|
681
|
+
});
|
|
682
|
+
pushCursorWaypoint(waypoints, {
|
|
683
|
+
at: movement.arriveAt,
|
|
684
|
+
x: target.point.x,
|
|
685
|
+
y: target.point.y,
|
|
686
|
+
});
|
|
687
|
+
pushCursorWaypoint(waypoints, {
|
|
688
|
+
at: Math.max(holdEnd, movement.arriveAt),
|
|
689
|
+
x: target.point.x,
|
|
690
|
+
y: target.point.y,
|
|
691
|
+
});
|
|
692
|
+
plannedTargets.push({
|
|
693
|
+
...target,
|
|
694
|
+
arriveAt: movement.arriveAt,
|
|
695
|
+
});
|
|
696
|
+
currentPoint = target.point;
|
|
697
|
+
earliestStart = Math.max(earliestStart, target.outputEnd);
|
|
698
|
+
}
|
|
699
|
+
return { targets: plannedTargets, waypoints };
|
|
700
|
+
};
|
|
701
|
+
const methodCursorSpans = (targets, methods) => {
|
|
702
|
+
return targets
|
|
703
|
+
.filter((target) => target.method !== undefined && methods.includes(target.method))
|
|
704
|
+
.map((target) => ({
|
|
705
|
+
end: target.outputEnd,
|
|
706
|
+
start: target.arriveAt,
|
|
707
|
+
}))
|
|
708
|
+
.filter((span) => span.end > span.start);
|
|
709
|
+
};
|
|
710
|
+
const textCursorSpans = (targets) => {
|
|
711
|
+
return targets
|
|
712
|
+
.filter((target) => target.method === "fill" || target.method === "type")
|
|
713
|
+
.map((target) => ({
|
|
714
|
+
end: Math.min(target.arriveAt + TEXT_CURSOR_HOLD_IDEAL_MS, Math.max(target.arriveAt, target.outputEnd - TEXT_CURSOR_POINTER_TAIL_MS)),
|
|
715
|
+
start: target.arriveAt,
|
|
716
|
+
}))
|
|
717
|
+
.filter((span) => span.end > span.start);
|
|
718
|
+
};
|
|
719
|
+
const cursorActivitySpan = (targets, waypoints) => {
|
|
720
|
+
if (targets.length === 0 || waypoints.length === 0) {
|
|
721
|
+
return undefined;
|
|
722
|
+
}
|
|
723
|
+
const lastTarget = targets[targets.length - 1];
|
|
724
|
+
const tailDuration = Math.max(0, lastTarget.outputEnd - lastTarget.outputStart);
|
|
725
|
+
return {
|
|
726
|
+
end: lastTarget.outputEnd + tailDuration,
|
|
727
|
+
start: waypoints[0].at,
|
|
728
|
+
};
|
|
729
|
+
};
|
|
730
|
+
const cursorExpression = (waypoints, property) => {
|
|
731
|
+
let expression = formatFilterNumber(waypoints[waypoints.length - 1][property]);
|
|
732
|
+
for (let index = waypoints.length - 2; index >= 0; index -= 1) {
|
|
733
|
+
const from = waypoints[index];
|
|
734
|
+
const to = waypoints[index + 1];
|
|
735
|
+
if (to.at <= from.at) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
const start = formatSeconds(from.at);
|
|
739
|
+
const end = formatSeconds(to.at);
|
|
740
|
+
const progress = `((t-${start})/(${end}-${start}))`;
|
|
741
|
+
const eased = `((${progress})*(${progress})*(3-2*(${progress})))`;
|
|
742
|
+
const delta = to[property] - from[property];
|
|
743
|
+
const value = delta === 0
|
|
744
|
+
? formatFilterNumber(from[property])
|
|
745
|
+
: `(${formatFilterNumber(from[property])}+(${formatFilterNumber(delta)})*${eased})`;
|
|
746
|
+
expression = `if(between(t\\,${start}\\,${end})\\,${value}\\,${expression})`;
|
|
747
|
+
}
|
|
748
|
+
return expression;
|
|
749
|
+
};
|
|
750
|
+
const cursorOverlayFilters = (options) => {
|
|
751
|
+
if (options.waypoints.length === 0) {
|
|
752
|
+
return `[${options.inputLabel}]null[${options.outputLabel}]`;
|
|
753
|
+
}
|
|
754
|
+
const pointerScale = options.pointerInput.size / options.pointerInput.sourceSize;
|
|
755
|
+
const x = `(${cursorExpression(options.waypoints, "x")})-${formatFilterNumber(options.pointerInput.hotspot.x * pointerScale)}`;
|
|
756
|
+
const y = `(${cursorExpression(options.waypoints, "y")})-${formatFilterNumber(options.pointerInput.hotspot.y * pointerScale)}`;
|
|
757
|
+
return [
|
|
758
|
+
`[${options.pointerInput.inputIndex}:v]scale=w=${options.pointerInput.size}:h=${options.pointerInput.size},format=rgba[pointercursor]`,
|
|
759
|
+
[
|
|
760
|
+
`[${options.inputLabel}][pointercursor]overlay=x='${x}'`,
|
|
761
|
+
`y='${y}'`,
|
|
762
|
+
"eval=frame",
|
|
763
|
+
"eof_action=pass",
|
|
764
|
+
`enable='${options.enable}'[${options.outputLabel}]`,
|
|
765
|
+
].join(":"),
|
|
766
|
+
].join(";");
|
|
767
|
+
};
|
|
768
|
+
const videoSpanExpression = (spans) => {
|
|
769
|
+
return spans
|
|
770
|
+
.map((span) => `between(t\\,${formatSeconds(span.start)}\\,${formatSeconds(span.end)})`)
|
|
771
|
+
.join("+");
|
|
772
|
+
};
|
|
773
|
+
const renderedVideoFilter = (options) => {
|
|
774
|
+
const highlightInputByImage = new Map(options.highlightInputs.map((input) => [input.image, input]));
|
|
775
|
+
const pieces = videoPieces({
|
|
776
|
+
highlights: options.highlights,
|
|
777
|
+
segments: options.segments,
|
|
778
|
+
});
|
|
779
|
+
const renderedPieces = renderedVideoPieces(pieces);
|
|
780
|
+
const targets = cursorTargets({
|
|
781
|
+
highlights: options.highlights,
|
|
782
|
+
pieces: renderedPieces,
|
|
783
|
+
video: options.video,
|
|
784
|
+
});
|
|
785
|
+
const plan = cursorPlan(targets, options.video);
|
|
786
|
+
const clickSpans = methodCursorSpans(plan.targets, ["click"]);
|
|
787
|
+
const textSpans = textCursorSpans(plan.targets);
|
|
788
|
+
const activitySpan = cursorActivitySpan(targets, plan.waypoints);
|
|
789
|
+
const clickSpanExpression = videoSpanExpression(clickSpans);
|
|
790
|
+
const textSpanExpression = videoSpanExpression(textSpans);
|
|
791
|
+
if (pieces.length === 0) {
|
|
792
|
+
return undefined;
|
|
793
|
+
}
|
|
794
|
+
const filters = [];
|
|
795
|
+
const labels = [];
|
|
796
|
+
for (let index = 0; index < pieces.length; index += 1) {
|
|
797
|
+
const piece = pieces[index];
|
|
798
|
+
const label = `render${index}`;
|
|
799
|
+
labels.push(`[${label}]`);
|
|
800
|
+
const operations = [];
|
|
801
|
+
if (piece.highlight?.image && highlightInputByImage.has(piece.highlight.image)) {
|
|
802
|
+
const input = highlightInputByImage.get(piece.highlight.image);
|
|
803
|
+
const scaledViewport = scaledViewportSize(piece.highlight.viewport, options.video);
|
|
804
|
+
operations.push(`[${input.inputIndex}:v]scale=w=${scaledViewport.width}:h=${scaledViewport.height}`);
|
|
805
|
+
operations.push(`pad=w=${options.video.width}:h=${options.video.height}:x=0:y=0:color=gray`);
|
|
806
|
+
if (options.highlightMode === "outline") {
|
|
807
|
+
operations.push(drawboxFilter(piece.highlight, options.video));
|
|
808
|
+
}
|
|
809
|
+
operations.push(`trim=start=0:end=${formatSeconds(piece.highlight.end - piece.highlight.start)}`);
|
|
810
|
+
operations.push("setpts=PTS-STARTPTS");
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
operations.push(`[0:v]trim=start=${formatSeconds(piece.start)}:end=${formatSeconds(piece.end)}`);
|
|
814
|
+
operations.push(`setpts=(PTS-STARTPTS)/${formatFilterNumber(piece.speed)}`);
|
|
815
|
+
}
|
|
816
|
+
if (piece.highlight && !piece.highlight.image) {
|
|
817
|
+
const sourceDuration = (piece.end - piece.start) / piece.speed;
|
|
818
|
+
if (options.highlightMode === "outline") {
|
|
819
|
+
operations.push(drawboxFilter(piece.highlight, options.video));
|
|
820
|
+
}
|
|
821
|
+
operations.push(`tpad=stop_mode=clone:stop_duration=${formatSeconds(Math.max(0, piece.highlight.end - piece.highlight.start - sourceDuration))}`);
|
|
822
|
+
}
|
|
823
|
+
filters.push(`${operations.join(",")}[${label}]`);
|
|
824
|
+
}
|
|
825
|
+
const concatLabel = labels.length === 1 ? labels[0].slice(1, -1) : "renderconcat";
|
|
826
|
+
if (labels.length > 1) {
|
|
827
|
+
filters.push(`${labels.join("")}concat=n=${labels.length}:v=1:a=0[${concatLabel}]`);
|
|
828
|
+
}
|
|
829
|
+
const finalHoldMs = Math.max(0, Math.round(options.finalHoldMs));
|
|
830
|
+
const outputLabel = finalHoldMs > 0 ? "renderout" : concatLabel;
|
|
831
|
+
if (finalHoldMs > 0) {
|
|
832
|
+
filters.push(`[${concatLabel}]tpad=stop_mode=clone:stop_duration=${formatSeconds(finalHoldMs)}[${outputLabel}]`);
|
|
833
|
+
}
|
|
834
|
+
if (options.highlightMode === "pointer" &&
|
|
835
|
+
options.cursorPointerInput &&
|
|
836
|
+
plan.waypoints.length > 0 &&
|
|
837
|
+
activitySpan) {
|
|
838
|
+
const cursorOutputLabel = "renderpointer";
|
|
839
|
+
const cursorActivityExpression = `between(t\\,${formatSeconds(activitySpan.start)}\\,${formatSeconds(activitySpan.end)})`;
|
|
840
|
+
const specialCursorExpression = [clickSpanExpression, textSpanExpression]
|
|
841
|
+
.filter(Boolean)
|
|
842
|
+
.join("+");
|
|
843
|
+
const cursorEnable = specialCursorExpression
|
|
844
|
+
? `${cursorActivityExpression}*not(${specialCursorExpression})`
|
|
845
|
+
: cursorActivityExpression;
|
|
846
|
+
filters.push(cursorOverlayFilters({
|
|
847
|
+
enable: cursorEnable,
|
|
848
|
+
inputLabel: outputLabel,
|
|
849
|
+
outputLabel: cursorOutputLabel,
|
|
850
|
+
pointerInput: options.cursorPointerInput,
|
|
851
|
+
waypoints: plan.waypoints,
|
|
852
|
+
}));
|
|
853
|
+
let pointerOutputLabel = cursorOutputLabel;
|
|
854
|
+
if (options.textPointerInput && textSpanExpression) {
|
|
855
|
+
const textPointerOutputLabel = "rendertextpointer";
|
|
856
|
+
filters.push(cursorOverlayFilters({
|
|
857
|
+
enable: textSpanExpression,
|
|
858
|
+
inputLabel: pointerOutputLabel,
|
|
859
|
+
outputLabel: textPointerOutputLabel,
|
|
860
|
+
pointerInput: options.textPointerInput,
|
|
861
|
+
waypoints: plan.waypoints,
|
|
862
|
+
}));
|
|
863
|
+
pointerOutputLabel = textPointerOutputLabel;
|
|
864
|
+
}
|
|
865
|
+
if (options.clickPointerInput && clickSpanExpression) {
|
|
866
|
+
const clickPointerOutputLabel = "renderclickpointer";
|
|
867
|
+
filters.push(cursorOverlayFilters({
|
|
868
|
+
enable: clickSpanExpression,
|
|
869
|
+
inputLabel: pointerOutputLabel,
|
|
870
|
+
outputLabel: clickPointerOutputLabel,
|
|
871
|
+
pointerInput: options.clickPointerInput,
|
|
872
|
+
waypoints: plan.waypoints,
|
|
873
|
+
}));
|
|
874
|
+
return {
|
|
875
|
+
outputLabel: clickPointerOutputLabel,
|
|
876
|
+
value: filters.join(";"),
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
outputLabel: pointerOutputLabel,
|
|
881
|
+
value: filters.join(";"),
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
return {
|
|
885
|
+
outputLabel,
|
|
886
|
+
value: filters.join(";"),
|
|
887
|
+
};
|
|
888
|
+
};
|
|
889
|
+
const videoInfo = async (path) => {
|
|
890
|
+
const { stdout } = await execFile("ffprobe", [
|
|
891
|
+
"-v",
|
|
892
|
+
"error",
|
|
893
|
+
"-show_entries",
|
|
894
|
+
"format=duration:stream=width,height",
|
|
895
|
+
"-of",
|
|
896
|
+
"json",
|
|
897
|
+
path,
|
|
898
|
+
], { maxBuffer: 1024 * 1024 });
|
|
899
|
+
const payload = JSON.parse(stdout);
|
|
900
|
+
const seconds = Number(payload.format?.duration);
|
|
901
|
+
const stream = payload.streams?.find((candidate) => candidate.width && candidate.height);
|
|
902
|
+
if (!Number.isFinite(seconds) || seconds <= 0 || !stream) {
|
|
903
|
+
throw new Error(`Could not read video duration from ffprobe output: ${stdout}`);
|
|
904
|
+
}
|
|
905
|
+
return {
|
|
906
|
+
durationMs: Math.round(seconds * 1000),
|
|
907
|
+
height: Number(stream.height),
|
|
908
|
+
width: Number(stream.width),
|
|
909
|
+
};
|
|
910
|
+
};
|
|
911
|
+
const waitForNonEmptyFile = async (path, timeoutMs = 5000) => {
|
|
912
|
+
const start = Date.now();
|
|
913
|
+
let lastError;
|
|
914
|
+
while (Date.now() - start < timeoutMs) {
|
|
915
|
+
try {
|
|
916
|
+
const stats = await stat(path);
|
|
917
|
+
if (stats.size > 0) {
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
lastError = new Error(`File exists but is empty: ${path}`);
|
|
921
|
+
}
|
|
922
|
+
catch (error) {
|
|
923
|
+
lastError = error;
|
|
924
|
+
}
|
|
925
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
926
|
+
}
|
|
927
|
+
throw lastError instanceof Error
|
|
928
|
+
? lastError
|
|
929
|
+
: new Error(`Timed out waiting for non-empty file: ${path}`);
|
|
930
|
+
};
|
|
931
|
+
const escapeHtml = (value) => {
|
|
932
|
+
const entities = {
|
|
933
|
+
"&": "&",
|
|
934
|
+
'"': """,
|
|
935
|
+
"'": "'",
|
|
936
|
+
"<": "<",
|
|
937
|
+
">": ">",
|
|
938
|
+
};
|
|
939
|
+
return value.replace(/[&"'<>]/g, (character) => entities[character]);
|
|
940
|
+
};
|
|
941
|
+
const videoElementHtml = (options) => {
|
|
942
|
+
const activeKey = escapeHtml(options.activeKey);
|
|
943
|
+
const label = escapeHtml(options.label);
|
|
944
|
+
const source = escapeHtml(options.source);
|
|
945
|
+
return `
|
|
946
|
+
<section class="video-section" data-active-key="${activeKey}">
|
|
947
|
+
<div class="section-title">${label}</div>
|
|
948
|
+
<video controls preload="metadata" tabindex="0" data-active-key="${activeKey}">
|
|
949
|
+
<source src="${source}" type="video/webm" />
|
|
950
|
+
</video>
|
|
951
|
+
</section>`;
|
|
952
|
+
};
|
|
953
|
+
const playwrightReportAttachmentName = async (path) => {
|
|
954
|
+
const data = await readFile(path);
|
|
955
|
+
return `${createHash("sha1").update(data).digest("hex")}${extname(path)}`;
|
|
956
|
+
};
|
|
957
|
+
const videoModePlayerHtml = (options) => {
|
|
958
|
+
const primary = options.rendered || options.raw;
|
|
959
|
+
const primaryLabel = options.rendered ? "Rendered video" : "Raw video";
|
|
960
|
+
const primaryActiveKey = options.rendered ? "rendered" : "raw";
|
|
961
|
+
const rawDetails = options.rendered
|
|
962
|
+
? `
|
|
963
|
+
<details>
|
|
964
|
+
<summary>Raw video</summary>
|
|
965
|
+
${videoElementHtml({ activeKey: "raw", label: "Raw video", source: options.raw })}
|
|
966
|
+
</details>`
|
|
967
|
+
: "";
|
|
968
|
+
return `<!doctype html>
|
|
969
|
+
<html lang="en">
|
|
970
|
+
<head>
|
|
971
|
+
<meta charset="utf-8" />
|
|
972
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
973
|
+
<title>video-mode player</title>
|
|
974
|
+
<style>
|
|
975
|
+
:root {
|
|
976
|
+
color-scheme: dark;
|
|
977
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
978
|
+
background: #111;
|
|
979
|
+
color: #eee;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
body {
|
|
983
|
+
margin: 0;
|
|
984
|
+
min-height: 100vh;
|
|
985
|
+
background: #111;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
header {
|
|
989
|
+
align-items: center;
|
|
990
|
+
background: #1d1d1d;
|
|
991
|
+
border-bottom: 1px solid #333;
|
|
992
|
+
display: grid;
|
|
993
|
+
gap: 12px;
|
|
994
|
+
grid-template-columns: 1fr auto auto auto;
|
|
995
|
+
padding: 10px 12px;
|
|
996
|
+
position: sticky;
|
|
997
|
+
top: 0;
|
|
998
|
+
z-index: 1;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
main {
|
|
1002
|
+
display: grid;
|
|
1003
|
+
gap: 14px;
|
|
1004
|
+
grid-template-columns: minmax(0, 1fr) 260px;
|
|
1005
|
+
padding: 12px;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
video {
|
|
1009
|
+
background: #000;
|
|
1010
|
+
max-height: calc(100vh - 92px);
|
|
1011
|
+
outline: 1px solid #333;
|
|
1012
|
+
width: 100%;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
label,
|
|
1016
|
+
button,
|
|
1017
|
+
input {
|
|
1018
|
+
font: inherit;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
input {
|
|
1022
|
+
background: #050505;
|
|
1023
|
+
border: 1px solid #555;
|
|
1024
|
+
color: #eee;
|
|
1025
|
+
padding: 6px 8px;
|
|
1026
|
+
width: 72px;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
button {
|
|
1030
|
+
background: #333;
|
|
1031
|
+
border: 1px solid #666;
|
|
1032
|
+
color: #eee;
|
|
1033
|
+
cursor: pointer;
|
|
1034
|
+
padding: 6px 10px;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
aside {
|
|
1038
|
+
color: #ddd;
|
|
1039
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1040
|
+
font-size: 13px;
|
|
1041
|
+
line-height: 1.5;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
details {
|
|
1045
|
+
border-top: 1px solid #333;
|
|
1046
|
+
margin-top: 14px;
|
|
1047
|
+
padding-top: 12px;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
summary {
|
|
1051
|
+
cursor: pointer;
|
|
1052
|
+
margin-bottom: 12px;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
a {
|
|
1056
|
+
color: #9ecbff;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.section-title {
|
|
1060
|
+
color: #aaa;
|
|
1061
|
+
font-size: 13px;
|
|
1062
|
+
margin-bottom: 8px;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
.hint {
|
|
1066
|
+
color: #aaa;
|
|
1067
|
+
margin-top: 12px;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
@media (max-width: 820px) {
|
|
1071
|
+
header,
|
|
1072
|
+
main {
|
|
1073
|
+
grid-template-columns: 1fr;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
video {
|
|
1077
|
+
max-height: none;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
</style>
|
|
1081
|
+
</head>
|
|
1082
|
+
<body>
|
|
1083
|
+
<header>
|
|
1084
|
+
<div>video-mode player</div>
|
|
1085
|
+
<label>fps <input id="fps" type="number" min="1" step="1" value="25" /></label>
|
|
1086
|
+
<button id="back" type="button">-1 frame</button>
|
|
1087
|
+
<button id="forward" type="button">+1 frame</button>
|
|
1088
|
+
</header>
|
|
1089
|
+
<main>
|
|
1090
|
+
<div>
|
|
1091
|
+
${videoElementHtml({ activeKey: primaryActiveKey, label: primaryLabel, source: primary })}
|
|
1092
|
+
${rawDetails}
|
|
1093
|
+
</div>
|
|
1094
|
+
<aside>
|
|
1095
|
+
<div>active: <span id="active">-</span></div>
|
|
1096
|
+
<div>time: <span id="time">0.000</span>s</div>
|
|
1097
|
+
<div>frame: <span id="frame">0</span></div>
|
|
1098
|
+
<div>duration: <span id="duration">?</span>s</div>
|
|
1099
|
+
<div class="hint">Left/right steps one frame. Shift+left/right steps ten. Space toggles play.</div>
|
|
1100
|
+
<div class="hint"><a href="${VIDEO_MODE_METADATA_FILE}">${VIDEO_MODE_METADATA_FILE}</a></div>
|
|
1101
|
+
</aside>
|
|
1102
|
+
</main>
|
|
1103
|
+
<script>
|
|
1104
|
+
const videos = Array.from(document.querySelectorAll("video"));
|
|
1105
|
+
const fps = document.querySelector("#fps");
|
|
1106
|
+
const active = document.querySelector("#active");
|
|
1107
|
+
const time = document.querySelector("#time");
|
|
1108
|
+
const frame = document.querySelector("#frame");
|
|
1109
|
+
const duration = document.querySelector("#duration");
|
|
1110
|
+
const videoByActiveKey = new Map(videos.map((video) => [video.dataset.activeKey, video]));
|
|
1111
|
+
let activeVideo = videos[0];
|
|
1112
|
+
|
|
1113
|
+
const rate = () => Number(fps.value) || 25;
|
|
1114
|
+
const currentFrame = () => Math.round(activeVideo.currentTime * rate());
|
|
1115
|
+
const activeKeyFor = (video) => video.dataset.activeKey || "rendered";
|
|
1116
|
+
const revealVideo = (video) => {
|
|
1117
|
+
const details = video.closest("details");
|
|
1118
|
+
if (details) details.open = true;
|
|
1119
|
+
};
|
|
1120
|
+
const writeStateToUrl = () => {
|
|
1121
|
+
const next = new URL(location.href);
|
|
1122
|
+
next.searchParams.set("active", activeKeyFor(activeVideo));
|
|
1123
|
+
next.searchParams.set("frame", String(currentFrame()));
|
|
1124
|
+
history.replaceState(null, "", next.href);
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
const setActiveVideo = (video, writeUrl) => {
|
|
1128
|
+
activeVideo = video;
|
|
1129
|
+
revealVideo(video);
|
|
1130
|
+
update(writeUrl);
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
const update = (writeUrl) => {
|
|
1134
|
+
const title = activeVideo.closest(".video-section").querySelector(".section-title").textContent;
|
|
1135
|
+
active.textContent = title;
|
|
1136
|
+
time.textContent = activeVideo.currentTime.toFixed(3);
|
|
1137
|
+
frame.textContent = String(currentFrame());
|
|
1138
|
+
duration.textContent = Number.isFinite(activeVideo.duration) ? activeVideo.duration.toFixed(3) : "?";
|
|
1139
|
+
if (writeUrl) writeStateToUrl();
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
const seekToFrame = (frameNumber, writeUrl) => {
|
|
1143
|
+
if (!Number.isFinite(frameNumber)) return;
|
|
1144
|
+
if (!Number.isFinite(activeVideo.duration)) {
|
|
1145
|
+
activeVideo.addEventListener("loadedmetadata", () => seekToFrame(frameNumber, writeUrl), {
|
|
1146
|
+
once: true,
|
|
1147
|
+
});
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
activeVideo.pause();
|
|
1152
|
+
activeVideo.currentTime = Math.max(
|
|
1153
|
+
0,
|
|
1154
|
+
Math.min(activeVideo.duration, Math.round(frameNumber) / rate()),
|
|
1155
|
+
);
|
|
1156
|
+
update(writeUrl);
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
const stepFrames = (count) => {
|
|
1160
|
+
seekToFrame(currentFrame() + count, true);
|
|
1161
|
+
};
|
|
1162
|
+
|
|
1163
|
+
for (const video of videos) {
|
|
1164
|
+
video.addEventListener("focus", () => setActiveVideo(video, true));
|
|
1165
|
+
video.addEventListener("pointerdown", () => setActiveVideo(video, true));
|
|
1166
|
+
video.addEventListener("mouseenter", () => setActiveVideo(video, true));
|
|
1167
|
+
video.addEventListener("loadedmetadata", () => update(false));
|
|
1168
|
+
video.addEventListener("seeked", () => update(true));
|
|
1169
|
+
video.addEventListener("timeupdate", () => update(true));
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
document.querySelector("#back").addEventListener("click", () => stepFrames(-1));
|
|
1173
|
+
document.querySelector("#forward").addEventListener("click", () => stepFrames(1));
|
|
1174
|
+
fps.addEventListener("input", () => update(true));
|
|
1175
|
+
window.addEventListener("keydown", (event) => {
|
|
1176
|
+
if (event.target instanceof HTMLInputElement) return;
|
|
1177
|
+
let handled = true;
|
|
1178
|
+
if (event.key === "ArrowRight") {
|
|
1179
|
+
stepFrames(event.shiftKey ? 10 : 1);
|
|
1180
|
+
} else if (event.key === "ArrowLeft") {
|
|
1181
|
+
stepFrames(event.shiftKey ? -10 : -1);
|
|
1182
|
+
} else if (event.key === " ") {
|
|
1183
|
+
if (activeVideo.paused) activeVideo.play();
|
|
1184
|
+
else activeVideo.pause();
|
|
1185
|
+
} else {
|
|
1186
|
+
handled = false;
|
|
1187
|
+
}
|
|
1188
|
+
if (!handled) return;
|
|
1189
|
+
event.preventDefault();
|
|
1190
|
+
event.stopPropagation();
|
|
1191
|
+
}, { capture: true });
|
|
1192
|
+
|
|
1193
|
+
const applyUrlState = () => {
|
|
1194
|
+
const params = new URL(location.href).searchParams;
|
|
1195
|
+
const requestedVideo = videoByActiveKey.get(params.get("active"));
|
|
1196
|
+
setActiveVideo(requestedVideo || activeVideo, false);
|
|
1197
|
+
const requestedFrame = Number(params.get("frame"));
|
|
1198
|
+
if (Number.isFinite(requestedFrame)) {
|
|
1199
|
+
seekToFrame(requestedFrame, false);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
update(false);
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
document.body.tabIndex = -1;
|
|
1206
|
+
document.body.focus();
|
|
1207
|
+
applyUrlState();
|
|
1208
|
+
</script>
|
|
1209
|
+
</body>
|
|
1210
|
+
</html>
|
|
1211
|
+
`;
|
|
1212
|
+
};
|
|
1213
|
+
const renderVideo = async (options) => {
|
|
1214
|
+
const info = await videoInfo(options.inputPath);
|
|
1215
|
+
const sourceRangeStart = options.sourceRange.start === undefined ? 0 : options.sourceRange.start;
|
|
1216
|
+
const sourceRangeEnd = options.sourceRange.end === undefined ? info.durationMs : options.sourceRange.end;
|
|
1217
|
+
const rangeStart = Math.max(0, Math.min(Math.round(sourceRangeStart), info.durationMs));
|
|
1218
|
+
const rangeEnd = Math.max(0, Math.min(Math.round(sourceRangeEnd), info.durationMs));
|
|
1219
|
+
if (rangeEnd <= rangeStart) {
|
|
1220
|
+
console.warn(`videoMode source range is empty: start ${rangeStart}ms must be before end ${rangeEnd}ms`);
|
|
1221
|
+
return false;
|
|
1222
|
+
}
|
|
1223
|
+
const highlightInputs = options.highlights
|
|
1224
|
+
.filter((highlight) => highlight.image)
|
|
1225
|
+
.map((highlight, index) => ({
|
|
1226
|
+
durationMs: highlight.end - highlight.start,
|
|
1227
|
+
image: highlight.image,
|
|
1228
|
+
inputIndex: index + 1,
|
|
1229
|
+
path: join(options.outputDir, highlight.image),
|
|
1230
|
+
}));
|
|
1231
|
+
const shouldRenderPointer = options.highlightMode === "pointer" && options.highlights.length > 0;
|
|
1232
|
+
const cursorPointerInput = shouldRenderPointer
|
|
1233
|
+
? {
|
|
1234
|
+
hotspot: VIDEO_MODE_POINTER_HOTSPOT,
|
|
1235
|
+
inputIndex: highlightInputs.length + 1,
|
|
1236
|
+
path: join(options.outputDir, VIDEO_MODE_POINTER_FILE),
|
|
1237
|
+
size: VIDEO_MODE_POINTER_SIZE,
|
|
1238
|
+
sourceSize: VIDEO_MODE_POINTER_SOURCE_SIZE,
|
|
1239
|
+
}
|
|
1240
|
+
: undefined;
|
|
1241
|
+
const clickPointerInput = shouldRenderPointer
|
|
1242
|
+
? {
|
|
1243
|
+
hotspot: VIDEO_MODE_CLICK_POINTER_HOTSPOT,
|
|
1244
|
+
inputIndex: highlightInputs.length + 2,
|
|
1245
|
+
path: join(options.outputDir, VIDEO_MODE_CLICK_POINTER_FILE),
|
|
1246
|
+
size: VIDEO_MODE_CLICK_POINTER_SIZE,
|
|
1247
|
+
sourceSize: VIDEO_MODE_CLICK_POINTER_SOURCE_SIZE,
|
|
1248
|
+
}
|
|
1249
|
+
: undefined;
|
|
1250
|
+
const textPointerInput = shouldRenderPointer
|
|
1251
|
+
? {
|
|
1252
|
+
hotspot: VIDEO_MODE_TEXT_POINTER_HOTSPOT,
|
|
1253
|
+
inputIndex: highlightInputs.length + 3,
|
|
1254
|
+
path: join(options.outputDir, VIDEO_MODE_TEXT_POINTER_FILE),
|
|
1255
|
+
size: VIDEO_MODE_TEXT_POINTER_SIZE,
|
|
1256
|
+
sourceSize: VIDEO_MODE_TEXT_POINTER_SOURCE_SIZE,
|
|
1257
|
+
}
|
|
1258
|
+
: undefined;
|
|
1259
|
+
if (cursorPointerInput) {
|
|
1260
|
+
await writeFile(cursorPointerInput.path, Buffer.from(VIDEO_MODE_POINTER_PNG, "base64"));
|
|
1261
|
+
}
|
|
1262
|
+
if (clickPointerInput) {
|
|
1263
|
+
await writeFile(clickPointerInput.path, Buffer.from(VIDEO_MODE_CLICK_POINTER_PNG, "base64"));
|
|
1264
|
+
}
|
|
1265
|
+
if (textPointerInput) {
|
|
1266
|
+
await writeFile(textPointerInput.path, Buffer.from(VIDEO_MODE_TEXT_POINTER_PNG, "base64"));
|
|
1267
|
+
}
|
|
1268
|
+
const segments = renderVideoSegments({
|
|
1269
|
+
deadAir: options.deadAir,
|
|
1270
|
+
finalEnd: rangeEnd,
|
|
1271
|
+
start: rangeStart,
|
|
1272
|
+
thresholdMs: options.thresholdMs,
|
|
1273
|
+
});
|
|
1274
|
+
const filter = renderedVideoFilter({
|
|
1275
|
+
clickPointerInput,
|
|
1276
|
+
cursorPointerInput,
|
|
1277
|
+
finalHoldMs: options.finalHoldMs,
|
|
1278
|
+
highlightMode: options.highlightMode,
|
|
1279
|
+
highlightInputs,
|
|
1280
|
+
highlights: options.highlights,
|
|
1281
|
+
segments,
|
|
1282
|
+
textPointerInput,
|
|
1283
|
+
video: info,
|
|
1284
|
+
});
|
|
1285
|
+
if (!filter) {
|
|
1286
|
+
return false;
|
|
1287
|
+
}
|
|
1288
|
+
await execFile("ffmpeg", [
|
|
1289
|
+
"-hide_banner",
|
|
1290
|
+
"-loglevel",
|
|
1291
|
+
"error",
|
|
1292
|
+
"-y",
|
|
1293
|
+
"-i",
|
|
1294
|
+
options.inputPath,
|
|
1295
|
+
...highlightInputs.flatMap((input) => [
|
|
1296
|
+
"-loop",
|
|
1297
|
+
"1",
|
|
1298
|
+
"-t",
|
|
1299
|
+
formatSeconds(input.durationMs),
|
|
1300
|
+
"-i",
|
|
1301
|
+
input.path,
|
|
1302
|
+
]),
|
|
1303
|
+
...(cursorPointerInput ? ["-loop", "1", "-i", cursorPointerInput.path] : []),
|
|
1304
|
+
...(clickPointerInput ? ["-loop", "1", "-i", clickPointerInput.path] : []),
|
|
1305
|
+
...(textPointerInput ? ["-loop", "1", "-i", textPointerInput.path] : []),
|
|
1306
|
+
"-filter_complex",
|
|
1307
|
+
filter.value,
|
|
1308
|
+
"-map",
|
|
1309
|
+
`[${filter.outputLabel}]`,
|
|
1310
|
+
"-an",
|
|
1311
|
+
options.outputPath,
|
|
1312
|
+
], { maxBuffer: 10 * 1024 * 1024 });
|
|
1313
|
+
return true;
|
|
1314
|
+
};
|
|
1315
|
+
/** Records video-mode facts and renders annotations into the recorded video. */
|
|
87
1316
|
export const videoMode = (options = {}) => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
1317
|
+
if (process.env.PWDEBUG) {
|
|
1318
|
+
let testInfoForOutputPaths;
|
|
1319
|
+
const controls = {
|
|
1320
|
+
deadAir: async (action) => {
|
|
1321
|
+
return await action();
|
|
1322
|
+
},
|
|
1323
|
+
getVideoTimestamp: () => 0,
|
|
1324
|
+
metadata: async () => ({
|
|
1325
|
+
deadAir: [],
|
|
1326
|
+
highlights: [],
|
|
1327
|
+
outputs: {},
|
|
1328
|
+
schemaVersion: 1,
|
|
1329
|
+
sourceRange: {},
|
|
1330
|
+
timebase: "ms",
|
|
1331
|
+
}),
|
|
1332
|
+
outputPaths: () => {
|
|
1333
|
+
if (!testInfoForOutputPaths) {
|
|
1334
|
+
throw new Error("videoMode.outputPaths() is only available after addPlugins registers videoMode");
|
|
1335
|
+
}
|
|
1336
|
+
return videoModeOutputPaths(testInfoForOutputPaths);
|
|
1337
|
+
},
|
|
1338
|
+
setEndTime: () => { },
|
|
1339
|
+
setStartTime: () => { },
|
|
1340
|
+
};
|
|
1341
|
+
return {
|
|
1342
|
+
...controls,
|
|
1343
|
+
name: "video-mode",
|
|
1344
|
+
pageExtension: ({ testInfo }) => {
|
|
1345
|
+
testInfoForOutputPaths = testInfo;
|
|
1346
|
+
return { videoMode: controls };
|
|
1347
|
+
},
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
const finalHold = resolveNonNegativeNumber({
|
|
1351
|
+
defaultValue: 3000,
|
|
1352
|
+
name: "videoMode finalHold",
|
|
1353
|
+
value: options.finalHold,
|
|
1354
|
+
});
|
|
1355
|
+
const highlight = resolveVideoModeHighlight(options);
|
|
91
1356
|
const skipMethods = options.skipMethods || ["waitFor"];
|
|
92
1357
|
const skipStackFrames = options.skipStackFrames || [];
|
|
1358
|
+
const deadAirThreshold = resolveDeadAirThreshold(options.deadAirThreshold);
|
|
1359
|
+
const trimStart = resolveTrimStart(options.trimStart);
|
|
1360
|
+
const state = {
|
|
1361
|
+
deadAirDepth: 0,
|
|
1362
|
+
deadAirSpans: [],
|
|
1363
|
+
highlightImageIndex: 0,
|
|
1364
|
+
highlights: [],
|
|
1365
|
+
outputs: {},
|
|
1366
|
+
sourceRange: {},
|
|
1367
|
+
startedAt: performance.now(),
|
|
1368
|
+
};
|
|
1369
|
+
let testInfoForOutputPaths;
|
|
1370
|
+
const getVideoTimestamp = () => {
|
|
1371
|
+
const now = performance.now();
|
|
1372
|
+
return Math.round(now - (state.startedAt || now));
|
|
1373
|
+
};
|
|
1374
|
+
const controls = {
|
|
1375
|
+
deadAir: async (action) => {
|
|
1376
|
+
return await recordDeadAir(state, action);
|
|
1377
|
+
},
|
|
1378
|
+
getVideoTimestamp,
|
|
1379
|
+
metadata: async () => {
|
|
1380
|
+
if (!testInfoForOutputPaths) {
|
|
1381
|
+
return metadataFor(state);
|
|
1382
|
+
}
|
|
1383
|
+
return await readVideoModeMetadata(videoModeOutputPaths(testInfoForOutputPaths).metadata, () => metadataFor(state));
|
|
1384
|
+
},
|
|
1385
|
+
outputPaths: () => {
|
|
1386
|
+
if (!testInfoForOutputPaths) {
|
|
1387
|
+
throw new Error("videoMode.outputPaths() is only available after addPlugins registers videoMode");
|
|
1388
|
+
}
|
|
1389
|
+
return videoModeOutputPaths(testInfoForOutputPaths);
|
|
1390
|
+
},
|
|
1391
|
+
setEndTime: (ms = getVideoTimestamp()) => {
|
|
1392
|
+
state.sourceRange.end = resolveVideoTimestamp("setEndTime", ms);
|
|
1393
|
+
},
|
|
1394
|
+
setStartTime: (ms = getVideoTimestamp()) => {
|
|
1395
|
+
state.sourceRange.start = resolveVideoTimestamp("setStartTime", ms);
|
|
1396
|
+
},
|
|
1397
|
+
};
|
|
93
1398
|
return {
|
|
1399
|
+
...controls,
|
|
94
1400
|
name: "video-mode",
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
1401
|
+
pageExtension: ({ testInfo }) => {
|
|
1402
|
+
testInfoForOutputPaths = testInfo;
|
|
1403
|
+
return { videoMode: controls };
|
|
1404
|
+
},
|
|
1405
|
+
middleware: async ({ locator, method, testInfo, timing }, next) => {
|
|
1406
|
+
if (state.deadAirDepth > 0)
|
|
1407
|
+
return next();
|
|
1408
|
+
// Skip if called from internal helpers (navigation, login flows etc)
|
|
1409
|
+
if (skipStackFrames.length > 0) {
|
|
1410
|
+
const stack = new Error().stack || "";
|
|
1411
|
+
if (skipStackFrames.some((frame) => stack.includes(frame)))
|
|
99
1412
|
return next();
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
1413
|
+
}
|
|
1414
|
+
if (method === "waitFor") {
|
|
1415
|
+
try {
|
|
1416
|
+
return await next();
|
|
1417
|
+
}
|
|
1418
|
+
finally {
|
|
1419
|
+
recordActionElapsedDeadAirFromTiming(state, timing, { minimumMs: 0 });
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
recordMiddlewareWaitBeforeVideoMode(state, timing);
|
|
1423
|
+
if (skipMethods.includes(method)) {
|
|
1424
|
+
try {
|
|
1425
|
+
return await next();
|
|
1426
|
+
}
|
|
1427
|
+
finally {
|
|
1428
|
+
if (timing.attachedAtStart) {
|
|
1429
|
+
recordActionElapsedDeadAirFromTiming(state, timing, { minimumMs: 50 });
|
|
1430
|
+
}
|
|
1431
|
+
recordAttachedWaitFromTiming(state, timing);
|
|
105
1432
|
}
|
|
106
|
-
const _ = __addDisposableResource(env_1, await setupHighlight(locator, highlightStyle, pauseBefore), false);
|
|
107
|
-
return await next();
|
|
108
1433
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
1434
|
+
const recordedHighlight = highlight.mode === "off"
|
|
1435
|
+
? undefined
|
|
1436
|
+
: await recordHighlight({
|
|
1437
|
+
color: highlight.color,
|
|
1438
|
+
durationMs: highlight.durationMs,
|
|
1439
|
+
locator,
|
|
1440
|
+
method,
|
|
1441
|
+
state,
|
|
1442
|
+
testInfo,
|
|
1443
|
+
thickness: highlight.thickness,
|
|
1444
|
+
});
|
|
1445
|
+
try {
|
|
1446
|
+
return await next();
|
|
112
1447
|
}
|
|
113
1448
|
finally {
|
|
114
|
-
|
|
1449
|
+
if (recordedHighlight && state.startedAt !== undefined) {
|
|
1450
|
+
recordedHighlight.actionEnd = Math.max(recordedHighlight.start, Math.round(performance.now() - state.startedAt));
|
|
1451
|
+
}
|
|
1452
|
+
if (!recordedHighlight &&
|
|
1453
|
+
(timing.attachedAtStart || timing.attachedAt === undefined)) {
|
|
1454
|
+
recordActionElapsedDeadAirFromTiming(state, timing, { minimumMs: 50 });
|
|
1455
|
+
}
|
|
1456
|
+
recordAttachedWaitFromTiming(state, timing);
|
|
115
1457
|
}
|
|
116
1458
|
},
|
|
117
1459
|
testLifecycle: (emitter) => {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
1460
|
+
const offBeforeTest = emitter.on("beforeTest", ({ page }) => {
|
|
1461
|
+
state.deadAirDepth = 0;
|
|
1462
|
+
state.deadAirSpans = [];
|
|
1463
|
+
state.highlightImageIndex = 0;
|
|
1464
|
+
state.highlights = [];
|
|
1465
|
+
state.outputs = {};
|
|
1466
|
+
state.sourceRange = {};
|
|
1467
|
+
if (state.startedAt) {
|
|
1468
|
+
recordDeadAirSpan(state, {
|
|
1469
|
+
end: Math.round(performance.now() - state.startedAt),
|
|
1470
|
+
start: 0,
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
else {
|
|
1474
|
+
state.startedAt = performance.now();
|
|
1475
|
+
}
|
|
1476
|
+
// Start the video from the moment the app's "ready" element first shows.
|
|
1477
|
+
// Kicked off now (before the test navigates), it resolves whenever the
|
|
1478
|
+
// element appears; a timeout or a page close just leaves the blank
|
|
1479
|
+
// detector to handle it. Never let it reject the test.
|
|
1480
|
+
if (trimStart.selector) {
|
|
1481
|
+
page
|
|
1482
|
+
.locator(trimStart.selector)
|
|
1483
|
+
.first()
|
|
1484
|
+
.waitFor({ state: "visible", timeout: TRIM_START_SELECTOR_TIMEOUT_MS })
|
|
1485
|
+
.then(() => {
|
|
1486
|
+
if (state.sourceRange.start === undefined)
|
|
1487
|
+
controls.setStartTime();
|
|
1488
|
+
})
|
|
1489
|
+
.catch(() => { });
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
const offAfterTestFinalize = emitter.on("afterTestFinalize", async ({ page, testInfo }) => {
|
|
1493
|
+
const metadataBeforeVideo = metadataFor(state);
|
|
1494
|
+
const deadAir = metadataBeforeVideo.deadAir;
|
|
1495
|
+
const highlights = metadataBeforeVideo.highlights;
|
|
1496
|
+
// Note: sourceRange is read fresh from state below, not snapshotted here —
|
|
1497
|
+
// a selector `waitFor` can still resolve during the awaits in this handler
|
|
1498
|
+
// and call setStartTime(), and the render must see that.
|
|
1499
|
+
const video = page.video();
|
|
1500
|
+
if (video) {
|
|
1501
|
+
const paths = videoModeOutputPaths(testInfo);
|
|
1502
|
+
await mkdir(testInfo.outputDir, { recursive: true });
|
|
1503
|
+
if (!page.isClosed()) {
|
|
1504
|
+
await page.close({ runBeforeUnload: false });
|
|
1505
|
+
}
|
|
1506
|
+
const recordedVideoPath = await video.path();
|
|
1507
|
+
await waitForNonEmptyFile(recordedVideoPath);
|
|
1508
|
+
await copyFile(recordedVideoPath, paths.raw);
|
|
1509
|
+
state.outputs.raw = VIDEO_MODE_RAW_FILE;
|
|
1510
|
+
await testInfo.attach("video-raw", {
|
|
1511
|
+
contentType: "video/webm",
|
|
1512
|
+
path: paths.raw,
|
|
1513
|
+
});
|
|
1514
|
+
// No explicit or selector-driven start: fall back to detecting where
|
|
1515
|
+
// the blank startup ends in the recorded pixels, and trim to there
|
|
1516
|
+
// when the lead-in is long enough to be worth removing. The second
|
|
1517
|
+
// `undefined` check guards the window across the ffmpeg await: if a
|
|
1518
|
+
// selector start landed meanwhile, it wins.
|
|
1519
|
+
//
|
|
1520
|
+
// This start is in the raw recording's timebase (t=0 = context
|
|
1521
|
+
// creation), which is exactly what the ffmpeg trim wants. Highlights
|
|
1522
|
+
// and dead-air are in videoMode time (from `startedAt` at plugin
|
|
1523
|
+
// construction); the two origins differ only by the fixture-setup gap
|
|
1524
|
+
// between context creation and construction — sub-frame in practice and
|
|
1525
|
+
// independent of how long the app takes to paint — so annotations stay
|
|
1526
|
+
// aligned with the trimmed video. (`setStartTime` instead shares the
|
|
1527
|
+
// videoMode timebase, so its trim and annotations shift together.)
|
|
1528
|
+
if (trimStart.detectBlank && state.sourceRange.start === undefined) {
|
|
1529
|
+
const detectedStart = await detectBlankLeadInEndMs(paths.raw);
|
|
1530
|
+
if (detectedStart !== undefined &&
|
|
1531
|
+
detectedStart >= TRIM_START_MIN_LEAD_IN_MS &&
|
|
1532
|
+
state.sourceRange.start === undefined) {
|
|
1533
|
+
state.sourceRange.start = detectedStart;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
// Read fresh, so an explicit, selector, or pixel-detected start all show.
|
|
1537
|
+
const sourceRange = metadataFor(state).sourceRange;
|
|
1538
|
+
if (highlights.length > 0 ||
|
|
1539
|
+
deadAirThreshold !== undefined ||
|
|
1540
|
+
finalHold > 0 ||
|
|
1541
|
+
sourceRangeIsSet(sourceRange)) {
|
|
1542
|
+
const wroteRenderedVideo = await renderVideo({
|
|
1543
|
+
deadAir,
|
|
1544
|
+
finalHoldMs: finalHold,
|
|
1545
|
+
highlightMode: highlight.mode === "pointer" ? "pointer" : "outline",
|
|
1546
|
+
highlights,
|
|
1547
|
+
inputPath: paths.raw,
|
|
1548
|
+
outputDir: testInfo.outputDir,
|
|
1549
|
+
outputPath: paths.rendered,
|
|
1550
|
+
sourceRange,
|
|
1551
|
+
thresholdMs: deadAirThreshold,
|
|
1552
|
+
});
|
|
1553
|
+
if (wroteRenderedVideo) {
|
|
1554
|
+
state.outputs.rendered = VIDEO_MODE_RENDERED_FILE;
|
|
1555
|
+
await testInfo.attach("video-rendered", {
|
|
1556
|
+
contentType: "video/webm",
|
|
1557
|
+
path: paths.rendered,
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
state.outputs.player = VIDEO_MODE_PLAYER_FILE;
|
|
1562
|
+
await writeFile(paths.player, videoModePlayerHtml({
|
|
1563
|
+
raw: state.outputs.raw,
|
|
1564
|
+
rendered: state.outputs.rendered,
|
|
1565
|
+
}));
|
|
1566
|
+
const reportPlayerHtml = videoModePlayerHtml({
|
|
1567
|
+
raw: await playwrightReportAttachmentName(paths.raw),
|
|
1568
|
+
rendered: state.outputs.rendered
|
|
1569
|
+
? await playwrightReportAttachmentName(paths.rendered)
|
|
1570
|
+
: undefined,
|
|
1571
|
+
});
|
|
1572
|
+
await writeFile(paths.reportPlayer, reportPlayerHtml);
|
|
1573
|
+
await testInfo.attach("video-mode-player", {
|
|
1574
|
+
contentType: "text/html",
|
|
1575
|
+
path: paths.reportPlayer,
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
const metadata = metadataFor(state);
|
|
1579
|
+
if (metadata.deadAir.length > 0 ||
|
|
1580
|
+
metadata.highlights.length > 0 ||
|
|
1581
|
+
metadata.outputs.player ||
|
|
1582
|
+
metadata.outputs.raw ||
|
|
1583
|
+
metadata.outputs.rendered ||
|
|
1584
|
+
sourceRangeIsSet(metadata.sourceRange)) {
|
|
1585
|
+
const path = videoModeOutputPaths(testInfo).metadata;
|
|
1586
|
+
await mkdir(testInfo.outputDir, { recursive: true });
|
|
1587
|
+
await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`);
|
|
1588
|
+
await testInfo.attach("video-mode", {
|
|
1589
|
+
contentType: "application/json",
|
|
1590
|
+
path,
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
state.startedAt = undefined;
|
|
1594
|
+
console.log(`video-mode metadata written to ${videoModeOutputPaths(testInfo).metadata}`);
|
|
121
1595
|
});
|
|
1596
|
+
return () => {
|
|
1597
|
+
offBeforeTest();
|
|
1598
|
+
offAfterTestFinalize();
|
|
1599
|
+
};
|
|
122
1600
|
},
|
|
123
1601
|
};
|
|
124
1602
|
};
|