@hyperframes/engine 0.7.42 → 0.7.44

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.
@@ -270,6 +270,32 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
270
270
  await armStaticDedup(session, page, logInitPhase);
271
271
  }
272
272
  }
273
+ // Capability gate: `canvas.drawElementImage` is an unlaunched Blink feature
274
+ // that only exists on recent Dev/Canary Chrome builds (~151+); it is absent
275
+ // from Stable and from most pinned/system Chrome installs. The
276
+ // `--enable-features=CanvasDrawElement` flag no-ops silently on a build that
277
+ // doesn't implement it, so without this probe the first drawElementImage()
278
+ // call throws `TypeError: ... is not a function` deep inside the capture
279
+ // loop and takes the whole render down instead of falling back (HF#2060).
280
+ // Cheap (no paint-wait) and must run before any other drawElement work.
281
+ // Not gated by forceDE (HF_FORCE_DRAWELEMENT, an R&D knob that bypasses the
282
+ // quality gates below to measure raw damage) — there's no "forced but
283
+ // degraded" mode for a method that doesn't exist, only a crash, so this
284
+ // always routes to the fallback instead.
285
+ const supportsDrawElement = await page.evaluate(() => {
286
+ const c = document.createElement("canvas");
287
+ const ctx = c.getContext("2d");
288
+ return (typeof ctx?.drawElementImage === "function");
289
+ });
290
+ if (!supportsDrawElement) {
291
+ session.deGateReason = "unsupported_chrome";
292
+ console.log(`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
293
+ "this Chrome build does not implement canvas.drawElementImage (Dev/Canary-only " +
294
+ "feature, ~151+); run `hyperframes browser ensure --force` to fetch a supported " +
295
+ "build, or set HYPERFRAMES_BROWSER_PATH to one.");
296
+ await routeToFallback();
297
+ return;
298
+ }
273
299
  // SwiftShader gate: drawElement's only advantage is skipping the GPU→CPU
274
300
  // screenshot-readback IPC. On a software rasterizer (Docker/CI, no GPU) both
275
301
  // paths block on identical software raster, so drawElement is parity-or-slower
@@ -600,6 +626,7 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
600
626
  onBeforeCapture,
601
627
  isInitialized: false,
602
628
  browserConsoleBuffer: [],
629
+ scriptLoadFailures: [],
603
630
  capturePerf: {
604
631
  frames: 0,
605
632
  seekMs: 0,
@@ -663,16 +690,6 @@ export function formatConsoleDiagnostic(type, text, locationUrl) {
663
690
  : "[Browser]";
664
691
  return { text: `${prefix} ${text}`, suppressHostLog: false };
665
692
  }
666
- async function pollPageExpression(page, expression, timeoutMs, intervalMs = 100) {
667
- const deadline = Date.now() + timeoutMs;
668
- while (Date.now() < deadline) {
669
- const ready = Boolean(await page.evaluate(expression));
670
- if (ready)
671
- return true;
672
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
673
- }
674
- return Boolean(await page.evaluate(expression));
675
- }
676
693
  const HF_READY_DIAGNOSTIC_EXPR = `(function() {
677
694
  var hf = window.__hf;
678
695
  var player = window.__player;
@@ -773,7 +790,14 @@ async function pollHfReady(page, timeoutMs, intervalMs = 100) {
773
790
  ` State: __hf=${diag.hasHf}, seek=${diag.hasSeek}, player=${diag.hasPlayer}, ` +
774
791
  `renderReady=${diag.renderReady}, duration=${diag.duration}`);
775
792
  }
776
- async function pollSubCompositionTimelines(page, timeoutMs, intervalMs = 150) {
793
+ export async function pollSubCompositionTimelines(page, timeoutMs, intervalMs = 150,
794
+ // Fail-fast hook: when a SCRIPT resource failed to load (404 / request
795
+ // failure), the timeline registration it carried can never arrive — the
796
+ // full-timeout wait buys nothing (measured: a 705-render spike at the 45s
797
+ // setup bucket in 30 days of wild local renders, ~1% of renders, each also
798
+ // shipping silently-broken animations). Once failures are present the poll
799
+ // is cut to `scriptFailureGraceMs` from its start.
800
+ getScriptLoadFailures, scriptFailureGraceMs = 2_000) {
777
801
  // Hosts may opt out of the timeline wait with `data-no-timeline` —
778
802
  // compositions driven purely by CSS animations / rAF (the render-compat
779
803
  // contract) never register window.__timelines[id], and without the opt-out
@@ -790,7 +814,28 @@ async function pollSubCompositionTimelines(page, timeoutMs, intervalMs = 150) {
790
814
  }
791
815
  return true;
792
816
  })()`;
793
- const ready = await pollPageExpression(page, expression, timeoutMs, intervalMs);
817
+ const start = Date.now();
818
+ const deadline = start + timeoutMs;
819
+ let ready = false;
820
+ let scriptFailureBail = false;
821
+ for (;;) {
822
+ ready = Boolean(await page.evaluate(expression));
823
+ if (ready)
824
+ break;
825
+ const now = Date.now();
826
+ if (now >= deadline)
827
+ break;
828
+ const failures = getScriptLoadFailures?.() ?? [];
829
+ if (failures.length > 0 && now - start >= scriptFailureGraceMs) {
830
+ scriptFailureBail = true;
831
+ console.warn(`[FrameCapture] Sub-composition timeline wait cut short after ${now - start}ms: ` +
832
+ `script resource(s) failed to load (${failures.join(", ")}) — ` +
833
+ `the timeline registration they carry can never arrive. ` +
834
+ `Fix the script reference; the render proceeds without those animations.`);
835
+ break;
836
+ }
837
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
838
+ }
794
839
  // Always force a timeline rebind once sub-composition timelines are
795
840
  // confirmed present. The previous implementation only called rebind
796
841
  // when the timeline count grew during the poll, which missed the case
@@ -804,23 +849,32 @@ async function pollSubCompositionTimelines(page, timeoutMs, intervalMs = 150) {
804
849
  window.__hfForceTimelineRebind();
805
850
  }
806
851
  })()`);
852
+ return "ready";
807
853
  }
808
- if (!ready) {
809
- const missing = await page.evaluate(`(function() {
810
- var hosts = document.querySelectorAll("[data-composition-id]");
811
- var timelines = window.__timelines || {};
812
- var m = [];
813
- for (var i = 0; i < hosts.length; i++) {
814
- if (hosts[i].hasAttribute("data-no-timeline")) continue;
815
- var id = hosts[i].getAttribute("data-composition-id");
816
- if (id && !timelines[id]) m.push(id);
817
- }
818
- return m.join(", ");
819
- })()`);
854
+ // Enumerate the still-unregistered composition ids regardless of bail
855
+ // reason a script-failure bail used to skip this entirely, so a render
856
+ // with multiple sub-compositions only named the failed script URL(s), not
857
+ // which composition(s) it was still waiting on (review).
858
+ const missing = await page.evaluate(`(function() {
859
+ var hosts = document.querySelectorAll("[data-composition-id]");
860
+ var timelines = window.__timelines || {};
861
+ var m = [];
862
+ for (var i = 0; i < hosts.length; i++) {
863
+ if (hosts[i].hasAttribute("data-no-timeline")) continue;
864
+ var id = hosts[i].getAttribute("data-composition-id");
865
+ if (id && !timelines[id]) m.push(id);
866
+ }
867
+ return m.join(", ");
868
+ })()`);
869
+ if (scriptFailureBail) {
870
+ console.warn(`[FrameCapture] Composition(s) still waiting on the failed script: ${missing}.`);
871
+ }
872
+ else {
820
873
  console.warn(`[FrameCapture] Sub-composition timelines not registered after ${timeoutMs}ms: ${missing}. ` +
821
874
  `Compositions that load data asynchronously (e.g. fetch) must register window.__timelines[id] after setup completes. ` +
822
875
  `Compositions intentionally driven without GSAP timelines (CSS animations / rAF) can mark the host with data-no-timeline to skip this wait.`);
823
876
  }
877
+ return scriptFailureBail ? "script_failure" : "timeout";
824
878
  }
825
879
  async function pollVideosReady(page, skipIds, timeoutMs, intervalMs = 100) {
826
880
  const check = async () => {
@@ -965,6 +1019,15 @@ async function waitForOptionalTailwindReady(page, timeoutMs) {
965
1019
  throw new Error(`[FrameCapture] window.__tailwindReady not resolved after ${timeoutMs}ms. Tailwind browser runtime must finish before frame capture starts.`);
966
1020
  }
967
1021
  }
1022
+ // A 4xx `response` and a `requestfailed` can both fire for the same script
1023
+ // (e.g. a `requestfailed` following the 4xx), and repeated <script> tags for
1024
+ // the same URL duplicate it further — dedupe so the fail-fast warning names
1025
+ // each failed URL once.
1026
+ function recordScriptLoadFailure(session, url) {
1027
+ if (!session.scriptLoadFailures.includes(url)) {
1028
+ session.scriptLoadFailures.push(url);
1029
+ }
1030
+ }
968
1031
  // fallow-ignore-next-line unit-size
969
1032
  export async function initializeSession(session) {
970
1033
  const { page, serverUrl } = session;
@@ -990,6 +1053,9 @@ export async function initializeSession(session) {
990
1053
  appendBrowserDiagnostic(session, text);
991
1054
  });
992
1055
  page.on("requestfailed", (request) => {
1056
+ if (request.resourceType() === "script") {
1057
+ recordScriptLoadFailure(session, request.url());
1058
+ }
993
1059
  appendBrowserDiagnostic(session, formatRequestFailureDiagnostic({
994
1060
  method: request.method(),
995
1061
  resourceType: request.resourceType(),
@@ -1002,6 +1068,9 @@ export async function initializeSession(session) {
1002
1068
  if (status < 400)
1003
1069
  return;
1004
1070
  const request = response.request();
1071
+ if (request.resourceType() === "script") {
1072
+ recordScriptLoadFailure(session, response.url());
1073
+ }
1005
1074
  appendBrowserDiagnostic(session, formatHttpErrorDiagnostic({
1006
1075
  method: request.method(),
1007
1076
  resourceType: request.resourceType(),
@@ -1051,8 +1120,8 @@ export async function initializeSession(session) {
1051
1120
  const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
1052
1121
  await pollHfReady(page, pageReadyTimeout);
1053
1122
  logInitPhase("pollHfReady complete");
1054
- await pollSubCompositionTimelines(page, pageReadyTimeout);
1055
- logInitPhase("pollSubCompositionTimelines complete");
1123
+ session.subTimelineWaitOutcome = await pollSubCompositionTimelines(page, pageReadyTimeout, undefined, () => session.scriptLoadFailures);
1124
+ logInitPhase(`pollSubCompositionTimelines complete (${session.subTimelineWaitOutcome})`);
1056
1125
  await applyVideoMetadataHints(page, session.options.videoMetadataHints);
1057
1126
  logInitPhase("applyVideoMetadataHints complete");
1058
1127
  // Run independent readiness checks in parallel — videos, images, fonts,
@@ -1169,8 +1238,8 @@ export async function initializeSession(session) {
1169
1238
  warmupState.running = false;
1170
1239
  throw err;
1171
1240
  }
1172
- await pollSubCompositionTimelines(page, pageReadyTimeout);
1173
- logInitPhase("pollSubCompositionTimelines complete");
1241
+ session.subTimelineWaitOutcome = await pollSubCompositionTimelines(page, pageReadyTimeout, undefined, () => session.scriptLoadFailures);
1242
+ logInitPhase(`pollSubCompositionTimelines complete (${session.subTimelineWaitOutcome})`);
1174
1243
  await applyVideoMetadataHints(page, session.options.videoMetadataHints);
1175
1244
  logInitPhase("applyVideoMetadataHints complete");
1176
1245
  // Run independent readiness checks in parallel — videos, images, fonts,
@@ -1561,13 +1630,16 @@ export async function verifyStaticFramesSafe(session, page, staticFrames, fps, s
1561
1630
  else
1562
1631
  runs.push({ a: f, b: f });
1563
1632
  }
1564
- const seekCapture = async (frameIdx) => {
1633
+ const seekToFrame = async (frameIdx) => {
1565
1634
  const t = quantizeTimeToFrame(frameIdx / fps, fps);
1566
1635
  await page.evaluate((tt) => {
1567
1636
  const hf = window.__hf;
1568
1637
  if (hf && typeof hf.seek === "function")
1569
- hf.seek(tt);
1638
+ hf.seek(tt, { suppressEvents: true });
1570
1639
  }, t);
1640
+ };
1641
+ const seekCapture = async (frameIdx) => {
1642
+ await seekToFrame(frameIdx);
1571
1643
  return pageScreenshotCapture(page, session.options);
1572
1644
  };
1573
1645
  // Verify EVERY run in order (no longest-first truncation that would leave runs armed
@@ -1584,26 +1656,31 @@ export async function verifyStaticFramesSafe(session, page, staticFrames, fps, s
1584
1656
  // thorough checking. `frames.length` approximates total interior checks; a 3x
1585
1657
  // margin absorbs per-run anchor overhead and the 3-point floor on short runs.
1586
1658
  const hardCap = Math.max(sampleCount * 8, 400, Math.ceil(frames.length / STATIC_VERIFY_REFERENCE_STRIDE) * 3 + runs.length);
1587
- let spent = 0;
1588
- for (const { a, b } of runs) {
1589
- const anchor = a - 1;
1590
- if (anchor < 0)
1591
- continue;
1592
- const anchorBuf = await seekCapture(anchor);
1593
- spent++;
1594
- for (const f of computeStaticVerificationPoints(a, b, sampleCount)) {
1595
- const cur = await seekCapture(f);
1659
+ try {
1660
+ let spent = 0;
1661
+ for (const { a, b } of runs) {
1662
+ const anchor = a - 1;
1663
+ if (anchor < 0)
1664
+ continue;
1665
+ const anchorBuf = await seekCapture(anchor);
1596
1666
  spent++;
1597
- if (!anchorBuf.equals(cur))
1598
- return { badFrame: f, budgetExhausted: false };
1667
+ for (const f of computeStaticVerificationPoints(a, b, sampleCount)) {
1668
+ const cur = await seekCapture(f);
1669
+ spent++;
1670
+ if (!anchorBuf.equals(cur))
1671
+ return { badFrame: f, budgetExhausted: false };
1672
+ }
1673
+ // Budget exhausted → can't fully verify → disarm, distinct from real drift so a
1674
+ // `verification_budget` spike in telemetry reads as "this composition has a lot
1675
+ // of static material to verify," not "compositions are non-static."
1676
+ if (spent > hardCap)
1677
+ return { badFrame: a, budgetExhausted: true };
1599
1678
  }
1600
- // Budget exhausted → can't fully verify → disarm, distinct from real drift so a
1601
- // `verification_budget` spike in telemetry reads as "this composition has a lot
1602
- // of static material to verify," not "compositions are non-static."
1603
- if (spent > hardCap)
1604
- return { badFrame: a, budgetExhausted: true };
1679
+ return null;
1680
+ }
1681
+ finally {
1682
+ await seekToFrame(0).catch(() => { });
1605
1683
  }
1606
- return null;
1607
1684
  }
1608
1685
  /**
1609
1686
  * Arm static-frame dedup for this render (default-on; opt out with HF_STATIC_DEDUP=false).
@@ -1960,9 +2037,28 @@ export async function captureFrameToBufferPipelined(session, frameIndex, time) {
1960
2037
  // and skip the seek + drawElement + encode entirely. Same predicate as the serial
1961
2038
  // path; clip-cut frames are excluded from staticFrames so they always capture.
1962
2039
  if (session.staticFrames?.has(frameIndex) && session.lastEncodeResult) {
1963
- session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
1964
- session.capturePerf.frames += 1;
1965
- return { encodeResult: session.lastEncodeResult, captureTimeMs: Date.now() - startTime };
2040
+ // Reuse is valid only when EVERY frame in (lastEncodeResultFrame, i] is
2041
+ // predicted-static — then all of them (and the reused buffer) share the
2042
+ // same pixels. Sequential capture reduces to has(i) (gap = {i}); the
2043
+ // interleaved parallel stride makes the gap N frames wide.
2044
+ const lastIdx = session.lastEncodeResultFrame ?? frameIndex - 1;
2045
+ let gapStatic = true;
2046
+ for (let j = lastIdx + 1; j <= frameIndex; j++) {
2047
+ if (!session.staticFrames.has(j)) {
2048
+ gapStatic = false;
2049
+ break;
2050
+ }
2051
+ }
2052
+ if (gapStatic) {
2053
+ session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
2054
+ session.capturePerf.frames += 1;
2055
+ // Advance the watermark on reuse too, not just on real captures — the
2056
+ // gap-check above starts from lastEncodeResultFrame, so leaving it
2057
+ // pinned to the last REAL capture makes every consecutive reuse rescan
2058
+ // an ever-widening window instead of just the one new frame (review).
2059
+ session.lastEncodeResultFrame = frameIndex;
2060
+ return { encodeResult: session.lastEncodeResult, captureTimeMs: Date.now() - startTime };
2061
+ }
1966
2062
  }
1967
2063
  try {
1968
2064
  const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(session, frameIndex, time);
@@ -1989,8 +2085,10 @@ export async function captureFrameToBufferPipelined(session, frameIndex, time) {
1989
2085
  session.capturePerf.frameMs.push(boundaryMs);
1990
2086
  }
1991
2087
  const boundaryResult = Promise.resolve(buffer);
1992
- if (session.staticFrames)
2088
+ if (session.staticFrames) {
1993
2089
  session.lastEncodeResult = boundaryResult;
2090
+ session.lastEncodeResultFrame = frameIndex;
2091
+ }
1994
2092
  return { encodeResult: boundaryResult, captureTimeMs: Date.now() - startTime };
1995
2093
  }
1996
2094
  // Worker-encode is gated to the macOS GPU path (beginFrameTimeTicks === 0,
@@ -2007,8 +2105,10 @@ export async function captureFrameToBufferPipelined(session, frameIndex, time) {
2007
2105
  session.capturePerf.totalMs += captureTimeMs;
2008
2106
  session.capturePerf.frameMs.push(captureTimeMs);
2009
2107
  // Task B: retain this encode result so a following static frame can reuse it.
2010
- if (session.staticFrames)
2108
+ if (session.staticFrames) {
2011
2109
  session.lastEncodeResult = encodeResult;
2110
+ session.lastEncodeResultFrame = frameIndex;
2111
+ }
2012
2112
  return { encodeResult, captureTimeMs };
2013
2113
  }
2014
2114
  catch (captureError) {
@@ -2308,7 +2408,7 @@ async function captureDeVerificationFrames(session, page, logInitPhase) {
2308
2408
  await page.evaluate((tt) => {
2309
2409
  const hf = window.__hf;
2310
2410
  if (hf && typeof hf.seek === "function")
2311
- hf.seek(tt);
2411
+ hf.seek(tt, { suppressEvents: true });
2312
2412
  }, t);
2313
2413
  };
2314
2414
  await seekTo(quantizeTimeToFrame(0, fps));
@@ -2364,6 +2464,7 @@ export function getCapturePerfSummary(session) {
2364
2464
  avgBeforeCaptureMs: Math.round(session.capturePerf.beforeCaptureMs / frames),
2365
2465
  avgScreenshotMs: Math.round(session.capturePerf.screenshotMs / frames),
2366
2466
  p50TotalMs: medianOf(session.capturePerf.frameMs),
2467
+ subTimelineWaitOutcome: session.subTimelineWaitOutcome,
2367
2468
  staticDedupReused: session.staticDedupCount ?? 0,
2368
2469
  staticDedupEnabled: session.staticDedupEnabled ?? false,
2369
2470
  // armed ⟺ a non-empty static set survived verification; predicted === its size.