@hyperframes/engine 0.9.13 → 0.9.15
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.
|
@@ -207,4 +207,6 @@ export declare function produceDrawElementFrameBatch(page: Page, times: number[]
|
|
|
207
207
|
encodeResults: Array<Promise<Buffer>>;
|
|
208
208
|
failedAt: number | null;
|
|
209
209
|
error?: string;
|
|
210
|
+
/** Background urls whose decode timed out during this batch. */
|
|
211
|
+
lateBackgrounds: string[];
|
|
210
212
|
}>;
|
|
@@ -944,7 +944,9 @@ export async function produceDrawElementFrameBatch(page, times, width, height, q
|
|
|
944
944
|
if (aw.__hf && typeof aw.__hf.seek === "function")
|
|
945
945
|
aw.__hf.seek(t);
|
|
946
946
|
await aw.__hfWaitForSeekCompletion?.();
|
|
947
|
-
await aw.__hfDecodeDynamicCssBackgroundImages?.();
|
|
947
|
+
const late = await aw.__hfDecodeDynamicCssBackgroundImages?.();
|
|
948
|
+
if (late?.length)
|
|
949
|
+
(aw.__hf_css_background_late ??= []).push(...late);
|
|
948
950
|
aw.__hf3d?.update();
|
|
949
951
|
const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
|
|
950
952
|
for (const c of accel) {
|
|
@@ -1074,5 +1076,9 @@ export async function produceDrawElementFrameBatch(page, times, width, height, q
|
|
|
1074
1076
|
}
|
|
1075
1077
|
}
|
|
1076
1078
|
}
|
|
1077
|
-
|
|
1079
|
+
const lateBackgrounds = await page.evaluate(() => {
|
|
1080
|
+
const w = window;
|
|
1081
|
+
return w.__hf_css_background_late?.splice(0) ?? [];
|
|
1082
|
+
});
|
|
1083
|
+
return { encodeResults, failedAt: outcome.failedAt, error: outcome.error, lateBackgrounds };
|
|
1078
1084
|
}
|
|
@@ -274,8 +274,13 @@ export declare function isDrawElementVerificationError(err: unknown): boolean;
|
|
|
274
274
|
* undefined when the error isn't a verification failure at all.
|
|
275
275
|
*/
|
|
276
276
|
export declare function getDrawElementVerificationDetails(err: unknown): DrawElementVerificationDetails | undefined;
|
|
277
|
-
/**
|
|
278
|
-
export declare
|
|
277
|
+
/** Longest the capture waits for one inline background image to decode; it is then left out. */
|
|
278
|
+
export declare const BACKGROUND_DECODE_TIMEOUT_MS = 15000;
|
|
279
|
+
/**
|
|
280
|
+
* Wait for inline CSS background images introduced by the latest seek.
|
|
281
|
+
* Resolves with the urls that did not settle in time: they are captured as they stand and not waited on again.
|
|
282
|
+
*/
|
|
283
|
+
export declare function decodeDynamicCssBackgroundImages(page: Page): Promise<string[]>;
|
|
279
284
|
export declare function sanitizeDiagnosticUrl(input: string): string;
|
|
280
285
|
export declare function formatNavigationFailureDiagnostic(input: {
|
|
281
286
|
captureMode: CaptureMode;
|
|
@@ -450,6 +455,8 @@ export declare function pollImagesReady(page: Page, timeoutMs: number, intervalM
|
|
|
450
455
|
export declare function waitForImagesReady(page: Page, timeoutMs: number, onUnloaded: (sources: string) => void): Promise<boolean>;
|
|
451
456
|
/** @internal exported for contract testing. */
|
|
452
457
|
export declare function collectMediaReadinessWarnings(page: Page, skipIds: readonly string[], timeoutMs: number): Promise<CaptureWarning[]>;
|
|
458
|
+
/** Report background urls that timed out decoding; the shown url drops credentials and query. */
|
|
459
|
+
export declare function recordBackgroundDecodeTimeouts(session: CaptureSession, urls: readonly string[] | undefined): void;
|
|
453
460
|
export declare function recordSubTimelineWarning(session: CaptureSession, timeoutMs: number): void;
|
|
454
461
|
/**
|
|
455
462
|
* Build the `live_map_detected` warning for the detected map libraries.
|
|
@@ -89,12 +89,19 @@ export function getDrawElementVerificationDetails(err) {
|
|
|
89
89
|
}
|
|
90
90
|
return undefined;
|
|
91
91
|
}
|
|
92
|
-
/**
|
|
92
|
+
/** Longest the capture waits for one inline background image to decode; it is then left out. */
|
|
93
|
+
export const BACKGROUND_DECODE_TIMEOUT_MS = 15_000;
|
|
94
|
+
/**
|
|
95
|
+
* Wait for inline CSS background images introduced by the latest seek.
|
|
96
|
+
* Resolves with the urls that did not settle in time: they are captured as they stand and not waited on again.
|
|
97
|
+
*/
|
|
93
98
|
export async function decodeDynamicCssBackgroundImages(page) {
|
|
94
|
-
|
|
99
|
+
return page.evaluate(async (timeoutMs) => {
|
|
95
100
|
const root = globalThis;
|
|
96
101
|
const decode = (root.__hfDecodeDynamicCssBackgroundImages ??= async () => {
|
|
102
|
+
const limitMs = root.__hf_css_background_timeout_ms ?? 0;
|
|
97
103
|
const decoded = (root.__hf_css_background_decoded ??= new Set());
|
|
104
|
+
const timedOut = (root.__hf_css_background_timed_out ??= new Set());
|
|
98
105
|
const urls = [];
|
|
99
106
|
const parseBackgroundUrls = (value) => {
|
|
100
107
|
const found = [];
|
|
@@ -146,24 +153,42 @@ export async function decodeDynamicCssBackgroundImages(page) {
|
|
|
146
153
|
if (!backgroundImage || backgroundImage === "none")
|
|
147
154
|
continue;
|
|
148
155
|
for (const url of parseBackgroundUrls(backgroundImage)) {
|
|
149
|
-
if (!decoded.has(url))
|
|
156
|
+
if (!decoded.has(url) && !timedOut.has(url))
|
|
150
157
|
urls.push(url);
|
|
151
158
|
}
|
|
152
159
|
}
|
|
160
|
+
const late = [];
|
|
153
161
|
await Promise.all([...new Set(urls)].map(async (url) => {
|
|
154
162
|
const image = new Image();
|
|
155
163
|
image.src = url;
|
|
164
|
+
let timer;
|
|
165
|
+
const expired = new Promise((resolve) => {
|
|
166
|
+
timer = setTimeout(() => resolve("late"), limitMs);
|
|
167
|
+
});
|
|
156
168
|
try {
|
|
157
|
-
await
|
|
158
|
-
|
|
169
|
+
const outcome = await Promise.race([
|
|
170
|
+
image.decode().then(() => "ready"),
|
|
171
|
+
expired,
|
|
172
|
+
]);
|
|
173
|
+
if (outcome === "ready")
|
|
174
|
+
decoded.add(url);
|
|
175
|
+
else {
|
|
176
|
+
timedOut.add(url);
|
|
177
|
+
late.push(url);
|
|
178
|
+
}
|
|
159
179
|
}
|
|
160
180
|
catch {
|
|
161
181
|
// Keep existing capture behavior for missing assets; request diagnostics report them.
|
|
162
182
|
}
|
|
183
|
+
finally {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
}
|
|
163
186
|
}));
|
|
187
|
+
return late;
|
|
164
188
|
});
|
|
165
|
-
|
|
166
|
-
|
|
189
|
+
root.__hf_css_background_timeout_ms = timeoutMs;
|
|
190
|
+
return decode();
|
|
191
|
+
}, BACKGROUND_DECODE_TIMEOUT_MS);
|
|
167
192
|
}
|
|
168
193
|
// Circular buffer for browser console messages dumped on render failure diagnostics.
|
|
169
194
|
// Complex compositions produce 100+ messages; 50 was too small to capture relevant errors.
|
|
@@ -251,15 +276,6 @@ export function sanitizeDiagnosticUrl(input) {
|
|
|
251
276
|
return "data:<redacted>";
|
|
252
277
|
if (input.startsWith("blob:"))
|
|
253
278
|
return "blob:<redacted>";
|
|
254
|
-
if (input.startsWith("/")) {
|
|
255
|
-
try {
|
|
256
|
-
const url = new URL(input, "http://hyperframes.local");
|
|
257
|
-
return url.pathname;
|
|
258
|
-
}
|
|
259
|
-
catch {
|
|
260
|
-
return input;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
279
|
try {
|
|
264
280
|
const url = new URL(input);
|
|
265
281
|
url.username = "";
|
|
@@ -269,7 +285,13 @@ export function sanitizeDiagnosticUrl(input) {
|
|
|
269
285
|
return url.toString();
|
|
270
286
|
}
|
|
271
287
|
catch {
|
|
272
|
-
|
|
288
|
+
// A relative reference keeps its path only, and raw input never comes back.
|
|
289
|
+
try {
|
|
290
|
+
return new URL(input, "http://hyperframes.local").pathname;
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
return input.split(/[?#]/, 1)[0] ?? "";
|
|
294
|
+
}
|
|
273
295
|
}
|
|
274
296
|
}
|
|
275
297
|
export function formatNavigationFailureDiagnostic(input) {
|
|
@@ -779,7 +801,7 @@ async function finalizeDrawElementInit(session, page, logInitPhase, opts) {
|
|
|
779
801
|
const { transparent, forceDE } = opts;
|
|
780
802
|
// Install the page-local decoder before batch drawElement capture begins;
|
|
781
803
|
// the batch loop re-runs it after every in-page seek.
|
|
782
|
-
await decodeDynamicCssBackgroundImages(page);
|
|
804
|
+
recordBackgroundDecodeTimeouts(session, await decodeDynamicCssBackgroundImages(page));
|
|
783
805
|
// Self-verification ground truth: must run pre-injection — after the canvas
|
|
784
806
|
// wraps the root, a page screenshot shows the canvas's last-drawn bitmap,
|
|
785
807
|
// not the live DOM (see the Lim 6 boundary-screenshot note).
|
|
@@ -1679,6 +1701,23 @@ function recordCaptureWarnings(session, warnings) {
|
|
|
1679
1701
|
console.warn(`[FrameCapture:${warning.code}] ${warning.message}`, warning.details ?? {});
|
|
1680
1702
|
}
|
|
1681
1703
|
}
|
|
1704
|
+
/** Report background urls that timed out decoding; the shown url drops credentials and query. */
|
|
1705
|
+
export function recordBackgroundDecodeTimeouts(session, urls) {
|
|
1706
|
+
// Mocked pages answer evaluate() with undefined, so tolerate a missing list.
|
|
1707
|
+
if (!urls?.length)
|
|
1708
|
+
return;
|
|
1709
|
+
const sources = [...new Set(urls.map(sanitizeDiagnosticUrl))].sort();
|
|
1710
|
+
for (const source of sources) {
|
|
1711
|
+
appendBrowserDiagnostic(session, `[FrameCapture] background image still decoding after ${BACKGROUND_DECODE_TIMEOUT_MS}ms, captured without it: ${source}`);
|
|
1712
|
+
}
|
|
1713
|
+
recordCaptureWarnings(session, [
|
|
1714
|
+
{
|
|
1715
|
+
code: "media_readiness_timeout",
|
|
1716
|
+
message: `image media did not finish decoding within ${BACKGROUND_DECODE_TIMEOUT_MS}ms`,
|
|
1717
|
+
details: { mediaType: "image", sources, timeoutMs: BACKGROUND_DECODE_TIMEOUT_MS },
|
|
1718
|
+
},
|
|
1719
|
+
]);
|
|
1720
|
+
}
|
|
1682
1721
|
export function recordSubTimelineWarning(session, timeoutMs) {
|
|
1683
1722
|
const outcome = session.subTimelineWaitOutcome;
|
|
1684
1723
|
if (outcome === "ready" || !outcome)
|
|
@@ -2373,7 +2412,8 @@ async function prepareFrameForCapture(session, frameIndex, time, seekOptions, he
|
|
|
2373
2412
|
session.captureStep = "seeking the timeline";
|
|
2374
2413
|
const hasPendingComposite = await seekPageTimeline(page, quantizedTime, seekOptions);
|
|
2375
2414
|
session.captureStep = "decoding CSS background images";
|
|
2376
|
-
|
|
2415
|
+
// Mocked pages answer evaluate() with undefined, so tolerate a missing list.
|
|
2416
|
+
recordBackgroundDecodeTimeouts(session, await decodeDynamicCssBackgroundImages(page));
|
|
2377
2417
|
const seekMs = Date.now() - seekStart;
|
|
2378
2418
|
// Before-capture hook (e.g. video frame injection) — runs before
|
|
2379
2419
|
// page-side compositor clones so cloneNode picks up injected <img>
|
|
@@ -3796,7 +3836,8 @@ export async function captureFramesBatchPipelined(session, frameIndices, times)
|
|
|
3796
3836
|
const fps = fpsToNumber(options.fps);
|
|
3797
3837
|
const quantized = times.map((t) => quantizeTimeToFrame(t, fps));
|
|
3798
3838
|
session.captureStep = "capturing the frame";
|
|
3799
|
-
const { encodeResults, failedAt, error } = await produceDrawElementFrameBatch(page, quantized, options.width, options.height, options.quality ?? 80);
|
|
3839
|
+
const { encodeResults, failedAt, error, lateBackgrounds } = await produceDrawElementFrameBatch(page, quantized, options.width, options.height, options.quality ?? 80);
|
|
3840
|
+
recordBackgroundDecodeTimeouts(session, lateBackgrounds);
|
|
3800
3841
|
// A later batch failure abandons this prefix. Its asynchronous encodes can
|
|
3801
3842
|
// still reject while session cleanup shuts the worker down.
|
|
3802
3843
|
for (const result of encodeResults)
|
|
@@ -44,6 +44,19 @@ async function scanChunk(page, times) {
|
|
|
44
44
|
return out;
|
|
45
45
|
}, times);
|
|
46
46
|
}
|
|
47
|
+
/** The chunk's frames, or null once `ms` pass first: a wedged page answers only at the protocol timeout. */
|
|
48
|
+
function withinBudget(work, ms) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const timer = setTimeout(() => resolve(null), Math.max(0, ms));
|
|
51
|
+
work.then((value) => {
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
resolve(value);
|
|
54
|
+
}, (error) => {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
reject(error);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
47
60
|
/**
|
|
48
61
|
* Frames where a recorded effect element shows. Seeks the page forward, so use a throwaway one.
|
|
49
62
|
* Null once more than `maxFrames` frames qualify, or once the whole scan would take over `budgetMs`.
|
|
@@ -56,7 +69,13 @@ export async function scanCssEffectLiveFrames(page, fps, totalFrames, maxFrames,
|
|
|
56
69
|
const times = [];
|
|
57
70
|
for (let f = from; f < to; f++)
|
|
58
71
|
times.push(quantizeTimeToFrame(f / fps, fps));
|
|
59
|
-
|
|
72
|
+
const shown = await withinBudget(scanChunk(page, times), budgetMs - (now() - started));
|
|
73
|
+
if (!shown) {
|
|
74
|
+
console.error(`[engine] fast capture: the effect scan got no answer from the page within its ` +
|
|
75
|
+
`${budgetMs / 1000} s budget (frames ${from}-${to - 1})`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
for (const i of shown)
|
|
60
79
|
live.add(from + i);
|
|
61
80
|
onProgress?.(to, totalFrames);
|
|
62
81
|
const projectedMs = ((now() - started) / to) * totalFrames;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.15",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"puppeteer": "~25.11.0",
|
|
62
62
|
"puppeteer-core": "^25.11.0",
|
|
63
63
|
"undici": "7.25.0",
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
64
|
+
"@hyperframes/core": "^0.9.15",
|
|
65
|
+
"@hyperframes/parsers": "^0.9.15"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
68
|
"@types/node": "^25.0.10",
|