@hyperframes/engine 0.8.15 → 0.8.17

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 (38) 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 +184 -33
  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/dist/utils/ffprobe.d.ts +5 -0
  35. package/dist/utils/ffprobe.d.ts.map +1 -1
  36. package/dist/utils/ffprobe.js +9 -0
  37. package/dist/utils/ffprobe.js.map +1 -1
  38. 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";
@@ -1330,13 +1330,16 @@ function recordSubTimelineWarning(session, timeoutMs) {
1330
1330
  if (session.subTimelineWaitOutcome === "ready" || !session.subTimelineWaitOutcome)
1331
1331
  return;
1332
1332
  const scriptFailure = session.subTimelineWaitOutcome === "script_failure";
1333
+ const hasRuntimeErrors = session.scriptLoadFailures.some((f) => f.startsWith("runtime-error:"));
1333
1334
  recordCaptureWarnings(session, [
1334
1335
  {
1335
1336
  code: scriptFailure ? "sub_timeline_script_failure" : "sub_timeline_readiness_timeout",
1336
1337
  message: scriptFailure
1337
- ? "A sub-composition timeline script failed to load"
1338
+ ? hasRuntimeErrors
1339
+ ? `A sub-composition script threw during execution — timeline registration never arrived (${session.scriptLoadFailures.join(", ")})`
1340
+ : `A sub-composition timeline script failed to load (${session.scriptLoadFailures.join(", ")})`
1338
1341
  : `Sub-composition timelines did not become ready within ${timeoutMs}ms`,
1339
- details: { timeoutMs },
1342
+ details: { timeoutMs, sources: [...session.scriptLoadFailures] },
1340
1343
  },
1341
1344
  ]);
1342
1345
  }
@@ -1478,6 +1481,15 @@ export async function initializeSession(session) {
1478
1481
  if (!diagnostic.suppressHostLog)
1479
1482
  console.log(diagnostic.text);
1480
1483
  appendBrowserDiagnostic(session, diagnostic.text);
1484
+ // Composition script runtime errors mean the GSAP timeline registration
1485
+ // can never arrive — same fail-fast treatment as script load failures.
1486
+ // Without this, pollSubCompositionTimelines burns the full timeout and
1487
+ // the render silently succeeds with a degenerate 2-frame output (#3352).
1488
+ if (type === "error" && text.startsWith("[HyperFrames] composition script error:")) {
1489
+ const detail = text.slice("[HyperFrames] composition script error:".length).trim();
1490
+ const compId = detail.split(" ")[0] || "unknown";
1491
+ recordScriptLoadFailure(session, `runtime-error:${compId}`);
1492
+ }
1481
1493
  });
