@hyperframes/engine 0.8.15 → 0.8.16

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.
Files changed (34) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/services/audioMixer.d.ts.map +1 -1
  6. package/dist/services/audioMixer.js +11 -9
  7. package/dist/services/audioMixer.js.map +1 -1
  8. package/dist/services/chunkEncoder.d.ts +7 -1
  9. package/dist/services/chunkEncoder.d.ts.map +1 -1
  10. package/dist/services/chunkEncoder.js +12 -8
  11. package/dist/services/chunkEncoder.js.map +1 -1
  12. package/dist/services/drawElementService.d.ts +25 -0
  13. package/dist/services/drawElementService.d.ts.map +1 -1
  14. package/dist/services/drawElementService.js +49 -6
  15. package/dist/services/drawElementService.js.map +1 -1
  16. package/dist/services/extractedFrameIndex.d.ts +8 -0
  17. package/dist/services/extractedFrameIndex.d.ts.map +1 -0
  18. package/dist/services/extractedFrameIndex.js +43 -0
  19. package/dist/services/extractedFrameIndex.js.map +1 -0
  20. package/dist/services/extractionCache.d.ts +6 -1
  21. package/dist/services/extractionCache.d.ts.map +1 -1
  22. package/dist/services/extractionCache.js +26 -10
  23. package/dist/services/extractionCache.js.map +1 -1
  24. package/dist/services/frameCapture.d.ts +18 -6
  25. package/dist/services/frameCapture.d.ts.map +1 -1
  26. package/dist/services/frameCapture.js +170 -31
  27. package/dist/services/frameCapture.js.map +1 -1
  28. package/dist/services/referenceResolver.d.ts +1 -2
  29. package/dist/services/referenceResolver.d.ts.map +1 -1
  30. package/dist/services/videoFrameExtractor.d.ts +12 -0
  31. package/dist/services/videoFrameExtractor.d.ts.map +1 -1
  32. package/dist/services/videoFrameExtractor.js +25 -20
  33. package/dist/services/videoFrameExtractor.js.map +1 -1
  34. package/package.json +3 -3
@@ -13,7 +13,7 @@ import { quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, } from "
13
13
  // ── Extracted modules ───────────────────────────────────────────────────────
14
14
  import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, resolveHeadlessShellPath, } from "./browserManager.js";
