@hyperframes/engine 0.9.13 → 0.9.14

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
- return { encodeResults, failedAt: outcome.failedAt, error: outcome.error };
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
- /** Wait for inline CSS background images introduced by the latest seek. */
278
- export declare function decodeDynamicCssBackgroundImages(page: Page): Promise<void>;
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
- /** Wait for inline CSS background images introduced by the latest seek. */
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
- await page.evaluate(async () => {
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 image.decode();
158
- decoded.add(url);
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
- await decode();
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
- return input;
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
- await decodeDynamicCssBackgroundImages(page);
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
- for (const i of await scanChunk(page, times))
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.13",
3
+ "version": "0.9.14",
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/parsers": "^0.9.13",
65
- "@hyperframes/core": "^0.9.13"
64
+ "@hyperframes/parsers": "^0.9.14",
65
+ "@hyperframes/core": "^0.9.14"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@types/node": "^25.0.10",