1482
1494
  page.on("pageerror", (err) => {
1483
1495
  const message = err instanceof Error ? err.message : String(err);
@@ -2475,10 +2487,49 @@ async function computeTimelineAtRiskFrames(page, fps) {
2475
2487
  * thrown when a subtree element has no paint record for the current frame (display
2476
2488
  * toggled / detached / freshly-shown at a clip-cut boundary). Per-frame, not
2477
2489
  * whole-comp — callers fall back to screenshot for the single frame.
2490
+ *
2491
+ * This is a NATIVE Chrome DOMException (`drawElementImage`'s own error), so we
2492
+ * can't bake a discriminant into it the way we can for our own thrown errors
2493
+ * (see {@link isCanvasNotInitializedError}) — Puppeteer's `page.evaluate`
2494
+ * error reconstruction also doesn't preserve a usable `.name` for it (comes
2495
+ * back generic). Match on the FULL native phrase ("...for element"), not just
2496
+ * the generic "No cached paint record" prefix, to cut the odds of an
2497
+ * unrelated message coincidentally matching (review: substring-match footgun).
2478
2498
  */
2479
2499
  function isNoCachedPaintRecordError(err) {
2480
2500
  const msg = err instanceof Error ? err.message : String(err);
2481
- return msg.includes("No cached paint record");
2501
+ return msg.includes("No cached paint record for element");
2502
+ }
2503
+ /**
2504
+ * True for the drawElement "capture canvas isn't set up yet" error — thrown
2505
+ * (or, on the batch path, returned as a string) by drawElementService when
2506
+ * the injected capture canvas (`#__hf_de_canvas`) isn't set up yet (observed
2507
+ * at frame 0 on some macOS/Chrome combinations, see #3423). Recoverable:
2508
+ * the composition root IS present, so `pageScreenshotCapture` captures valid
2509
+ * content.
2510
+ *
2511
+ * This is distinct from the composition-root-missing case
2512
+ * (`HF_DE_COMPOSITION_ROOT_MISSING`), which is NOT recoverable — the page
2513
+ * has no composition content to screenshot, so falling back would capture
2514
+ * blank or navigated-away content.
2515
+ *
2516
+ * Matches the {@link DE_CANVAS_NOT_INITIALIZED_CODE} discriminant baked into
2517
+ * the message (not free-text), so it survives `produceDrawElementFrameBatch`'s
2518
+ * "batch produce failed at frame N: <code>: ..." wrapping.
2519
+ */
2520
+ function isCanvasNotInitializedError(err) {
2521
+ const msg = err instanceof Error ? err.message : String(err);
2522
+ return msg.includes(DE_CANVAS_NOT_INITIALIZED_CODE);
2523
+ }
2524
+ /**
2525
+ * Single gate for drawElement failures the fast-capture pipeline knows how to
2526
+ * recover from by falling back to screenshot capture instead of aborting the
2527
+ * render. Both {@link captureFrameCore} and {@link captureFrameToBufferPipelined}
2528
+ * consult this so a newly-recognized recoverable error only needs to be taught
2529
+ * here once.
2530
+ */
2531
+ function isRecoverableDrawElementError(err) {
2532
+ return isNoCachedPaintRecordError(err) || isCanvasNotInitializedError(err);
2482
2533
  }
2483
2534
  async function captureFrameCore(session, frameIndex, time) {
2484
2535
  const { page, options } = session;
@@ -2532,7 +2583,7 @@ async function captureFrameCore(session, frameIndex, time) {
2532
2583
  // stale), so the "fallback" REPLACES good frames with damaged ones (validated:
2533
2584
  // 35e8fa9f 462→0 damaged frames, 4001da8e 11→0, when this is off). The two real
2534
2585
  // boundary failure modes are now caught reactively below — the throw case by
2535
- // isNoCachedPaintRecordError, the silent-solid-black case by the small-frame
2586
+ // isRecoverableDrawElementError, the silent-solid-black case by the small-frame
2536
2587
  // blank-guard (a solid frame is a tiny JPEG) — without touching frames drawElement
2537
2588
  // handles. Force the old behavior with HF_FAST_CAPTURE_BOUNDARY_SS=true. The worker
2538
2589
  // path keeps proactive boundary-SS (it has no blank-guard); see
@@ -2586,12 +2637,17 @@ async function captureFrameCore(session, frameIndex, time) {
2586
2637
  catch (err) {
2587
2638
  // drawElementImage throws `InvalidStateError: No cached paint record for
2588
2639
  // 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)) {
2640
+ // (display toggled / detached / freshly-shown at a clip-cut boundary), and
2641
+ // `canvas not initialized` when the injected capture canvas isn't set up yet
2642
+ // (observed at frame 0 on some macOS/Chrome combinations, see #3423). Both
2643
+ // are per-frame conditions, not whole-comp ones — fall back to screenshot for
2644
+ // THIS frame instead of aborting the render. See fast-capture-limitations.md.
2645
+ if (isRecoverableDrawElementError(err)) {
2593
2646
  session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
2594
- console.log(`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
2647
+ const reason = isCanvasNotInitializedError(err)
2648
+ ? "drawElement canvas not initialized"
2649
+ : "No cached paint record";
2650
+ console.log(`[engine] fast capture: frame ${frameIndex} — ${reason}; ` +
2595
2651
  `screenshot fallback for this frame (see fast-capture-limitations.md)`);
2596
2652
  screenshotBuffer = await pageScreenshotCapture(page, options);
2597
2653
  }
@@ -2749,13 +2805,17 @@ export async function captureFrameToBufferPipelined(session, frameIndex, time) {
2749
2805
  return { encodeResult, captureTimeMs };
2750
2806
  }
2751
2807
  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)) {
2808
+ // Per-frame `No cached paint record` or `canvas not initialized` (#3423): fall
2809
+ // back to screenshot for THIS frame instead of aborting the render (clip-cut
2810
+ // boundary / freshly-shown element / capture canvas not yet set up). The worker
2811
+ // isn't involved for this frame; return a resolved encodeResult so the pipeline
2812
+ // loop writes it like any other. See fast-capture-limitations.md.
2813
+ if (isRecoverableDrawElementError(captureError)) {
2757
2814
  session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
2758
- console.log(`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
2815
+ const reason = isCanvasNotInitializedError(captureError)
2816
+ ? "drawElement canvas not initialized"
2817
+ : "No cached paint record";
2818
+ console.log(`[engine] fast capture: frame ${frameIndex} — ${reason}; ` +
2759
2819
  `screenshot fallback for this frame (see fast-capture-limitations.md)`);
2760
2820
  const buffer = await pageScreenshotCapture(page, options);
2761
2821
  return { encodeResult: Promise.resolve(buffer), captureTimeMs: Date.now() - startTime };
@@ -2776,8 +2836,9 @@ export async function captureFrameToBufferPipelined(session, frameIndex, time) {
2776
2836
  * drain time:
2777
2837
  * - the static-dedup fast path returns session.lastEncodeResult, which by
2778
2838
  * 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.
2839
+ * - the per-frame recoverable-error screenshot fallback ("No cached paint
2840
+ * record" or "canvas not initialized") captures the viewport which may
2841
+ * hold the LAST drawn drawElement frame, not this one.
2781
2842
  * Any failure here throws; the caller treats that as verification failure and
2782
2843
  * falls back the whole render (correct, never wrong-frame).
2783
2844
  */
@@ -2794,10 +2855,21 @@ export async function recaptureDrawElementFrameForVerify(session, frameIndex, ti
2794
2855
  * P6 prototype (HF_DE_BATCH): capture N consecutive frames in one CDP
2795
2856
  * round-trip via {@link produceDrawElementFrameBatch}. The caller pre-plans the
2796
2857
  * 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.
2858
+ * boundary-screenshot). On a mid-batch in-page failure the remaining frames'
2859
+ * handling depends on whether the failure is one of the recoverable
2860
+ * per-frame drawElement conditions (canvas-not-initialized / no-cached-paint-
2861
+ * record, #3423):
2862
+ * - Recoverable: capture the remaining frames directly via screenshot,
2863
+ * same as the per-frame paths' own fallback (avoids re-attempting a
2864
+ * drawElement produce that the batch call just told us will fail again —
2865
+ * review finding: audit this path explicitly rather than relying on the
2866
+ * incidental retry-then-catch behavior below).
2867
+ * - Anything else (unrecognized error): fall through to
2868
+ * {@link captureFrameToBufferPipelined}, which re-attempts drawElement (so
2869
+ * a genuinely transient, non-drawElement-specific failure still gets a
2870
+ * second chance) and owns the same recoverable-error/fatal-error split for
2871
+ * whatever it encounters — so failure behavior for a truly fatal error is
2872
+ * identical to the unbatched path, just discovered at batch granularity.
2801
2873
  */
2802
2874
  export async function captureFramesBatchPipelined(session, frameIndices, times) {
2803
2875
  const { page, options } = session;
@@ -2829,16 +2901,57 @@ export async function captureFramesBatchPipelined(session, frameIndices, times)
2829
2901
  results.push({ frameIndex, encodeResult });
2830
2902
  }
2831
2903
  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 });
2904
+ // `error` is a plain string here (produceDrawElementFrameBatch returns it
2905
+ // out of an in-page evaluate rather than throwing an Error instance)
2906
+ // isRecoverableDrawElementError accepts `unknown` and stringifies non-Error
2907
+ // input, so passing the string straight through classifies it correctly,
2908
+ // including through produceDrawElementFrameBatch's own error text (which
2909
+ // embeds the same DE_CANVAS_NOT_INITIALIZED_CODE / native paint-record
2910
+ // phrase the per-frame paths match on).
2911
+ if (isRecoverableDrawElementError(error)) {
2912
+ const reason = isCanvasNotInitializedError(error)
2913
+ ? "drawElement canvas not initialized"
2914
+ : "No cached paint record";
2915
+ console.log(`[engine] fast capture: batch produce failed at frame ` +
2916
+ `${frameIndices[failedAt] ?? "?"} (${reason}); ` +
2917
+ `screenshot fallback for ${frameIndices.length - failedAt} frame(s) ` +
2918
+ `(see fast-capture-limitations.md)`);
2919
+ for (let i = failedAt; i < frameIndices.length; i++) {
2920
+ const frameIndex = frameIndices[i];
2921
+ const time = times[i];
2922
+ if (frameIndex === undefined || time === undefined)
2923
+ break;
2924
+ session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
2925
+ // Each remaining frame still needs its own seek/prepare — the batch
2926
+ // produce call left the page composited for whichever frame it last
2927
+ // attempted, not this one. Without this, every fallback screenshot in
2928
+ // the loop captures the SAME (stale) frame instead of advancing.
2929
+ // Deliberately reuse prepareFrameForCapture rather than routing
2930
+ // through captureFrameToBufferPipelined here, since that would
2931
+ // re-attempt produceDrawElementFrame — which the batch call already
2932
+ // told us will fail again for these frames (see function doc above).
2933
+ await prepareFrameForCapture(session, frameIndex, time);
2934
+ const buffer = await pageScreenshotCapture(page, options);
2935
+ const encodeResult = Promise.resolve(buffer);
2936
+ if (session.staticFrames) {
2937
+ session.lastEncodeResult = encodeResult;
2938
+ session.lastEncodeResultFrame = frameIndex;
2939
+ }
2940
+ results.push({ frameIndex, encodeResult });
2941
+ }
2942
+ }
2943
+ else {
2944
+ console.log(`[engine] fast capture: batch produce failed at frame ` +
2945
+ `${frameIndices[failedAt] ?? "?"} (${error ?? "?"}); ` +
2946
+ `re-capturing ${frameIndices.length - failedAt} frame(s) per-frame`);
2947
+ for (let i = failedAt; i < frameIndices.length; i++) {
2948
+ const frameIndex = frameIndices[i];
2949
+ const time = times[i];
2950
+ if (frameIndex === undefined || time === undefined)
2951
+ break;
2952
+ const { encodeResult } = await captureFrameToBufferPipelined(session, frameIndex, time);
2953
+ results.push({ frameIndex, encodeResult });
2954
+ }
2842
2955
  }
2843
2956
  }
2844
2957
  // Task B: retain the last encode result so a following static frame can reuse it.
@@ -3137,8 +3250,46 @@ export function percentileOf(samples, p) {
3137
3250
  const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor(p * sorted.length)));
3138
3251
  return Math.round(sorted[idx] ?? 0);
3139
3252
  }
3253
+ /**
3254
+ * Fraction of captured frames above which a fast-capture render is treated as
3255
+ * "drawElement effectively didn't engage" rather than "recovered a handful of
3256
+ * edge-case frames" (see the cross-PR-seam warning in
3257
+ * {@link getCapturePerfSummary}). Not currently a hard gate — see that
3258
+ * function's comment for why — just the threshold for the loud diagnostic.
3259
+ */
3260
+ const DE_FALLBACK_RATIO_WARN_THRESHOLD = 0.5;
3140
3261
  export function getCapturePerfSummary(session) {
3141
3262
  const frames = Math.max(1, session.capturePerf.frames);
3263
+ const ncprFallbacks = session.deNcprFallbacks ?? 0;
3264
+ // Cross-PR seam (#3423 per-frame screenshot fallback vs #3429 artifact
3265
+ // validation): #3429's artifact validation only checks that the render
3266
+ // produced the right frame COUNT and duration — it has no visibility into
3267
+ // HOW each frame was captured. If a composition is so incompatible with
3268
+ // drawElement that most/all frames take the per-frame screenshot fallback
3269
+ // added here, the render still reports "complete" with a correct frame
3270
+ // count, even though drawElement effectively never engaged for it. That's
3271
+ // not itself a correctness bug — screenshot capture is the platform's
3272
+ // normal, well-tested baseline, so the SHIPPED PIXELS are fine — but a
3273
+ // near-100% fallback ratio is a strong signal that fast-capture silently
3274
+ // failed to engage for the whole render (e.g. a persistent canvas-injection
3275
+ // problem) rather than recovering a handful of expected edge-case frames,
3276
+ // and today nothing surfaces that distinction to telemetry or to a human.
3277
+ //
3278
+ // Deliberately NOT a circuit breaker: aborting/failing the render here
3279
+ // would make a render that reliably succeeds via the well-tested screenshot
3280
+ // path fail instead, which is a worse outcome than a slow-but-correct
3281
+ // render. Whether artifact validation (or this session) should eventually
3282
+ // gate on the ratio — and where that decision belongs — is tracked as an
3283
+ // explicit follow-up: https://github.com/heygen-com/hyperframes/issues/3482
3284
+ // ("Fast-capture: fallback-ratio guard for #3423 x #3429 seam"), rather
3285
+ // than decided unilaterally in this review-response commit.
3286
+ if (frames > 0 && ncprFallbacks / frames > DE_FALLBACK_RATIO_WARN_THRESHOLD) {
3287
+ const pct = Math.round((ncprFallbacks / frames) * 100);
3288
+ console.warn(`[engine] fast capture: ${ncprFallbacks}/${frames} frame(s) (${pct}%) fell back to ` +
3289
+ `screenshot capture (canvas-not-initialized / no-cached-paint-record) — ` +
3290
+ `drawElement likely failed to engage for this render rather than recovering a few ` +
3291
+ `edge-case frames; see fast-capture-limitations.md.`);
3292
+ }
3142
3293
  return {
3143
3294
  frames: session.capturePerf.frames,
3144
3295
  avgTotalMs: Math.round(session.capturePerf.totalMs / frames),
@@ -3177,7 +3328,7 @@ export function getCapturePerfSummary(session) {
3177
3328
  deVerifyArmed: session.deVerifyFrames?.size ?? 0,
3178
3329
  deVerifyInitMs: session.deVerifyInitMs ?? 0,
3179
3330
  deBoundaryFrames: session.clipBoundaryFrames?.size ?? 0,
3180
- deNcprFallbacks: session.deNcprFallbacks ?? 0,
3331
+ deNcprFallbacks: ncprFallbacks,
3181
3332
  };
3182
3333
  }
3183
3334
  //# sourceMappingURL=frameCapture.js.map