@hyperframes/engine 0.6.4 → 0.6.6
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/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/services/frameCapture.d.ts +83 -0
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +130 -27
- package/dist/services/frameCapture.js.map +1 -1
- package/dist/services/parallelCoordinator.d.ts.map +1 -1
- package/dist/services/parallelCoordinator.js +18 -3
- package/dist/services/parallelCoordinator.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/assertSwiftShader.d.ts +59 -0
- package/dist/utils/assertSwiftShader.d.ts.map +1 -0
- package/dist/utils/assertSwiftShader.js +106 -0
- package/dist/utils/assertSwiftShader.js.map +1 -0
- package/package.json +5 -3
- package/src/index.ts +15 -1
- package/src/services/frameCapture-discardWarmup.test.ts +183 -0
- package/src/services/frameCapture-warmupTicks.test.ts +174 -0
- package/src/services/frameCapture.ts +184 -24
- package/src/services/parallelCoordinator.ts +18 -3
- package/src/types.ts +14 -0
- package/src/utils/assertSwiftShader.test.ts +130 -0
- package/src/utils/assertSwiftShader.ts +126 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assertSwiftShader — verify Chrome's WebGL is rendered by SwiftShader.
|
|
3
|
+
*
|
|
4
|
+
* Distributed renders pixel-lock on the GPU backend: hardware GL is bitwise
|
|
5
|
+
* unstable across worker machines (different drivers, driver versions, GL
|
|
6
|
+
* extension sets, even differing fp32 rounding on the same vendor). Chunk
|
|
7
|
+
* workers launch Chrome with `--use-gl=swiftshader --use-angle=swiftshader`
|
|
8
|
+
* so every worker uses the same pure-software GL implementation.
|
|
9
|
+
*
|
|
10
|
+
* Those Chrome flags are advisory: a misconfigured base image, a missing
|
|
11
|
+
* SwiftShader library, or a `chrome://gpu` blocklist override can silently
|
|
12
|
+
* downgrade to system GL. The distributed pipeline cannot detect the
|
|
13
|
+
* downgrade by sampling pixels (one machine = one render), so we read
|
|
14
|
+
* `chrome://gpu` directly after launch and refuse to render if the active
|
|
15
|
+
* GL renderer is anything other than SwiftShader.
|
|
16
|
+
*/
|
|
17
|
+
import type { Page } from "puppeteer-core";
|
|
18
|
+
/**
|
|
19
|
+
* Error code classifying this failure as non-retryable for distributed
|
|
20
|
+
* workflow adapters — a downgraded GPU on a worker will not heal on retry.
|
|
21
|
+
*/
|
|
22
|
+
export declare const BROWSER_GPU_NOT_SOFTWARE = "BROWSER_GPU_NOT_SOFTWARE";
|
|
23
|
+
/**
|
|
24
|
+
* Error thrown when chrome://gpu reports a non-SwiftShader WebGL backend.
|
|
25
|
+
*
|
|
26
|
+
* Carries a `code` property so the adapter can match on it without parsing
|
|
27
|
+
* the message string — Temporal/Step Functions retry policies key off the
|
|
28
|
+
* code, not the message.
|
|
29
|
+
*/
|
|
30
|
+
export declare class SwiftShaderAssertionError extends Error {
|
|
31
|
+
readonly code: typeof BROWSER_GPU_NOT_SOFTWARE;
|
|
32
|
+
readonly vendor: string;
|
|
33
|
+
readonly renderer: string;
|
|
34
|
+
constructor(message: string, vendor: string, renderer: string);
|
|
35
|
+
}
|
|
36
|
+
interface WebGlInfo {
|
|
37
|
+
vendor: string;
|
|
38
|
+
renderer: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Read the WebGL vendor/renderer strings from a live `chrome://gpu` page.
|
|
42
|
+
*
|
|
43
|
+
* Extracted from `assertSwiftShader` so tests can stub the navigation +
|
|
44
|
+
* extraction step. Returns the raw values; callers decide how to interpret
|
|
45
|
+
* them. Both fields are best-effort — Chrome returns empty strings if the
|
|
46
|
+
* GPU info table hasn't populated yet, which the caller treats as failure.
|
|
47
|
+
*/
|
|
48
|
+
export declare function readWebGlVendorInfo(page: Page): Promise<WebGlInfo>;
|
|
49
|
+
/**
|
|
50
|
+
* Validate that the active WebGL renderer is SwiftShader. Throws
|
|
51
|
+
* `SwiftShaderAssertionError` otherwise.
|
|
52
|
+
*
|
|
53
|
+
* Pass an optional `readInfo` override for tests that don't have a real
|
|
54
|
+
* Puppeteer `Page`. The default implementation navigates to `chrome://gpu`
|
|
55
|
+
* and parses the GL_VENDOR / GL_RENDERER rows.
|
|
56
|
+
*/
|
|
57
|
+
export declare function assertSwiftShader(page: Page, readInfo?: (page: Page) => Promise<WebGlInfo>): Promise<void>;
|
|
58
|
+
export {};
|
|
59
|
+
//# sourceMappingURL=assertSwiftShader.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assertSwiftShader.d.ts","sourceRoot":"","sources":["../../src/utils/assertSwiftShader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAE3C;;;GAGG;AACH,eAAO,MAAM,wBAAwB,6BAA6B,CAAC;AAEnE;;;;;;GAMG;AACH,qBAAa,yBAA0B,SAAQ,KAAK;IAClD,QAAQ,CAAC,IAAI,EAAE,OAAO,wBAAwB,CAA4B;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;gBAEd,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM;CAM9D;AAiBD,UAAU,SAAS;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,SAAS,CAAC,CAqBxE;AAED;;;;;;;GAOG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,IAAI,EACV,QAAQ,GAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,SAAS,CAAuB,GACjE,OAAO,CAAC,IAAI,CAAC,CAkBf"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assertSwiftShader — verify Chrome's WebGL is rendered by SwiftShader.
|
|
3
|
+
*
|
|
4
|
+
* Distributed renders pixel-lock on the GPU backend: hardware GL is bitwise
|
|
5
|
+
* unstable across worker machines (different drivers, driver versions, GL
|
|
6
|
+
* extension sets, even differing fp32 rounding on the same vendor). Chunk
|
|
7
|
+
* workers launch Chrome with `--use-gl=swiftshader --use-angle=swiftshader`
|
|
8
|
+
* so every worker uses the same pure-software GL implementation.
|
|
9
|
+
*
|
|
10
|
+
* Those Chrome flags are advisory: a misconfigured base image, a missing
|
|
11
|
+
* SwiftShader library, or a `chrome://gpu` blocklist override can silently
|
|
12
|
+
* downgrade to system GL. The distributed pipeline cannot detect the
|
|
13
|
+
* downgrade by sampling pixels (one machine = one render), so we read
|
|
14
|
+
* `chrome://gpu` directly after launch and refuse to render if the active
|
|
15
|
+
* GL renderer is anything other than SwiftShader.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Error code classifying this failure as non-retryable for distributed
|
|
19
|
+
* workflow adapters — a downgraded GPU on a worker will not heal on retry.
|
|
20
|
+
*/
|
|
21
|
+
export const BROWSER_GPU_NOT_SOFTWARE = "BROWSER_GPU_NOT_SOFTWARE";
|
|
22
|
+
/**
|
|
23
|
+
* Error thrown when chrome://gpu reports a non-SwiftShader WebGL backend.
|
|
24
|
+
*
|
|
25
|
+
* Carries a `code` property so the adapter can match on it without parsing
|
|
26
|
+
* the message string — Temporal/Step Functions retry policies key off the
|
|
27
|
+
* code, not the message.
|
|
28
|
+
*/
|
|
29
|
+
export class SwiftShaderAssertionError extends Error {
|
|
30
|
+
code = BROWSER_GPU_NOT_SOFTWARE;
|
|
31
|
+
vendor;
|
|
32
|
+
renderer;
|
|
33
|
+
constructor(message, vendor, renderer) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "SwiftShaderAssertionError";
|
|
36
|
+
this.vendor = vendor;
|
|
37
|
+
this.renderer = renderer;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* SwiftShader identifies itself on `chrome://gpu` and in
|
|
42
|
+
* `WEBGL_debug_renderer_info` with this exact vendor string. Locking to
|
|
43
|
+
* Google's own GL string (rather than a substring match on "swiftshader")
|
|
44
|
+
* avoids false-positives from third-party ANGLE backends that incidentally
|
|
45
|
+
* mention SwiftShader in unrelated diagnostic text.
|
|
46
|
+
*/
|
|
47
|
+
const SWIFTSHADER_VENDOR_SIGNATURE = "Google Inc. (Google)";
|
|
48
|
+
/**
|
|
49
|
+
* Renderer string contains the literal "SwiftShader" token. We match
|
|
50
|
+
* case-insensitively and only require the substring; Chrome occasionally
|
|
51
|
+
* appends a build suffix (e.g. " Vulkan 1.3").
|
|
52
|
+
*/
|
|
53
|
+
const SWIFTSHADER_RENDERER_TOKEN = "swiftshader";
|
|
54
|
+
/**
|
|
55
|
+
* Read the WebGL vendor/renderer strings from a live `chrome://gpu` page.
|
|
56
|
+
*
|
|
57
|
+
* Extracted from `assertSwiftShader` so tests can stub the navigation +
|
|
58
|
+
* extraction step. Returns the raw values; callers decide how to interpret
|
|
59
|
+
* them. Both fields are best-effort — Chrome returns empty strings if the
|
|
60
|
+
* GPU info table hasn't populated yet, which the caller treats as failure.
|
|
61
|
+
*/
|
|
62
|
+
export async function readWebGlVendorInfo(page) {
|
|
63
|
+
await page.goto("chrome://gpu", { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
64
|
+
// The "GL_VENDOR" / "GL_RENDERER" rows live inside <info-view> shadow DOM
|
|
65
|
+
// in modern Chrome. We pull the structured `info_log_` payload off the
|
|
66
|
+
// page-level globals instead of querying the DOM, since the DOM layout has
|
|
67
|
+
// drifted across versions.
|
|
68
|
+
const info = await page.evaluate(() => {
|
|
69
|
+
const w = window;
|
|
70
|
+
const rows = w.browserBridge?.gpuInfo_?.graphics_info?.basic_info ?? [];
|
|
71
|
+
let vendor = "";
|
|
72
|
+
let renderer = "";
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
if (typeof row.description !== "string" || typeof row.value !== "string")
|
|
75
|
+
continue;
|
|
76
|
+
if (row.description === "GL_VENDOR")
|
|
77
|
+
vendor = row.value;
|
|
78
|
+
else if (row.description === "GL_RENDERER")
|
|
79
|
+
renderer = row.value;
|
|
80
|
+
}
|
|
81
|
+
return { vendor, renderer };
|
|
82
|
+
});
|
|
83
|
+
return info;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Validate that the active WebGL renderer is SwiftShader. Throws
|
|
87
|
+
* `SwiftShaderAssertionError` otherwise.
|
|
88
|
+
*
|
|
89
|
+
* Pass an optional `readInfo` override for tests that don't have a real
|
|
90
|
+
* Puppeteer `Page`. The default implementation navigates to `chrome://gpu`
|
|
91
|
+
* and parses the GL_VENDOR / GL_RENDERER rows.
|
|
92
|
+
*/
|
|
93
|
+
export async function assertSwiftShader(page, readInfo = readWebGlVendorInfo) {
|
|
94
|
+
const { vendor, renderer } = await readInfo(page);
|
|
95
|
+
const vendorMatches = vendor.trim() === SWIFTSHADER_VENDOR_SIGNATURE;
|
|
96
|
+
const rendererMatches = renderer.toLowerCase().includes(SWIFTSHADER_RENDERER_TOKEN);
|
|
97
|
+
if (vendorMatches && rendererMatches)
|
|
98
|
+
return;
|
|
99
|
+
throw new SwiftShaderAssertionError(`[assertSwiftShader] Chrome reported a non-SwiftShader WebGL backend. ` +
|
|
100
|
+
`Distributed renders require pure-software GL for pixel-identical retries. ` +
|
|
101
|
+
`Got vendor=${JSON.stringify(vendor)} renderer=${JSON.stringify(renderer)}; ` +
|
|
102
|
+
`expected vendor=${JSON.stringify(SWIFTSHADER_VENDOR_SIGNATURE)} renderer to contain "SwiftShader". ` +
|
|
103
|
+
`Ensure Chrome was launched with --use-gl=swiftshader --use-angle=swiftshader and that the ` +
|
|
104
|
+
`SwiftShader libraries are present in the runtime image.`, vendor, renderer);
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=assertSwiftShader.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assertSwiftShader.js","sourceRoot":"","sources":["../../src/utils/assertSwiftShader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH;;;GAGG;AACH,MAAM,CAAC,MAAM,wBAAwB,GAAG,0BAA0B,CAAC;AAEnE;;;;;;GAMG;AACH,MAAM,OAAO,yBAA0B,SAAQ,KAAK;IACzC,IAAI,GAAoC,wBAAwB,CAAC;IACjE,MAAM,CAAS;IACf,QAAQ,CAAS;IAE1B,YAAY,OAAe,EAAE,MAAc,EAAE,QAAgB;QAC3D,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,2BAA2B,CAAC;QACxC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;IAC3B,CAAC;CACF;AAED;;;;;;GAMG;AACH,MAAM,4BAA4B,GAAG,sBAAsB,CAAC;AAC5D;;;;GAIG;AACH,MAAM,0BAA0B,GAAG,aAAa,CAAC;AAOjD;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CAAC,IAAU;IAClD,MAAM,IAAI,CAAC,IAAI,CAAC,cAAc,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;IACpF,0EAA0E;IAC1E,uEAAuE;IACvE,2EAA2E;IAC3E,2BAA2B;IAC3B,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,GAAc,EAAE;QAG/C,MAAM,CAAC,GAAG,MAA+D,CAAC;QAC1E,MAAM,IAAI,GAAU,CAAC,CAAC,aAAa,EAAE,QAAQ,EAAE,aAAa,EAAE,UAAU,IAAI,EAAE,CAAC;QAC/E,IAAI,MAAM,GAAG,EAAE,CAAC;QAChB,IAAI,QAAQ,GAAG,EAAE,CAAC;QAClB,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,IAAI,OAAO,GAAG,CAAC,WAAW,KAAK,QAAQ,IAAI,OAAO,GAAG,CAAC,KAAK,KAAK,QAAQ;gBAAE,SAAS;YACnF,IAAI,GAAG,CAAC,WAAW,KAAK,WAAW;gBAAE,MAAM,GAAG,GAAG,CAAC,KAAK,CAAC;iBACnD,IAAI,GAAG,CAAC,WAAW,KAAK,aAAa;gBAAE,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC;QACnE,CAAC;QACD,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;IAC9B,CAAC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,IAAU,EACV,WAA+C,mBAAmB;IAElE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAC;IAElD,MAAM,aAAa,GAAG,MAAM,CAAC,IAAI,EAAE,KAAK,4BAA4B,CAAC;IACrE,MAAM,eAAe,GAAG,QAAQ,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,0BAA0B,CAAC,CAAC;IAEpF,IAAI,aAAa,IAAI,eAAe;QAAE,OAAO;IAE7C,MAAM,IAAI,yBAAyB,CACjC,uEAAuE;QACrE,4EAA4E;QAC5E,cAAc,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,aAAa,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI;QAC7E,mBAAmB,IAAI,CAAC,SAAS,CAAC,4BAA4B,CAAC,sCAAsC;QACrG,4FAA4F;QAC5F,yDAAyD,EAC3D,MAAM,EACN,QAAQ,CACT,CAAC;AACJ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"main": "./src/index.ts",
|
|
12
12
|
"types": "./src/index.ts",
|
|
13
13
|
"exports": {
|
|
14
|
-
".": "./src/index.ts"
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./alpha-blit": "./src/utils/alphaBlit.ts",
|
|
16
|
+
"./shader-transitions": "./src/utils/shaderTransitions.ts"
|
|
15
17
|
},
|
|
16
18
|
"dependencies": {
|
|
17
19
|
"@hono/node-server": "^1.13.0",
|
|
@@ -19,7 +21,7 @@
|
|
|
19
21
|
"linkedom": "^0.18.12",
|
|
20
22
|
"puppeteer": "^24.0.0",
|
|
21
23
|
"puppeteer-core": "^24.39.1",
|
|
22
|
-
"@hyperframes/core": "^0.6.
|
|
24
|
+
"@hyperframes/core": "^0.6.6"
|
|
23
25
|
},
|
|
24
26
|
"devDependencies": {
|
|
25
27
|
"@types/node": "^25.0.10",
|
package/src/index.ts
CHANGED
|
@@ -65,11 +65,13 @@ export {
|
|
|
65
65
|
closeCaptureSession,
|
|
66
66
|
captureFrame,
|
|
67
67
|
captureFrameToBuffer,
|
|
68
|
+
discardWarmupCapture,
|
|
68
69
|
getCompositionDuration,
|
|
69
70
|
getCapturePerfSummary,
|
|
70
71
|
prepareCaptureSessionForReuse,
|
|
71
72
|
type CaptureSession,
|
|
72
73
|
type BeforeCaptureHook,
|
|
74
|
+
type DiscardWarmupInnerCapture,
|
|
73
75
|
} from "./services/frameCapture.js";
|
|
74
76
|
|
|
75
77
|
// ── Screenshot (BeginFrame) ─────────────────────────────────────────────────────
|
|
@@ -155,6 +157,13 @@ export {
|
|
|
155
157
|
// ── Utilities ──────────────────────────────────────────────────────────────────
|
|
156
158
|
export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
|
|
157
159
|
|
|
160
|
+
export {
|
|
161
|
+
assertSwiftShader,
|
|
162
|
+
readWebGlVendorInfo,
|
|
163
|
+
SwiftShaderAssertionError,
|
|
164
|
+
BROWSER_GPU_NOT_SOFTWARE,
|
|
165
|
+
} from "./utils/assertSwiftShader.js";
|
|
166
|
+
|
|
158
167
|
export {
|
|
159
168
|
extractMediaMetadata,
|
|
160
169
|
extractVideoMetadata,
|
|
@@ -166,7 +175,12 @@ export {
|
|
|
166
175
|
} from "./utils/ffprobe.js";
|
|
167
176
|
|
|
168
177
|
export { downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
|
|
169
|
-
export {
|
|
178
|
+
export {
|
|
179
|
+
runFfmpeg,
|
|
180
|
+
formatFfmpegError,
|
|
181
|
+
type RunFfmpegOptions,
|
|
182
|
+
type RunFfmpegResult,
|
|
183
|
+
} from "./utils/runFfmpeg.js";
|
|
170
184
|
|
|
171
185
|
export {
|
|
172
186
|
decodePng,
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `discardWarmupCapture` — the helper distributed chunk workers
|
|
3
|
+
* run before their first real capture to prime `lastFrameCache`.
|
|
4
|
+
*
|
|
5
|
+
* The helper is a thin wrapper around the inner `captureFrameCore`
|
|
6
|
+
* machinery, so its testable contract is post-conditional rather than
|
|
7
|
+
* pixel-level:
|
|
8
|
+
*
|
|
9
|
+
* 1. The wrapper invokes the inner capture exactly once with the supplied
|
|
10
|
+
* `(frameIndex, time)`.
|
|
11
|
+
* 2. After the wrapper returns, the session's perf and BeginFrame damage
|
|
12
|
+
* counters look exactly as they did before — even though the inner
|
|
13
|
+
* capture mutated them.
|
|
14
|
+
* 3. The wrapper writes no file to disk (no path is plumbed through;
|
|
15
|
+
* asserted indirectly by observing that `outputDir` is never read).
|
|
16
|
+
* 4. State is restored even if the inner capture throws.
|
|
17
|
+
*
|
|
18
|
+
* The inner capture is stubbed (the helper accepts an injectable
|
|
19
|
+
* `innerCapture` for exactly this reason). We don't need a real Chrome.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, expect, it } from "vitest";
|
|
23
|
+
import { existsSync, readdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { discardWarmupCapture, type CaptureSession } from "./frameCapture.js";
|
|
27
|
+
|
|
28
|
+
function makeFakeSession(): CaptureSession {
|
|
29
|
+
// The discardWarmupCapture wrapper only reads `capturePerf`,
|
|
30
|
+
// `beginFrameHasDamageCount`, `beginFrameNoDamageCount`. Everything else
|
|
31
|
+
// is unused — leave it as bare-minimum stubs cast through `unknown`.
|
|
32
|
+
return {
|
|
33
|
+
browser: {} as unknown,
|
|
34
|
+
page: {} as unknown,
|
|
35
|
+
options: { width: 1, height: 1, fps: { num: 30, den: 1 } },
|
|
36
|
+
serverUrl: "",
|
|
37
|
+
outputDir: mkdtempSync(join(tmpdir(), "discard-warmup-")),
|
|
38
|
+
onBeforeCapture: null,
|
|
39
|
+
isInitialized: true,
|
|
40
|
+
browserConsoleBuffer: [],
|
|
41
|
+
capturePerf: {
|
|
42
|
+
frames: 7,
|
|
43
|
+
seekMs: 100,
|
|
44
|
+
beforeCaptureMs: 50,
|
|
45
|
+
screenshotMs: 200,
|
|
46
|
+
totalMs: 350,
|
|
47
|
+
},
|
|
48
|
+
captureMode: "beginframe",
|
|
49
|
+
beginFrameTimeTicks: 0,
|
|
50
|
+
beginFrameIntervalMs: 33,
|
|
51
|
+
beginFrameHasDamageCount: 4,
|
|
52
|
+
beginFrameNoDamageCount: 3,
|
|
53
|
+
} as unknown as CaptureSession;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("discardWarmupCapture", () => {
|
|
57
|
+
it("calls the inner capture exactly once with (frameIndex=0, time=0) by default", async () => {
|
|
58
|
+
const session = makeFakeSession();
|
|
59
|
+
try {
|
|
60
|
+
let calls = 0;
|
|
61
|
+
let receivedFrameIndex = -1;
|
|
62
|
+
let receivedTime = -1;
|
|
63
|
+
await discardWarmupCapture(session, undefined, undefined, async (_s, fi, t) => {
|
|
64
|
+
calls++;
|
|
65
|
+
receivedFrameIndex = fi;
|
|
66
|
+
receivedTime = t;
|
|
67
|
+
return { buffer: Buffer.alloc(0), quantizedTime: t, captureTimeMs: 0 };
|
|
68
|
+
});
|
|
69
|
+
expect(calls).toBe(1);
|
|
70
|
+
expect(receivedFrameIndex).toBe(0);
|
|
71
|
+
expect(receivedTime).toBe(0);
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(session.outputDir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("passes through caller-supplied (frameIndex, time)", async () => {
|
|
78
|
+
const session = makeFakeSession();
|
|
79
|
+
try {
|
|
80
|
+
let received: { fi: number; t: number } | null = null;
|
|
81
|
+
await discardWarmupCapture(session, 240, 8, async (_s, fi, t) => {
|
|
82
|
+
received = { fi, t };
|
|
83
|
+
return { buffer: Buffer.alloc(0), quantizedTime: t, captureTimeMs: 0 };
|
|
84
|
+
});
|
|
85
|
+
expect(received).toEqual({ fi: 240, t: 8 });
|
|
86
|
+
} finally {
|
|
87
|
+
rmSync(session.outputDir, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("restores perf counters after the inner capture mutates them", async () => {
|
|
92
|
+
const session = makeFakeSession();
|
|
93
|
+
const before = { ...session.capturePerf };
|
|
94
|
+
try {
|
|
95
|
+
await discardWarmupCapture(session, 0, 0, async (s) => {
|
|
96
|
+
s.capturePerf.frames += 1;
|
|
97
|
+
s.capturePerf.seekMs += 12;
|
|
98
|
+
s.capturePerf.beforeCaptureMs += 5;
|
|
99
|
+
s.capturePerf.screenshotMs += 33;
|
|
100
|
+
s.capturePerf.totalMs += 50;
|
|
101
|
+
return { buffer: Buffer.alloc(0), quantizedTime: 0, captureTimeMs: 50 };
|
|
102
|
+
});
|
|
103
|
+
expect(session.capturePerf).toEqual(before);
|
|
104
|
+
} finally {
|
|
105
|
+
rmSync(session.outputDir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("restores BeginFrame damage counters after the inner capture mutates them", async () => {
|
|
110
|
+
const session = makeFakeSession();
|
|
111
|
+
const hasBefore = session.beginFrameHasDamageCount;
|
|
112
|
+
const noBefore = session.beginFrameNoDamageCount;
|
|
113
|
+
try {
|
|
114
|
+
await discardWarmupCapture(session, 0, 0, async (s) => {
|
|
115
|
+
s.beginFrameHasDamageCount += 10;
|
|
116
|
+
s.beginFrameNoDamageCount += 1;
|
|
117
|
+
return { buffer: Buffer.alloc(0), quantizedTime: 0, captureTimeMs: 0 };
|
|
118
|
+
});
|
|
119
|
+
expect(session.beginFrameHasDamageCount).toBe(hasBefore);
|
|
120
|
+
expect(session.beginFrameNoDamageCount).toBe(noBefore);
|
|
121
|
+
} finally {
|
|
122
|
+
rmSync(session.outputDir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("restores state even when the inner capture throws", async () => {
|
|
127
|
+
const session = makeFakeSession();
|
|
128
|
+
const perfBefore = { ...session.capturePerf };
|
|
129
|
+
const hasBefore = session.beginFrameHasDamageCount;
|
|
130
|
+
const noBefore = session.beginFrameNoDamageCount;
|
|
131
|
+
try {
|
|
132
|
+
let thrown: unknown;
|
|
133
|
+
try {
|
|
134
|
+
await discardWarmupCapture(session, 0, 0, async (s) => {
|
|
135
|
+
s.capturePerf.frames += 5;
|
|
136
|
+
s.beginFrameNoDamageCount += 2;
|
|
137
|
+
throw new Error("simulated capture failure");
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
thrown = err;
|
|
141
|
+
}
|
|
142
|
+
expect((thrown as Error).message).toBe("simulated capture failure");
|
|
143
|
+
// The whole point of `finally { restore }`: failure must not leak
|
|
144
|
+
// inflated counters into the real capture summary.
|
|
145
|
+
expect(session.capturePerf).toEqual(perfBefore);
|
|
146
|
+
expect(session.beginFrameHasDamageCount).toBe(hasBefore);
|
|
147
|
+
expect(session.beginFrameNoDamageCount).toBe(noBefore);
|
|
148
|
+
} finally {
|
|
149
|
+
rmSync(session.outputDir, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("writes no output file to the session's outputDir", async () => {
|
|
154
|
+
const session = makeFakeSession();
|
|
155
|
+
try {
|
|
156
|
+
expect(existsSync(session.outputDir)).toBe(true);
|
|
157
|
+
const before = readdirSync(session.outputDir);
|
|
158
|
+
await discardWarmupCapture(session, 0, 0, async () => ({
|
|
159
|
+
buffer: Buffer.from([0xff, 0xff, 0xff]),
|
|
160
|
+
quantizedTime: 0,
|
|
161
|
+
captureTimeMs: 0,
|
|
162
|
+
}));
|
|
163
|
+
const after = readdirSync(session.outputDir);
|
|
164
|
+
expect(after).toEqual(before);
|
|
165
|
+
} finally {
|
|
166
|
+
rmSync(session.outputDir, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("returns undefined (no result type, so the buffer can't escape)", async () => {
|
|
171
|
+
const session = makeFakeSession();
|
|
172
|
+
try {
|
|
173
|
+
const result = await discardWarmupCapture(session, 0, 0, async () => ({
|
|
174
|
+
buffer: Buffer.from([0x01]),
|
|
175
|
+
quantizedTime: 0,
|
|
176
|
+
captureTimeMs: 1,
|
|
177
|
+
}));
|
|
178
|
+
expect(result).toBeUndefined();
|
|
179
|
+
} finally {
|
|
180
|
+
rmSync(session.outputDir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `driveWarmupTicks` BeginFrame warmup driver.
|
|
3
|
+
*
|
|
4
|
+
* The wall-clock warmup loop in `initializeSession` accumulates a different
|
|
5
|
+
* number of ticks per host CPU speed, which shifts `beginFrameTimeTicks`
|
|
6
|
+
* and breaks byte-identical captures across distributed workers.
|
|
7
|
+
*
|
|
8
|
+
* `lockWarmupTicks=true` clamps the loop to a fixed iteration count
|
|
9
|
+
* (`LOCKED_WARMUP_TICKS = 60`) so the baseline becomes host-independent.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
LOCKED_WARMUP_TICKS,
|
|
15
|
+
driveWarmupTicks,
|
|
16
|
+
warmupFrameTimeTicks,
|
|
17
|
+
type WarmupTickState,
|
|
18
|
+
} from "./frameCapture.js";
|
|
19
|
+
|
|
20
|
+
function makeState(): WarmupTickState {
|
|
21
|
+
return { running: true, ticks: 0 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Run the warmup driver "concurrently" with a simulated page-load that
|
|
25
|
+
// flips `state.running` to false after `pageLoadIterations` of the
|
|
26
|
+
// `tick` callback. Returns the final state.
|
|
27
|
+
async function runWithSimulatedPageLoad(
|
|
28
|
+
lockWarmupTicks: boolean,
|
|
29
|
+
pageLoadIterations: number,
|
|
30
|
+
tickDelay: () => Promise<void> = () => Promise.resolve(),
|
|
31
|
+
): Promise<WarmupTickState> {
|
|
32
|
+
const state = makeState();
|
|
33
|
+
const intervalMs = 33;
|
|
34
|
+
|
|
35
|
+
const tick = async (): Promise<void> => {
|
|
36
|
+
if (state.ticks + 1 === pageLoadIterations) {
|
|
37
|
+
state.running = false;
|
|
38
|
+
}
|
|
39
|
+
await tickDelay();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
await driveWarmupTicks(
|
|
43
|
+
{
|
|
44
|
+
intervalMs,
|
|
45
|
+
lockWarmupTicks,
|
|
46
|
+
tick,
|
|
47
|
+
sleep: () => Promise.resolve(),
|
|
48
|
+
},
|
|
49
|
+
state,
|
|
50
|
+
);
|
|
51
|
+
return state;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("driveWarmupTicks — unlocked", () => {
|
|
55
|
+
it("stops when state.running flips false", async () => {
|
|
56
|
+
const state = await runWithSimulatedPageLoad(false, 10);
|
|
57
|
+
expect(state.ticks).toBe(10);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("yields different tick counts for different simulated page-load lengths", async () => {
|
|
61
|
+
const fast = await runWithSimulatedPageLoad(false, 5);
|
|
62
|
+
const slow = await runWithSimulatedPageLoad(false, 50);
|
|
63
|
+
expect(fast.ticks).toBe(5);
|
|
64
|
+
expect(slow.ticks).toBe(50);
|
|
65
|
+
expect(fast.ticks).not.toBe(slow.ticks);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("frame time derived as ticks * intervalMs", async () => {
|
|
69
|
+
const state = await runWithSimulatedPageLoad(false, 7);
|
|
70
|
+
expect(warmupFrameTimeTicks(state, 33)).toBe(7 * 33);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not iterate when state.running starts false", async () => {
|
|
74
|
+
const state: WarmupTickState = { running: false, ticks: 0 };
|
|
75
|
+
await driveWarmupTicks(
|
|
76
|
+
{
|
|
77
|
+
intervalMs: 33,
|
|
78
|
+
lockWarmupTicks: false,
|
|
79
|
+
tick: async () => {},
|
|
80
|
+
sleep: () => Promise.resolve(),
|
|
81
|
+
},
|
|
82
|
+
state,
|
|
83
|
+
);
|
|
84
|
+
expect(state.ticks).toBe(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("counts ticks even when tick callback throws (page not ready yet)", async () => {
|
|
88
|
+
// Pre-acquisition errors are swallowed — the loop must keep spinning so
|
|
89
|
+
// it can drive the page once CDP comes up.
|
|
90
|
+
let stopAt = 5;
|
|
91
|
+
const state = makeState();
|
|
92
|
+
await driveWarmupTicks(
|
|
93
|
+
{
|
|
94
|
+
intervalMs: 33,
|
|
95
|
+
lockWarmupTicks: false,
|
|
96
|
+
tick: async () => {
|
|
97
|
+
if (--stopAt <= 0) state.running = false;
|
|
98
|
+
throw new Error("CDP not ready");
|
|
99
|
+
},
|
|
100
|
+
sleep: () => Promise.resolve(),
|
|
101
|
+
},
|
|
102
|
+
state,
|
|
103
|
+
);
|
|
104
|
+
// We don't count ticks on throw, but the loop kept running until
|
|
105
|
+
// state.running flipped — so it exited cleanly.
|
|
106
|
+
expect(state.running).toBe(false);
|
|
107
|
+
expect(state.ticks).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("driveWarmupTicks — locked", () => {
|
|
112
|
+
it("runs exactly LOCKED_WARMUP_TICKS iterations regardless of state.running", async () => {
|
|
113
|
+
// Simulate a fast page load: state.running flips false after just 5 ticks.
|
|
114
|
+
// The locked loop must still run to LOCKED_WARMUP_TICKS.
|
|
115
|
+
const state = await runWithSimulatedPageLoad(true, 5);
|
|
116
|
+
expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("runs exactly LOCKED_WARMUP_TICKS iterations on slow simulated load", async () => {
|
|
120
|
+
// state.running stays true past the locked count — loop still stops at
|
|
121
|
+
// LOCKED_WARMUP_TICKS.
|
|
122
|
+
const state = await runWithSimulatedPageLoad(true, 9999);
|
|
123
|
+
expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("yields IDENTICAL tick counts across simulated fast and slow loads", async () => {
|
|
127
|
+
// THE determinism property the locked mode exists for.
|
|
128
|
+
const fast = await runWithSimulatedPageLoad(true, 5);
|
|
129
|
+
const slow = await runWithSimulatedPageLoad(true, 200);
|
|
130
|
+
expect(fast.ticks).toBe(slow.ticks);
|
|
131
|
+
expect(fast.ticks).toBe(LOCKED_WARMUP_TICKS);
|
|
132
|
+
expect(warmupFrameTimeTicks(fast, 33)).toBe(warmupFrameTimeTicks(slow, 33));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("yields LOCKED_WARMUP_TICKS even when state.running starts false", async () => {
|
|
136
|
+
// Guards the locked contract independently of the caller's running flag.
|
|
137
|
+
const state: WarmupTickState = { running: false, ticks: 0 };
|
|
138
|
+
await driveWarmupTicks(
|
|
139
|
+
{
|
|
140
|
+
intervalMs: 33,
|
|
141
|
+
lockWarmupTicks: true,
|
|
142
|
+
tick: async () => {},
|
|
143
|
+
sleep: () => Promise.resolve(),
|
|
144
|
+
},
|
|
145
|
+
state,
|
|
146
|
+
);
|
|
147
|
+
expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("does not exceed LOCKED_WARMUP_TICKS even if the caller never stops it", async () => {
|
|
151
|
+
const state = makeState();
|
|
152
|
+
let observedMax = 0;
|
|
153
|
+
await driveWarmupTicks(
|
|
154
|
+
{
|
|
155
|
+
intervalMs: 33,
|
|
156
|
+
lockWarmupTicks: true,
|
|
157
|
+
tick: async () => {
|
|
158
|
+
observedMax = Math.max(observedMax, state.ticks + 1);
|
|
159
|
+
},
|
|
160
|
+
sleep: () => Promise.resolve(),
|
|
161
|
+
},
|
|
162
|
+
state,
|
|
163
|
+
);
|
|
164
|
+
expect(observedMax).toBe(LOCKED_WARMUP_TICKS);
|
|
165
|
+
expect(state.ticks).toBe(LOCKED_WARMUP_TICKS);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("baseline frame time matches (LOCKED_WARMUP_TICKS * intervalMs)", async () => {
|
|
169
|
+
// initializeSession derives session.beginFrameTimeTicks from the final
|
|
170
|
+
// tick count — locked mode pins this to a host-independent value.
|
|
171
|
+
const state = await runWithSimulatedPageLoad(true, 5);
|
|
172
|
+
expect(warmupFrameTimeTicks(state, 33)).toBe(LOCKED_WARMUP_TICKS * 33);
|
|
173
|
+
});
|
|
174
|
+
});
|