15
15
  import { beginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
16
- import { classifyGpuRenderer, detectGpuBackend, injectDrawElementCanvas, captureDrawElementFrame, resolveDrawElementCaptureMode, instrumentAcceleratedCanvases, initDrawElementWorkerEncode, cleanupDrawElementWorkerEncode, produceDrawElementFrame, produceDrawElementFrameBatch, } from "./drawElementService.js";
16
+ import { classifyGpuRenderer, detectGpuBackend, injectDrawElementCanvas, captureDrawElementFrame, resolveDrawElementCaptureMode, instrumentAcceleratedCanvases, initDrawElementWorkerEncode, cleanupDrawElementWorkerEncode, produceDrawElementFrame, produceDrawElementFrameBatch, DE_CANVAS_NOT_INITIALIZED_CODE, } from "./drawElementService.js";
17
17
  import { initThreeDProjection, detectCssEffectRisk } from "./threeDProjection.js";
18
18
  import { isPsnrFilterAvailable } from "../utils/psnrFilterAvailability.js";
19
19
  import { DEFAULT_CONFIG, applyConcreteGpuScreenshotClamp } from "../config.js";
@@ -2475,10 +2475,49 @@ async function computeTimelineAtRiskFrames(page, fps) {
2475
2475
  * thrown when a subtree element has no paint record for the current frame (display
2476
2476
  * toggled / detached / freshly-shown at a clip-cut boundary). Per-frame, not
2477
2477
  * whole-comp — callers fall back to screenshot for the single frame.
2478
+ *
2479
+ * This is a NATIVE Chrome DOMException (`drawElementImage`'s own error), so we
2480
+ * can't bake a discriminant into it the way we can for our own thrown errors
2481
+ * (see {@link isCanvasNotInitializedError}) — Puppeteer's `page.evaluate`
2482
+ * error reconstruction also doesn't preserve a usable `.name` for it (comes
2483
+ * back generic). Match on the FULL native phrase ("...for element"), not just
2484
+ * the generic "No cached paint record" prefix, to cut the odds of an
2485
+ * unrelated message coincidentally matching (review: substring-match footgun).
2478
2486
  */
2479
2487
  function isNoCachedPaintRecordError(err) {
2480
2488
  const msg = err instanceof Error ? err.message : String(err);
2481
- return msg.includes("No cached paint record");
2489
+ return msg.includes("No cached paint record for element");
2490
+ }
2491
+ /**
2492
+ * True for the drawElement "capture canvas isn't set up yet" error — thrown
2493
+ * (or, on the batch path, returned as a string) by drawElementService when
2494
+ * the injected capture canvas (`#__hf_de_canvas`) isn't set up yet (observed
2495
+ * at frame 0 on some macOS/Chrome combinations, see #3423). Recoverable:
2496
+ * the composition root IS present, so `pageScreenshotCapture` captures valid
2497
+ * content.
2498
+ *
2499
+ * This is distinct from the composition-root-missing case
2500
+ * (`HF_DE_COMPOSITION_ROOT_MISSING`), which is NOT recoverable — the page
2501
+ * has no composition content to screenshot, so falling back would capture
2502
+ * blank or navigated-away content.
2503
+ *
2504
+ * Matches the {@link DE_CANVAS_NOT_INITIALIZED_CODE} discriminant baked into
2505
+ * the message (not free-text), so it survives `produceDrawElementFrameBatch`'s
2506
+ * "batch produce failed at frame N: <code>: ..." wrapping.
2507
+ */
2508
+ function isCanvasNotInitializedError(err) {
2509
+ const msg = err instanceof Error ? err.message : String(err);
2510
+ return msg.includes(DE_CANVAS_NOT_INITIALIZED_CODE);
2511
+ }
2512
+ /**
2513
+ * Single gate for drawElement failures the fast-capture pipeline knows how to
2514
+ * recover from by falling back to screenshot capture instead of aborting the
2515
+ * render. Both {@link captureFrameCore} and {@link captureFrameToBufferPipelined}
2516
+ * consult this so a newly-recognized recoverable error only needs to be taught
2517
+ * here once.
2518
+ */
2519
+ function isRecoverableDrawElementError(err) {
2520
+ return isNoCachedPaintRecordError(err) || isCanvasNotInitializedError(err);
2482
2521
  }
2483
2522
  async function captureFrameCore(session, frameIndex, time) {
2484
2523
  const { page, options } = session;
@@ -2532,7 +2571,7 @@ async function captureFrameCore(session, frameIndex, time) {
2532
2571
  // stale), so the "fallback" REPLACES good frames with damaged ones (validated:
2533
2572
  // 35e8fa9f 462→0 damaged frames, 4001da8e 11→0, when this is off). The two real
2534
2573
  // boundary failure modes are now caught reactively below — the throw case by
2535
- // isNoCachedPaintRecordError, the silent-solid-black case by the small-frame
2574
+ // isRecoverableDrawElementError, the silent-solid-black case by the small-frame
2536
2575
  // blank-guard (a solid frame is a tiny JPEG) — without touching frames drawElement
2537
2576
  // handles. Force the old behavior with HF_FAST_CAPTURE_BOUNDARY_SS=true. The worker
2538
2577
  // path keeps proactive boundary-SS (it has no blank-guard); see
@@ -2586,12 +2625,17 @@ async function captureFrameCore(session, frameIndex, time) {
2586
2625
  catch (err) {
2587
2626
  // drawElementImage throws `InvalidStateError: No cached paint record for
2588
2627
  // element` when an element in the subtree has no paint record this frame
2589
- // (display toggled / detached / freshly-shown at a clip-cut boundary). This
2590
- // is a per-frame condition, not a whole-comp one fall back to screenshot
2591
- // for THIS frame instead of aborting the render. See fast-capture-limitations.md.
2592
- if (isNoCachedPaintRecordError(err)) {
2628
+ // (display toggled / detached / freshly-shown at a clip-cut boundary), and
2629
+ // `canvas not initialized` when the injected capture canvas isn't set up yet
2630
+ // (observed at frame 0 on some macOS/Chrome combinations, see #3423). Both
2631
+ // are per-frame conditions, not whole-comp ones — fall back to screenshot for
2632
+ // THIS frame instead of aborting the render. See fast-capture-limitations.md.
2633
+ if (isRecoverableDrawElementError(err)) {
2593
2634
  session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
2594
- console.log(`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
2635
+ const reason = isCanvasNotInitializedError(err)
2636
+ ? "drawElement canvas not initialized"
2637
+ : "No cached paint record";
2638
+ console.log(`[engine] fast capture: frame ${frameIndex} — ${reason}; ` +
2595
2639
  `screenshot fallback for this frame (see fast-capture-limitations.md)`);
2596
2640
  screenshotBuffer = await pageScreenshotCapture(page, options);
2597
2641
  }
@@ -2749,13 +2793,17 @@ export async function captureFrameToBufferPipelined(session, frameIndex, time) {
2749
2793
  return { encodeResult, captureTimeMs };
2750
2794
  }
2751
2795
  catch (captureError) {
2752
- // Per-frame `No cached paint record`: fall back to screenshot for THIS frame
2753
- // instead of aborting the render (clip-cut boundary / freshly-shown element).
2754
- // The worker isn't involved for this frame; return a resolved encodeResult so
2755
- // the pipeline loop writes it like any other. See fast-capture-limitations.md.
2756
- if (isNoCachedPaintRecordError(captureError)) {
2796
+ // Per-frame `No cached paint record` or `canvas not initialized` (#3423): fall
2797
+ // back to screenshot for THIS frame instead of aborting the render (clip-cut
2798
+ // boundary / freshly-shown element / capture canvas not yet set up). The worker
2799
+ // isn't involved for this frame; return a resolved encodeResult so the pipeline
2800
+ // loop writes it like any other. See fast-capture-limitations.md.
2801
+ if (isRecoverableDrawElementError(captureError)) {
2757
2802
  session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
2758
- console.log(`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
2803
+ const reason = isCanvasNotInitializedError(captureError)
2804
+ ? "drawElement canvas not initialized"
2805
+ : "No cached paint record";
2806
+ console.log(`[engine] fast capture: frame ${frameIndex} — ${reason}; ` +
2759
2807
  `screenshot fallback for this frame (see fast-capture-limitations.md)`);
2760
2808
  const buffer = await pageScreenshotCapture(page, options);
2761
2809
  return { encodeResult: Promise.resolve(buffer), captureTimeMs: Date.now() - startTime };
@@ -2776,8 +2824,9 @@ export async function captureFrameToBufferPipelined(session, frameIndex, time) {
2776
2824
  * drain time:
2777
2825
  * - the static-dedup fast path returns session.lastEncodeResult, which by
2778
2826
  * drain time can hold a frame several indices AHEAD of the suspect frame;
2779
- * - the per-frame "No cached paint record" screenshot fallback captures the
2780
- * injected canvas i.e. the LAST drawn drawElement frame, not this one.
2827
+ * - the per-frame recoverable-error screenshot fallback ("No cached paint
2828
+ * record" or "canvas not initialized") captures the viewport which may
2829
+ * hold the LAST drawn drawElement frame, not this one.
2781
2830
  * Any failure here throws; the caller treats that as verification failure and
2782
2831
  * falls back the whole render (correct, never wrong-frame).
2783
2832
  */
@@ -2794,10 +2843,21 @@ export async function recaptureDrawElementFrameForVerify(session, frameIndex, ti
2794
2843
  * P6 prototype (HF_DE_BATCH): capture N consecutive frames in one CDP
2795
2844
  * round-trip via {@link produceDrawElementFrameBatch}. The caller pre-plans the
2796
2845
  * batch (consecutive frame indices, none static-dedup'd, none opt-in
2797
- * boundary-screenshot). On a mid-batch in-page failure the remaining frames are
2798
- * re-captured through {@link captureFrameToBufferPipelined}, which owns the
2799
- * per-frame screenshot-fallback semantics so failure behavior is identical to
2800
- * the unbatched path, just discovered at batch granularity.
2846
+ * boundary-screenshot). On a mid-batch in-page failure the remaining frames'
2847
+ * handling depends on whether the failure is one of the recoverable
2848
+ * per-frame drawElement conditions (canvas-not-initialized / no-cached-paint-
2849
+ * record, #3423):
2850
+ * - Recoverable: capture the remaining frames directly via screenshot,
2851
+ * same as the per-frame paths' own fallback (avoids re-attempting a
2852
+ * drawElement produce that the batch call just told us will fail again —
2853
+ * review finding: audit this path explicitly rather than relying on the
2854
+ * incidental retry-then-catch behavior below).
2855
+ * - Anything else (unrecognized error): fall through to
2856
+ * {@link captureFrameToBufferPipelined}, which re-attempts drawElement (so
2857
+ * a genuinely transient, non-drawElement-specific failure still gets a
2858
+ * second chance) and owns the same recoverable-error/fatal-error split for
2859
+ * whatever it encounters — so failure behavior for a truly fatal error is
2860
+ * identical to the unbatched path, just discovered at batch granularity.
2801
2861
  */
2802
2862
  export async function captureFramesBatchPipelined(session, frameIndices, times) {
2803
2863
  const { page, options } = session;
@@ -2829,16 +2889,57 @@ export async function captureFramesBatchPipelined(session, frameIndices, times)
2829
2889
  results.push({ frameIndex, encodeResult });
2830
2890
  }
2831
2891
  if (failedAt !== null) {
2832
- console.log(`[engine] fast capture: batch produce failed at frame ` +
2833
- `${frameIndices[failedAt] ?? "?"} (${error ?? "?"}); ` +
2834
- `re-capturing ${frameIndices.length - failedAt} frame(s) per-frame`);
2835
- for (let i = failedAt; i < frameIndices.length; i++) {
2836
- const frameIndex = frameIndices[i];
2837
- const time = times[i];
2838
- if (frameIndex === undefined || time === undefined)
2839
- break;
2840
- const { encodeResult } = await captureFrameToBufferPipelined(session, frameIndex, time);
2841
- results.push({ frameIndex, encodeResult });
2892
+ // `error` is a plain string here (produceDrawElementFrameBatch returns it
2893
+ // out of an in-page evaluate rather than throwing an Error instance)
2894
+ // isRecoverableDrawElementError accepts `unknown` and stringifies non-Error
2895
+ // input, so passing the string straight through classifies it correctly,
2896
+ // including through produceDrawElementFrameBatch's own error text (which
2897
+ // embeds the same DE_CANVAS_NOT_INITIALIZED_CODE / native paint-record
2898
+ // phrase the per-frame paths match on).
2899
+ if (isRecoverableDrawElementError(error)) {
2900
+ const reason = isCanvasNotInitializedError(error)
2901
+ ? "drawElement canvas not initialized"
2902
+ : "No cached paint record";
2903
+ console.log(`[engine] fast capture: batch produce failed at frame ` +
2904
+ `${frameIndices[failedAt] ?? "?"} (${reason}); ` +
2905
+ `screenshot fallback for ${frameIndices.length - failedAt} frame(s) ` +
2906
+ `(see fast-capture-limitations.md)`);
2907
+ for (let i = failedAt; i < frameIndices.length; i++) {
2908
+ const frameIndex = frameIndices[i];
2909
+ const time = times[i];
2910
+ if (frameIndex === undefined || time === undefined)
2911
+ break;
2912
+ session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
2913
+ // Each remaining frame still needs its own seek/prepare — the batch
2914
+ // produce call left the page composited for whichever frame it last
2915
+ // attempted, not this one. Without this, every fallback screenshot in
2916
+ // the loop captures the SAME (stale) frame instead of advancing.
2917
+ // Deliberately reuse prepareFrameForCapture rather than routing
2918
+ // through captureFrameToBufferPipelined here, since that would
2919
+ // re-attempt produceDrawElementFrame — which the batch call already
2920
+ // told us will fail again for these frames (see function doc above).
2921
+ await prepareFrameForCapture(session, frameIndex, time);
2922
+ const buffer = await pageScreenshotCapture(page, options);
2923
+ const encodeResult = Promise.resolve(buffer);
2924
+ if (session.staticFrames) {
2925
+ session.lastEncodeResult = encodeResult;
2926
+ session.lastEncodeResultFrame = frameIndex;
2927
+ }
2928
+ results.push({ frameIndex, encodeResult });
2929
+ }
2930
+ }
2931
+ else {
2932
+ console.log(`[engine] fast capture: batch produce failed at frame ` +
2933
+ `${frameIndices[failedAt] ?? "?"} (${error ?? "?"}); ` +
2934
+ `re-capturing ${frameIndices.length - failedAt} frame(s) per-frame`);
2935
+ for (let i = failedAt; i < frameIndices.length; i++) {
2936
+ const frameIndex = frameIndices[i];
2937
+ const time = times[i];
2938
+ if (frameIndex === undefined || time === undefined)
2939
+ break;
2940
+ const { encodeResult } = await captureFrameToBufferPipelined(session, frameIndex, time);
2941
+ results.push({ frameIndex, encodeResult });
2942
+ }
2842
2943
  }
2843
2944
  }
2844
2945
  // Task B: retain the last encode result so a following static frame can reuse it.
@@ -3137,8 +3238,46 @@ export function percentileOf(samples, p) {
3137
3238
  const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor(p * sorted.length)));
3138
3239
  return Math.round(sorted[idx] ?? 0);
3139
3240
  }
3241
+ /**
3242
+ * Fraction of captured frames above which a fast-capture render is treated as
3243
+ * "drawElement effectively didn't engage" rather than "recovered a handful of
3244
+ * edge-case frames" (see the cross-PR-seam warning in
3245
+ * {@link getCapturePerfSummary}). Not currently a hard gate — see that
3246
+ * function's comment for why — just the threshold for the loud diagnostic.
3247
+ */
3248
+ const DE_FALLBACK_RATIO_WARN_THRESHOLD = 0.5;
3140
3249
  export function getCapturePerfSummary(session) {
3141
3250
  const frames = Math.max(1, session.capturePerf.frames);
3251
+ const ncprFallbacks = session.deNcprFallbacks ?? 0;
3252
+ // Cross-PR seam (#3423 per-frame screenshot fallback vs #3429 artifact
3253
+ // validation): #3429's artifact validation only checks that the render
3254
+ // produced the right frame COUNT and duration — it has no visibility into
3255
+ // HOW each frame was captured. If a composition is so incompatible with
3256
+ // drawElement that most/all frames take the per-frame screenshot fallback
3257
+ // added here, the render still reports "complete" with a correct frame
3258
+ // count, even though drawElement effectively never engaged for it. That's
3259
+ // not itself a correctness bug — screenshot capture is the platform's
3260
+ // normal, well-tested baseline, so the SHIPPED PIXELS are fine — but a
3261
+ // near-100% fallback ratio is a strong signal that fast-capture silently
3262
+ // failed to engage for the whole render (e.g. a persistent canvas-injection
3263
+ // problem) rather than recovering a handful of expected edge-case frames,
3264
+ // and today nothing surfaces that distinction to telemetry or to a human.
3265
+ //
3266
+ // Deliberately NOT a circuit breaker: aborting/failing the render here
3267
+ // would make a render that reliably succeeds via the well-tested screenshot
3268
+ // path fail instead, which is a worse outcome than a slow-but-correct
3269
+ // render. Whether artifact validation (or this session) should eventually
3270
+ // gate on the ratio — and where that decision belongs — is tracked as an
3271
+ // explicit follow-up: https://github.com/heygen-com/hyperframes/issues/3482
3272
+ // ("Fast-capture: fallback-ratio guard for #3423 x #3429 seam"), rather
3273
+ // than decided unilaterally in this review-response commit.
3274
+ if (frames > 0 && ncprFallbacks / frames > DE_FALLBACK_RATIO_WARN_THRESHOLD) {
3275
+ const pct = Math.round((ncprFallbacks / frames) * 100);
3276
+ console.warn(`[engine] fast capture: ${ncprFallbacks}/${frames} frame(s) (${pct}%) fell back to ` +
3277
+ `screenshot capture (canvas-not-initialized / no-cached-paint-record) — ` +
3278
+ `drawElement likely failed to engage for this render rather than recovering a few ` +
3279
+ `edge-case frames; see fast-capture-limitations.md.`);
3280
+ }
3142
3281
  return {
3143
3282
  frames: session.capturePerf.frames,
3144
3283
  avgTotalMs: Math.round(session.capturePerf.totalMs / frames),
@@ -3177,7 +3316,7 @@ export function getCapturePerfSummary(session) {
3177
3316
  deVerifyArmed: session.deVerifyFrames?.size ?? 0,
3178
3317
  deVerifyInitMs: session.deVerifyInitMs ?? 0,
3179
3318
  deBoundaryFrames: session.clipBoundaryFrames?.size ?? 0,
3180
- deNcprFallbacks: session.deNcprFallbacks ?? 0,
3319
+ deNcprFallbacks: ncprFallbacks,
3181
3320
  };
3182
3321
  }
3183
3322
  //# sourceMappingURL=frameCapture.js.map