@hyperframes/engine 0.7.37 → 0.7.39
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.
- package/dist/config.d.ts +25 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +43 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/services/browserManager.d.ts +1 -1
- package/dist/services/browserManager.d.ts.map +1 -1
- package/dist/services/browserManager.js.map +1 -1
- package/dist/services/drawElementService.d.ts +149 -0
- package/dist/services/drawElementService.d.ts.map +1 -0
- package/dist/services/drawElementService.js +957 -0
- package/dist/services/drawElementService.js.map +1 -0
- package/dist/services/frameCapture.d.ts +131 -0
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +862 -31
- package/dist/services/frameCapture.js.map +1 -1
- package/dist/services/screenshotService.d.ts +21 -4
- package/dist/services/screenshotService.d.ts.map +1 -1
- package/dist/services/screenshotService.js +81 -11
- package/dist/services/screenshotService.js.map +1 -1
- package/dist/services/threeDProjection.d.ts +80 -0
- package/dist/services/threeDProjection.d.ts.map +1 -0
- package/dist/services/threeDProjection.js +980 -0
- package/dist/services/threeDProjection.js.map +1 -0
- package/dist/services/videoFrameInjector.d.ts.map +1 -1
- package/dist/services/videoFrameInjector.js +29 -22
- package/dist/services/videoFrameInjector.js.map +1 -1
- package/dist/types.d.ts +34 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// fallow-ignore-file complexity
|
|
1
|
+
// fallow-ignore-file complexity code-duplication
|
|
2
2
|
/**
|
|
3
3
|
* Frame Capture Service
|
|
4
4
|
*
|
|
@@ -13,7 +13,36 @@ import { quantizeTimeToFrame, fpsToNumber } from "@hyperframes/core";
|
|
|
13
13
|
// ── Extracted modules ───────────────────────────────────────────────────────
|
|
14
14
|
import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, resolveHeadlessShellPath, } from "./browserManager.js";
|
|
15
15
|
import { beginFrameCapture, getCdpSession, pageScreenshotCapture, initTransparentBackground, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
|
|
16
|
+
import { detectSwiftShader, injectDrawElementCanvas, captureDrawElementFrame, resolveDrawElementCaptureMode, instrumentAcceleratedCanvases, initDrawElementWorkerEncode, cleanupDrawElementWorkerEncode, produceDrawElementFrame, produceDrawElementFrameBatch, } from "./drawElementService.js";
|
|
17
|
+
import { initThreeDProjection, detectCssEffectRisk } from "./threeDProjection.js";
|
|
16
18
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
19
|
+
/**
|
|
20
|
+
* drawElement self-verification failure — a captured DE frame diverged from its
|
|
21
|
+
* pre-injection screenshot ground truth (or a blank frame survived a retry).
|
|
22
|
+
* The orchestrator catches this and re-renders the whole job with
|
|
23
|
+
* forceScreenshot. Discriminant-based guard (not instanceof) so it survives
|
|
24
|
+
* duplicated module instances across package boundaries.
|
|
25
|
+
*/
|
|
26
|
+
export class DrawElementVerificationError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "DrawElementVerificationError";
|
|
30
|
+
// Discriminant property, assigned dynamically: isDrawElementVerificationError
|
|
31
|
+
// reads it structurally so detection survives duplicated module instances
|
|
32
|
+
// across package boundaries (where instanceof fails).
|
|
33
|
+
this.deVerificationFailure = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function isDrawElementVerificationError(err) {
|
|
37
|
+
// Walk the cause chain — the producer wraps capture errors (CaptureStageError).
|
|
38
|
+
let e = err;
|
|
39
|
+
for (let depth = 0; depth < 5 && typeof e === "object" && e !== null; depth++) {
|
|
40
|
+
if (e.deVerificationFailure === true)
|
|
41
|
+
return true;
|
|
42
|
+
e = e.cause;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
17
46
|
// Circular buffer for browser console messages dumped on render failure diagnostics.
|
|
18
47
|
// Complex compositions produce 100+ messages; 50 was too small to capture relevant errors.
|
|
19
48
|
const BROWSER_CONSOLE_BUFFER_SIZE = 200;
|
|
@@ -167,6 +196,274 @@ async function waitForCloseWithTimeout(promise) {
|
|
|
167
196
|
clearTimeout(timer);
|
|
168
197
|
return !timedOut;
|
|
169
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Post-readiness capture-surface init, shared by the screenshot and BeginFrame
|
|
201
|
+
* init paths (called after the page is fully ready). When `useDrawElement` is
|
|
202
|
+
* set, detect SwiftShader and route: transparent+SwiftShader falls back to
|
|
203
|
+
* screenshot capture (the drawElement transparent path is broken on SwiftShader),
|
|
204
|
+
* everything else injects the drawElement canvas and switches to "drawelement"
|
|
205
|
+
* mode. Otherwise, for PNG output, force a transparent page background so the
|
|
206
|
+
* screenshots carry a real alpha channel (Chrome resets the override on every
|
|
207
|
+
* navigation, so this must run after page load).
|
|
208
|
+
*
|
|
209
|
+
* drawElement is also skipped when supersampling (deviceScaleFactor > 1):
|
|
210
|
+
* `drawElementImage` reads the canvas at CSS pixels and has no equivalent of
|
|
211
|
+
* `Page.captureScreenshot`'s clip+scale, so it would silently capture at 1x and
|
|
212
|
+
* drop the requested supersample. Such renders fall through to the screenshot
|
|
213
|
+
* path (preMode already forces "screenshot" for DPR > 1).
|
|
214
|
+
*/
|
|
215
|
+
async function initDrawElementOrTransparentBackground(session, page, logInitPhase) {
|
|
216
|
+
const supersampling = (session.options.deviceScaleFactor ?? 1) > 1;
|
|
217
|
+
// forceScreenshot is an explicit routing decision made upstream (render-mode
|
|
218
|
+
// compat hints like raw requestAnimationFrame, alpha formats, low-memory) —
|
|
219
|
+
// drawElement must not override it. Concretely: an rAF-compat comp on
|
|
220
|
+
// SwiftShader gets a screenshot-launched (free-running) browser, where
|
|
221
|
+
// drawElement runs in paint-event-sync mode; SwiftShader never refreshes a
|
|
222
|
+
// 2d canvas bitmap inside a cached paint record there, so every canvas
|
|
223
|
+
// captures frozen-blank (raf-ball rendered fully black). On a GPU the same
|
|
224
|
+
// path happens to work, but the hint asked for screenshot — honor it.
|
|
225
|
+
const forceScreenshot = session.config?.forceScreenshot ?? false;
|
|
226
|
+
// DIAGNOSTIC ONLY — HF_FORCE_DRAWELEMENT=1 forces the drawElement path,
|
|
227
|
+
// bypassing every compile/init gate AND the compatibility hints (it overrides
|
|
228
|
+
// forceScreenshot). Exists for upstream-Chromium repro work (isolating gate
|
|
229
|
+
// behavior from drawElementImage behavior, e.g. the crbug 521861819 149-vs-151
|
|
230
|
+
// comparison) and for R&D on gated effect classes. Renders under this flag may
|
|
231
|
+
// be DAMAGED by design — the gates it skips exist because measured damage
|
|
232
|
+
// (blur/backdrop ~18-49dB, 3D backface, SwiftShader sub-layer drops) is real.
|
|
233
|
+
// Never set it in production; it is intentionally not documented in user-facing
|
|
234
|
+
// help, and the safety-net blank guard also stands down under it so diagnostic
|
|
235
|
+
// frames arrive unmodified.
|
|
236
|
+
const forceDE = process.env.HF_FORCE_DRAWELEMENT === "1";
|
|
237
|
+
const useDrawElement = ((session.config?.useDrawElement ?? false) || forceDE) &&
|
|
238
|
+
!supersampling &&
|
|
239
|
+
(!forceScreenshot || forceDE);
|
|
240
|
+
if ((session.config?.useDrawElement ?? false) && supersampling) {
|
|
241
|
+
session.deGateReason = "supersampling";
|
|
242
|
+
console.log("[engine] --experimental-fast-capture disabled for this render: drawElementImage " +
|
|
243
|
+
"ignores deviceScaleFactor, so supersampled (DPR > 1) output uses screenshot capture.");
|
|
244
|
+
}
|
|
245
|
+
if ((session.config?.useDrawElement ?? false) && !supersampling && forceScreenshot) {
|
|
246
|
+
session.deGateReason = "render_mode_hint";
|
|
247
|
+
console.log("[engine] fast capture: falling back to screenshot — render-mode compatibility " +
|
|
248
|
+
"hint forced screenshot capture (e.g. raw requestAnimationFrame composition).");
|
|
249
|
+
}
|
|
250
|
+
// Retract the per-page autoAlpha rewrite flag when a runtime gate routes the
|
|
251
|
+
// session to screenshot mode. evaluateOnNewDocument already fired; a follow-up
|
|
252
|
+
// evaluate overrides it in the live page context so hideTransparentAutoAlpha-
|
|
253
|
+
// Targets does not hide elements on the fallback screenshot render
|
|
254
|
+
// (up to 21 dB damage if not retracted, A/B proven 2026-06-12).
|
|
255
|
+
async function retractAutoAlphaFlag() {
|
|
256
|
+
await page.evaluate(() => {
|
|
257
|
+
window.__HF_FAST_CAPTURE_AUTOALPHA__ = false;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
if (useDrawElement) {
|
|
261
|
+
session.isSwiftShader = await detectSwiftShader(page);
|
|
262
|
+
const transparent = session.options.format === "png";
|
|
263
|
+
async function routeToFallback() {
|
|
264
|
+
session.captureMode = session.launchCaptureMode;
|
|
265
|
+
if (transparent) {
|
|
266
|
+
await initTransparentBackground(session.page);
|
|
267
|
+
}
|
|
268
|
+
await retractAutoAlphaFlag();
|
|
269
|
+
// Static-frame dedup is capture-mode-independent (the serial path reuses
|
|
270
|
+
// lastFrameBuffer regardless of how the frame was captured) and lossless
|
|
271
|
+
// (anchor-verified). A comp only reaches THIS fallback with useDrawElement=true
|
|
272
|
+
// AND forceScreenshot=false — i.e. it is deterministic: raw-rAF / iframe /
|
|
273
|
+
// htmlInCanvas comps are forced to screenshot upstream (forceScreenshot=true) and
|
|
274
|
+
// never enter this block, so they never arm dedup. The comps that DO fall back here
|
|
275
|
+
// (blur / backdrop / 3D / at-risk) carry only a compositor
|
|
276
|
+
// EFFECT drawElement can't paint, not nondeterminism, so their predicted-static set
|
|
277
|
+
// is sound. Verification seeks via Page.captureScreenshot, which hangs on a
|
|
278
|
+
// BeginFrame-launched browser — gate on the launch mode (macOS fast-capture launches
|
|
279
|
+
// screenshot-mode; Linux/Docker launches beginframe and is skipped).
|
|
280
|
+
if (session.launchCaptureMode === "screenshot") {
|
|
281
|
+
await armStaticDedup(session, page, logInitPhase);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// SwiftShader gate: drawElement's only advantage is skipping the GPU→CPU
|
|
285
|
+
// screenshot-readback IPC. On a software rasterizer (Docker/CI, no GPU) both
|
|
286
|
+
// paths block on identical software raster, so drawElement is parity-or-slower
|
|
287
|
+
// — route to the platform baseline.
|
|
288
|
+
//
|
|
289
|
+
// Two gates were REMOVED here once Chrome 151 fixed crbug 521861819
|
|
290
|
+
// (drawElementImage dropped compositor-promoted opacity layers mid-fade):
|
|
291
|
+
// - the <video> gate (a proxy for the word-by-word caption opacity pattern,
|
|
292
|
+
// Lim 2), and
|
|
293
|
+
// - the stacked-fade gate (>=2 overlapping viewport-scale opacity-fade targets).
|
|
294
|
+
// Both reproduced on Chrome <=150 (video+caption-fade ~12 dB; stacked fade
|
|
295
|
+
// 24.5 dB) and both render correctly on 151 (verified: video+nested-fade repro
|
|
296
|
+
// PSNR=inf; efb59c5b 24.5→47.4 dB, 0 damaged frames). 151 is the pinned floor.
|
|
297
|
+
const mode = resolveDrawElementCaptureMode(session.isSwiftShader, transparent);
|
|
298
|
+
if (mode === "screenshot") {
|
|
299
|
+
session.deGateReason = "swiftshader";
|
|
300
|
+
// Fall back to the browser's LAUNCH mode, not unconditionally to
|
|
301
|
+
// "screenshot": on a BeginFrame-launched browser (Linux fast capture)
|
|
302
|
+
// Page.captureScreenshot hangs for the full protocol timeout, while
|
|
303
|
+
// beginFrameCapture is the platform's normal baseline path.
|
|
304
|
+
console.log(`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
|
|
305
|
+
"SwiftShader (software rasterizer — no GPU egress to skip, drawElement is " +
|
|
306
|
+
"parity-or-slower; see fast-capture-limitations.md)");
|
|
307
|
+
await routeToFallback();
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
// CSS-effect gate: backdrop-filter samples the compositor backdrop and
|
|
311
|
+
// filter:blur/drop-shadow render differently through the paint-record
|
|
312
|
+
// path — drawElementImage can't reproduce either, producing 18–49 dB
|
|
313
|
+
// damaged frames (community eval). Fall back to the platform baseline.
|
|
314
|
+
// HF_FAST_CAPTURE_CSSFX=true bypasses for R&D.
|
|
315
|
+
if (!forceDE && process.env.HF_FAST_CAPTURE_CSSFX !== "true") {
|
|
316
|
+
const cssFx = await detectCssEffectRisk(page);
|
|
317
|
+
if (cssFx) {
|
|
318
|
+
session.deGateReason = `css_effect:${(cssFx.split(":")[0] ?? "").replace(/[^a-z-]/gi, "")}`;
|
|
319
|
+
console.log(`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
|
|
320
|
+
`${cssFx} detected (drawElementImage cannot reproduce it; see fast-capture-limitations.md)`);
|
|
321
|
+
await routeToFallback();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Lim 7: timeline-interval at-risk predictor. Walk window.__timelines and
|
|
326
|
+
// find tweens animating compositor-incompatible props (opacity, filter,
|
|
327
|
+
// blend-mode, 3D transform, clip-path, mask). drawElementImage drops these
|
|
328
|
+
// mid-animation (crbug 521861819 et al), and whether it drops a given one
|
|
329
|
+
// cannot be told reliably without rendering (jump-seek != sequential render,
|
|
330
|
+
// proven 2026-06-16) nor from geometry (size is a proxy, not the mechanism).
|
|
331
|
+
// The only deterministic + reliable route to 0 damage is to gate on the
|
|
332
|
+
// PRESENCE of any such tween — a pure fact of the declared timeline, the same
|
|
333
|
+
// every run. Conservative by design: comps whose risky tweens drawElement
|
|
334
|
+
// would have handled also fall back, but correctness is never at risk and the
|
|
335
|
+
// fast path stays open for static + plain-2D-transform comps (x/y/scale are
|
|
336
|
+
// NOT in the at-risk set). Tune the gate's frame-fraction floor with
|
|
337
|
+
// HF_FAST_CAPTURE_INTERVAL_FRACTION (default 0 = any at-risk frame gates).
|
|
338
|
+
// Must run BEFORE canvas injection so a whole-comp fallback doesn't leave the
|
|
339
|
+
// drawElement canvas wrapping the composition root. Disable with
|
|
340
|
+
// HF_FAST_CAPTURE_INTERVAL_SS=false.
|
|
341
|
+
if (!forceDE && process.env.HF_FAST_CAPTURE_INTERVAL_SS !== "false") {
|
|
342
|
+
const fps = fpsToNumber(session.options.fps);
|
|
343
|
+
const { frames: atRisk, totalFrames } = await computeTimelineAtRiskFrames(page, fps);
|
|
344
|
+
const atRiskFraction = atRisk.size / totalFrames;
|
|
345
|
+
const fractionFloor = Number(process.env.HF_FAST_CAPTURE_INTERVAL_FRACTION ?? "0");
|
|
346
|
+
logInitPhase(`timeline at-risk predictor: ${atRisk.size}/${totalFrames} frames (${Math.round(atRiskFraction * 100)}%)`);
|
|
347
|
+
if (atRisk.size > 0 && atRiskFraction > fractionFloor) {
|
|
348
|
+
session.deGateReason = "at_risk_timeline";
|
|
349
|
+
console.log(`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
|
|
350
|
+
`${atRisk.size}/${totalFrames} frames animate a compositor-incompatible prop ` +
|
|
351
|
+
`(blend/3D/clip/mask); drawElementImage drops these mid-animation ` +
|
|
352
|
+
`(deterministic timeline gate; see fast-capture-limitations.md Lim 7)`);
|
|
353
|
+
await routeToFallback();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Rewrite CSS 3D contexts into WebGL-projected canvases BEFORE the
|
|
358
|
+
// layoutsubtree canvas goes in (rects are measured in normal layout).
|
|
359
|
+
// drawElementImage cannot paint 3D rendering contexts — see
|
|
360
|
+
// threeDProjection.ts. No-op for compositions without 3D content.
|
|
361
|
+
const threeD = await initThreeDProjection(page);
|
|
362
|
+
if (!forceDE && !threeD.ok) {
|
|
363
|
+
session.deGateReason = "3d_init_failed";
|
|
364
|
+
console.log(`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
|
|
365
|
+
`3D projection init failed (${threeD.reason ?? "unknown"})`);
|
|
366
|
+
await routeToFallback();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (threeD.groups > 0) {
|
|
370
|
+
logInitPhase(`3D projection active: ${threeD.groups} context(s), ${threeD.quads} quad(s), ` +
|
|
371
|
+
`${threeD.selfQuads ?? 0} self-quad el(s), ${threeD.stubTargets ?? 0} stub target(s)`);
|
|
372
|
+
}
|
|
373
|
+
// Task B: arm static-frame dedup here — drawElement is confirmed (all gates
|
|
374
|
+
// passed) and the DOM is still normal (canvas not yet injected, so the
|
|
375
|
+
// verification screenshots are valid). drawElement-path only; see armStaticDedup.
|
|
376
|
+
await armStaticDedup(session, page, logInitPhase);
|
|
377
|
+
// Video comps on injector-less sessions (probe sessions initialize before
|
|
378
|
+
// video extraction) DEFER the rest of the drawElement init: ground-truth
|
|
379
|
+
// screenshots would capture black <video> boxes, and once the canvas is
|
|
380
|
+
// injected they can never be retaken. The capture stage completes the
|
|
381
|
+
// init after prepareCaptureSessionForReuse attaches the injector.
|
|
382
|
+
// Retract the autoAlpha rewrite flag while deferred — if no path ever
|
|
383
|
+
// completes the init (e.g. the disk path takes over), the session
|
|
384
|
+
// captures via screenshot, where the armed flag causes measured damage.
|
|
385
|
+
if (!session.onBeforeCapture && !forceDE) {
|
|
386
|
+
const hasVideos = await page.evaluate(() => document.querySelector("video") !== null);
|
|
387
|
+
if (hasVideos) {
|
|
388
|
+
session.deInitDeferred = true;
|
|
389
|
+
await retractAutoAlphaFlag();
|
|
390
|
+
logInitPhase("drawElement init deferred: video comp awaiting frame injector");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
await finalizeDrawElementInit(session, page, logInitPhase, { transparent, forceDE });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
else if (session.options.format === "png") {
|
|
398
|
+
await initTransparentBackground(session.page);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The tail of drawElement init: self-verification ground truth (pre-injection),
|
|
403
|
+
* canvas injection, capture-mode flip, clip-boundary predictor, worker-encode.
|
|
404
|
+
* Runs inline when the session already has its video-frame injector (or the
|
|
405
|
+
* comp has no videos); runs deferred via completeDeferredDrawElementInit for
|
|
406
|
+
* probe-initialized video comps.
|
|
407
|
+
*/
|
|
408
|
+
async function finalizeDrawElementInit(session, page, logInitPhase, opts) {
|
|
409
|
+
const { transparent, forceDE } = opts;
|
|
410
|
+
// Self-verification ground truth: must run pre-injection — after the canvas
|
|
411
|
+
// wraps the root, a page screenshot shows the canvas's last-drawn bitmap,
|
|
412
|
+
// not the live DOM (see the Lim 6 boundary-screenshot note).
|
|
413
|
+
{
|
|
414
|
+
const verifyStart = Date.now();
|
|
415
|
+
await captureDeVerificationFrames(session, page, logInitPhase);
|
|
416
|
+
session.deVerifyInitMs = Date.now() - verifyStart;
|
|
417
|
+
}
|
|
418
|
+
await injectDrawElementCanvas(page, session.options.width, session.options.height);
|
|
419
|
+
if (transparent) {
|
|
420
|
+
await initTransparentBackground(session.page);
|
|
421
|
+
}
|
|
422
|
+
session.captureMode = "drawelement";
|
|
423
|
+
session.drawElementReady = true;
|
|
424
|
+
logInitPhase("drawElement canvas injected");
|
|
425
|
+
// Lim 6: clip-cut boundary frames — screenshot these instead of drawElement.
|
|
426
|
+
if (process.env.HF_FAST_CAPTURE_BOUNDARY_SS !== "false" && !forceDE) {
|
|
427
|
+
const fps = fpsToNumber(session.options.fps);
|
|
428
|
+
const boundaryFrames = await computeClipBoundaryFrames(page, fps);
|
|
429
|
+
if (boundaryFrames.size > 0) {
|
|
430
|
+
session.clipBoundaryFrames = boundaryFrames;
|
|
431
|
+
logInitPhase(`screenshot fallback: ${boundaryFrames.size} clip-boundary frame(s)`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// Worker-encode pipeline: macOS hardware GPU path only (syncToPaintEvent=true,
|
|
435
|
+
// beginFrameTimeTicks=0). Skip for BeginFrame (Linux/Docker) and transparent
|
|
436
|
+
// (PNG) output — those use the existing synchronous path unchanged.
|
|
437
|
+
const workerEncodeEnabled = (session.config?.enableDrawElementWorkerEncode ?? false) &&
|
|
438
|
+
!transparent &&
|
|
439
|
+
session.beginFrameTimeTicks === 0;
|
|
440
|
+
if (workerEncodeEnabled) {
|
|
441
|
+
await initDrawElementWorkerEncode(page);
|
|
442
|
+
session.workerEncodeEnabled = true;
|
|
443
|
+
logInitPhase("drawElement worker encode initialized");
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Complete a deferred drawElement init (see CaptureSession.deInitDeferred).
|
|
448
|
+
* Call after prepareCaptureSessionForReuse has attached the video-frame
|
|
449
|
+
* injector; no-op when the session is not deferred or still has no injector.
|
|
450
|
+
* Re-asserts the autoAlpha rewrite flag retracted at deferral time.
|
|
451
|
+
*/
|
|
452
|
+
export async function completeDeferredDrawElementInit(session) {
|
|
453
|
+
if (!session.deInitDeferred || !session.onBeforeCapture)
|
|
454
|
+
return;
|
|
455
|
+
const page = session.page;
|
|
456
|
+
await page.evaluate(() => {
|
|
457
|
+
window.__HF_FAST_CAPTURE_AUTOALPHA__ =
|
|
458
|
+
true;
|
|
459
|
+
});
|
|
460
|
+
const logInitPhase = (phase) => console.log(`[initSession:${session.captureMode}] ${phase} (deferred drawElement init)`);
|
|
461
|
+
await finalizeDrawElementInit(session, page, logInitPhase, {
|
|
462
|
+
transparent: session.options.format === "png",
|
|
463
|
+
forceDE: process.env.HF_FORCE_DRAWELEMENT === "1",
|
|
464
|
+
});
|
|
465
|
+
session.deInitDeferred = false;
|
|
466
|
+
}
|
|
170
467
|
// fallow-ignore-next-line unit-size
|
|
171
468
|
export async function createCaptureSession(serverUrl, outputDir, options, onBeforeCapture = null, config) {
|
|
172
469
|
if (!existsSync(outputDir))
|
|
@@ -176,16 +473,38 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
|
|
|
176
473
|
// `options.format === "png"` for transparent capture should also set
|
|
177
474
|
// `config.forceScreenshot = true` (the producer's renderOrchestrator does this
|
|
178
475
|
// automatically when `RenderConfig.format` is an alpha-capable value).
|
|
476
|
+
// Exception: `useDrawElement=true` with png self-manages the screenshot-browser
|
|
477
|
+
// requirement (both the SwiftShader fallback and the GPU transparent path need
|
|
478
|
+
// a screenshot-launched browser — the SwiftShader path calls Page.captureScreenshot
|
|
479
|
+
// which hangs on a BeginFrame browser, and the GPU path doesn't need BeginFrame
|
|
480
|
+
// because the compositor runs freely on a screenshot-launched browser).
|
|
179
481
|
const headlessShell = resolveHeadlessShellPath(config);
|
|
180
482
|
const isLinux = process.platform === "linux";
|
|
181
483
|
const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
|
|
484
|
+
const useDrawElement = config?.useDrawElement ?? false;
|
|
485
|
+
const drawElementTransparent = useDrawElement && options.format === "png";
|
|
486
|
+
// drawElement and page-side shader compositing are mutually incompatible
|
|
487
|
+
// capture strategies: drawElement reads the composition root's paint records
|
|
488
|
+
// directly and skips the prepare→micro-screenshot→resolve protocol (the
|
|
489
|
+
// micro-screenshot would also hang on an opaque/beginframe-launched browser).
|
|
490
|
+
// `resolveConfig` forces page-side compositing off whenever useDrawElement is
|
|
491
|
+
// set, so this only trips for a direct caller that bypassed resolveConfig and
|
|
492
|
+
// passed both flags — warn once and treat page-side as disabled.
|
|
493
|
+
if (useDrawElement &&
|
|
494
|
+
(config?.enablePageSideCompositing ?? DEFAULT_CONFIG.enablePageSideCompositing)) {
|
|
495
|
+
console.warn("[engine] useDrawElement is incompatible with page-side shader compositing — " +
|
|
496
|
+
"ignoring enablePageSideCompositing for this render. Prefer resolveConfig, " +
|
|
497
|
+
"which disables page-side compositing automatically for fast-capture renders.");
|
|
498
|
+
}
|
|
182
499
|
// BeginFrame's screenshot does not honor a viewport `deviceScaleFactor`
|
|
183
500
|
// (the captured surface is sized by the OS window in CSS pixels regardless
|
|
184
501
|
// of `Emulation.setDeviceMetricsOverride`'s DPR). When supersampling we
|
|
185
502
|
// need explicit clip+scale on `Page.captureScreenshot`, so fall back to
|
|
186
503
|
// the screenshot path for any DPR > 1.
|
|
187
504
|
const supersampling = (options.deviceScaleFactor ?? 1) > 1;
|
|
188
|
-
const preMode = headlessShell && isLinux && !forceScreenshot && !supersampling
|
|
505
|
+
const preMode = headlessShell && isLinux && !forceScreenshot && !supersampling && !drawElementTransparent
|
|
506
|
+
? "beginframe"
|
|
507
|
+
: "screenshot";
|
|
189
508
|
const requestedGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode;
|
|
190
509
|
const resolvedGpuMode = await resolveBrowserGpuMode(requestedGpuMode, {
|
|
191
510
|
chromePath: headlessShell ?? undefined,
|
|
@@ -226,6 +545,42 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
|
|
|
226
545
|
w.__name = (fn, _name) => fn;
|
|
227
546
|
}
|
|
228
547
|
});
|
|
548
|
+
// Fast capture: record accelerated canvases (webgl/webgl2/webgpu) and force
|
|
549
|
+
// preserveDrawingBuffer before any page script can create a context — their
|
|
550
|
+
// paint records freeze at the first frame, so captureDrawElementFrame
|
|
551
|
+
// composites their live content via drawImage instead (see
|
|
552
|
+
// instrumentAcceleratedCanvases). Must be registered before navigation.
|
|
553
|
+
if (useDrawElement) {
|
|
554
|
+
await page.evaluateOnNewDocument(instrumentAcceleratedCanvases);
|
|
555
|
+
}
|
|
556
|
+
// Signal the producer's GSAP stub to rewrite `opacity` → `autoAlpha` in tween
|
|
557
|
+
// vars (stacked opacity-0 caption layers break drawElementImage capture).
|
|
558
|
+
//
|
|
559
|
+
// DEFAULT OFF (opt in with HF_FAST_CAPTURE_AUTOALPHA=true). The rewrite is baked
|
|
560
|
+
// at tween-creation (page load) and `retractAutoAlphaFlag` (flag-only) can't
|
|
561
|
+
// un-bake it: GSAP autoAlpha sets visibility:hidden under seek capture and renders
|
|
562
|
+
// ~28 dB below a clean opacity baseline (corpus eval 2026-06-16, e.g.
|
|
563
|
+
// 05f22830/06167790: autoAlpha-off = ∞, autoAlpha-on = 28 dB). The rewrite was a
|
|
564
|
+
// workaround for the stacked-fade opacity-layer drop (crbug 521861819), now fixed
|
|
565
|
+
// in Chrome 151 — so it damages more than it fixes. Re-enable per render only if a
|
|
566
|
+
// drawElement comp shows transparent-layer drop on the pinned 151 floor.
|
|
567
|
+
if (useDrawElement && process.env.HF_FAST_CAPTURE_AUTOALPHA === "true") {
|
|
568
|
+
await page.evaluateOnNewDocument(() => {
|
|
569
|
+
window.__HF_FAST_CAPTURE_AUTOALPHA__ = true;
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
// Re-apply the captured root's own computed opacity to the 2D context:
|
|
573
|
+
// drawElementImage does not reflect post-paint changes to compositor-applied
|
|
574
|
+
// properties on the captured element itself (the root's opacity is applied by
|
|
575
|
+
// its parent at composite time, never baked into its content snapshot), so an
|
|
576
|
+
// animated root fade renders at full opacity. captureDrawElementFrame corrects
|
|
577
|
+
// this by the ratio current/base opacity (no-op for a static root). On by
|
|
578
|
+
// default; disable with HF_FAST_CAPTURE_ROOT_PROPS=false.
|
|
579
|
+
if (useDrawElement && process.env.HF_FAST_CAPTURE_ROOT_PROPS !== "false") {
|
|
580
|
+
await page.evaluateOnNewDocument(() => {
|
|
581
|
+
window.__HF_ROOT_PROPS__ = true;
|
|
582
|
+
});
|
|
583
|
+
}
|
|
229
584
|
// Inject render-time variable overrides before any page script runs, so the
|
|
230
585
|
// runtime helper `getVariables()` returns the merged result on its first
|
|
231
586
|
// call. Pass the JSON string and parse inside the page so we don't require
|
|
@@ -278,8 +633,10 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
|
|
|
278
633
|
beforeCaptureMs: 0,
|
|
279
634
|
screenshotMs: 0,
|
|
280
635
|
totalMs: 0,
|
|
636
|
+
frameMs: [],
|
|
281
637
|
},
|
|
282
638
|
captureMode,
|
|
639
|
+
launchCaptureMode: captureMode,
|
|
283
640
|
beginFrameTimeTicks: 0,
|
|
284
641
|
// Frame interval in ms: 1000 * den / num. For 30/1 → 33.333…, for
|
|
285
642
|
// 30000/1001 (NTSC) → 33.366…. JavaScript number precision is fine at
|
|
@@ -444,11 +801,16 @@ async function pollHfReady(page, timeoutMs, intervalMs = 100) {
|
|
|
444
801
|
`renderReady=${diag.renderReady}, duration=${diag.duration}`);
|
|
445
802
|
}
|
|
446
803
|
async function pollSubCompositionTimelines(page, timeoutMs, intervalMs = 150) {
|
|
804
|
+
// Hosts may opt out of the timeline wait with `data-no-timeline` —
|
|
805
|
+
// compositions driven purely by CSS animations / rAF (the render-compat
|
|
806
|
+
// contract) never register window.__timelines[id], and without the opt-out
|
|
807
|
+
// they stall here for the full playerReadyTimeout (45 s) on every render.
|
|
447
808
|
const expression = `(function() {
|
|
448
809
|
var hosts = document.querySelectorAll("[data-composition-id]");
|
|
449
810
|
if (hosts.length === 0) return true;
|
|
450
811
|
var timelines = window.__timelines || {};
|
|
451
812
|
for (var i = 0; i < hosts.length; i++) {
|
|
813
|
+
if (hosts[i].hasAttribute("data-no-timeline")) continue;
|
|
452
814
|
var id = hosts[i].getAttribute("data-composition-id");
|
|
453
815
|
if (!id) continue;
|
|
454
816
|
if (!timelines[id]) return false;
|
|
@@ -476,13 +838,15 @@ async function pollSubCompositionTimelines(page, timeoutMs, intervalMs = 150) {
|
|
|
476
838
|
var timelines = window.__timelines || {};
|
|
477
839
|
var m = [];
|
|
478
840
|
for (var i = 0; i < hosts.length; i++) {
|
|
841
|
+
if (hosts[i].hasAttribute("data-no-timeline")) continue;
|
|
479
842
|
var id = hosts[i].getAttribute("data-composition-id");
|
|
480
843
|
if (id && !timelines[id]) m.push(id);
|
|
481
844
|
}
|
|
482
845
|
return m.join(", ");
|
|
483
846
|
})()`);
|
|
484
847
|
console.warn(`[FrameCapture] Sub-composition timelines not registered after ${timeoutMs}ms: ${missing}. ` +
|
|
485
|
-
`Compositions that load data asynchronously (e.g. fetch) must register window.__timelines[id] after setup completes
|
|
848
|
+
`Compositions that load data asynchronously (e.g. fetch) must register window.__timelines[id] after setup completes. ` +
|
|
849
|
+
`Compositions intentionally driven without GSAP timelines (CSS animations / rAF) can mark the host with data-no-timeline to skip this wait.`);
|
|
486
850
|
}
|
|
487
851
|
}
|
|
488
852
|
async function pollVideosReady(page, skipIds, timeoutMs, intervalMs = 100) {
|
|
@@ -760,16 +1124,8 @@ export async function initializeSession(session) {
|
|
|
760
1124
|
`Continuing render — affected videos will appear as blank/black frames.`);
|
|
761
1125
|
}
|
|
762
1126
|
await recordSessionInitTelemetry(session, initStart);
|
|
763
|
-
//
|
|
764
|
-
|
|
765
|
-
// navigation (Chrome resets the override on every goto) and AFTER the
|
|
766
|
-
// page is loaded (the injected stylesheet needs a real document.head).
|
|
767
|
-
// The override is overridden by `body { background: ... }` and
|
|
768
|
-
// `#root { background: ... }` rules — the helper handles that with a
|
|
769
|
-
// `[data-composition-id]{background:transparent !important}` injection.
|
|
770
|
-
if (session.options.format === "png") {
|
|
771
|
-
await initTransparentBackground(session.page);
|
|
772
|
-
}
|
|
1127
|
+
// drawElement or transparent-background init — runs after page is fully ready.
|
|
1128
|
+
await initDrawElementOrTransparentBackground(session, page, logInitPhase);
|
|
773
1129
|
await armStaticDedup(session, session.page, logInitPhase);
|
|
774
1130
|
session.isInitialized = true;
|
|
775
1131
|
return;
|
|
@@ -896,15 +1252,15 @@ export async function initializeSession(session) {
|
|
|
896
1252
|
// constant so chunk workers on different hosts compute the same baseline.
|
|
897
1253
|
const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks;
|
|
898
1254
|
session.beginFrameTimeTicks = (baseTickCount + 10) * session.beginFrameIntervalMs;
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1255
|
+
// drawElement or transparent-background init — runs after page is fully ready.
|
|
1256
|
+
// IMPORTANT: must stay after beginFrameTimeTicks is set above. The per-frame
|
|
1257
|
+
// drawelement branch gates its BeginFrame call on `beginFrameTimeTicks > 0`;
|
|
1258
|
+
// if this ran first, ticks would be 0 and the paused compositor would never
|
|
1259
|
+
// advance for opaque drawElement on Linux. (In beginframe-launched mode,
|
|
1260
|
+
// transparent is always false — useDrawElement+png forces preMode="screenshot"
|
|
1261
|
+
// upstream — so the SwiftShader fallback inside the helper is dead-but-harmless
|
|
1262
|
+
// defense-in-depth here.)
|
|
1263
|
+
await initDrawElementOrTransparentBackground(session, page, logInitPhase);
|
|
908
1264
|
await armStaticDedup(session, session.page, logInitPhase);
|
|
909
1265
|
session.isInitialized = true;
|
|
910
1266
|
}
|
|
@@ -965,7 +1321,9 @@ async function prepareFrameForCapture(session, frameIndex, time) {
|
|
|
965
1321
|
// 1. prepare — clone scenes (now containing injected video <img>s)
|
|
966
1322
|
// 2. micro-screenshot — force browser to paint cloned elements
|
|
967
1323
|
// 3. resolve — drawElementImage reads paint records, shader composites
|
|
968
|
-
if (hasPendingComposite &&
|
|
1324
|
+
if (hasPendingComposite &&
|
|
1325
|
+
session.captureMode !== "beginframe" &&
|
|
1326
|
+
session.captureMode !== "drawelement") {
|
|
969
1327
|
await page.evaluate(async () => {
|
|
970
1328
|
const w = window;
|
|
971
1329
|
if (typeof w.__hf_page_composite_prepare === "function") {
|
|
@@ -1249,6 +1607,14 @@ export async function verifyStaticFramesSafe(session, page, staticFrames, fps, s
|
|
|
1249
1607
|
* via HF_STATIC_DEDUP_SAMPLES (default 24).
|
|
1250
1608
|
*/
|
|
1251
1609
|
async function armStaticDedup(session, page, logInitPhase) {
|
|
1610
|
+
// Idempotent: the drawElement init path arms dedup BEFORE canvas injection
|
|
1611
|
+
// (verification screenshots need the un-injected DOM), and initializeSession
|
|
1612
|
+
// calls this again unconditionally afterwards. Once staticFrames is
|
|
1613
|
+
// populated, re-running would overwrite the armed state with
|
|
1614
|
+
// skipReason="capture_mode" (captureMode is "drawelement" by then) —
|
|
1615
|
+
// contradictory telemetry — and re-run the verification seeks. No-op instead.
|
|
1616
|
+
if (session.staticFrames || session.staticDedupSkipReason)
|
|
1617
|
+
return;
|
|
1252
1618
|
// Default ON for everyone; opt out via HF_STATIC_DEDUP in {false,0,off} (resolved into
|
|
1253
1619
|
// EngineConfig.staticFrameDedup by resolveConfig). Verification is the safety net at scale.
|
|
1254
1620
|
// Default-on: only an explicit `staticFrameDedup === false` (resolved from
|
|
@@ -1315,10 +1681,90 @@ async function armStaticDedup(session, page, logInitPhase) {
|
|
|
1315
1681
|
`(${Math.round((stats.staticFrameSet.size / stats.totalFrames) * 100)}%, verified)`);
|
|
1316
1682
|
}
|
|
1317
1683
|
/**
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
1684
|
+
* Walk window.__timelines and collect frame intervals where GSAP tweens animate
|
|
1685
|
+
* compositor-incompatible properties (blend-mode, 3D transforms, clip-path, mask).
|
|
1686
|
+
* drawElement cannot reproduce these effects mid-tween → capture those frames via
|
|
1687
|
+
* screenshot instead. opacity/filter fades were dropped from the set once Chrome 151
|
|
1688
|
+
* fixed crbug 521861819. See docs/fast-capture-limitations.md Lim 7.
|
|
1689
|
+
*
|
|
1690
|
+
* Returns the union of at-risk frame indices (±1 margin around each tween interval)
|
|
1691
|
+
* and totalFrames (for fraction computation by the caller).
|
|
1321
1692
|
*/
|
|
1693
|
+
async function computeTimelineAtRiskFrames(page, fps) {
|
|
1694
|
+
const result = await page.evaluate(() => {
|
|
1695
|
+
// opacity/autoAlpha/filter fades were removed from this set once Chrome 151
|
|
1696
|
+
// fixed crbug 521861819 (drawElementImage dropped promoted opacity layers
|
|
1697
|
+
// mid-fade) — those now render correctly on the drawElement path. backdrop-filter
|
|
1698
|
+
// and filter:blur stay gated by detectCssEffectRisk (architectural single-element
|
|
1699
|
+
// capture limit, not 521861819). What remains here is the per-tween backstop for
|
|
1700
|
+
// effects drawElementImage still cannot reproduce mid-animation: mix-blend-mode,
|
|
1701
|
+
// CSS 3D transforms (crbug 522872457), clip-path, and mask.
|
|
1702
|
+
const AT_RISK_PROPS = new Set([
|
|
1703
|
+
"backdropFilter",
|
|
1704
|
+
"backdrop-filter",
|
|
1705
|
+
"mixBlendMode",
|
|
1706
|
+
"mix-blend-mode",
|
|
1707
|
+
"rotationX",
|
|
1708
|
+
"rotationY",
|
|
1709
|
+
"rotateX",
|
|
1710
|
+
"rotateY",
|
|
1711
|
+
"z",
|
|
1712
|
+
"translateZ",
|
|
1713
|
+
"clipPath",
|
|
1714
|
+
"clip-path",
|
|
1715
|
+
"maskImage",
|
|
1716
|
+
"mask",
|
|
1717
|
+
]);
|
|
1718
|
+
function walkTimeline(tl, offset, out) {
|
|
1719
|
+
if (typeof tl.getChildren !== "function")
|
|
1720
|
+
return;
|
|
1721
|
+
for (const child of tl.getChildren(false, true, true)) {
|
|
1722
|
+
const childStart = offset + (typeof child.startTime === "function" ? child.startTime() : 0);
|
|
1723
|
+
const childDur = typeof child.duration === "function" ? child.duration() : 0;
|
|
1724
|
+
if (typeof child.getChildren === "function") {
|
|
1725
|
+
walkTimeline(child, childStart, out);
|
|
1726
|
+
}
|
|
1727
|
+
else {
|
|
1728
|
+
const vars = child.vars || {};
|
|
1729
|
+
if (Object.keys(vars).some((k) => AT_RISK_PROPS.has(k))) {
|
|
1730
|
+
out.push({ start: childStart, end: childStart + childDur });
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
const w = window;
|
|
1736
|
+
const timelines = w.__timelines || {};
|
|
1737
|
+
const intervals = [];
|
|
1738
|
+
for (const tl of Object.values(timelines)) {
|
|
1739
|
+
if (tl && typeof tl.getChildren === "function") {
|
|
1740
|
+
walkTimeline(tl, 0, intervals);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
const duration = w.__hf?.duration ?? 0;
|
|
1744
|
+
return { intervals, duration };
|
|
1745
|
+
});
|
|
1746
|
+
const { intervals, duration } = result;
|
|
1747
|
+
const frames = new Set();
|
|
1748
|
+
for (const { start, end } of intervals) {
|
|
1749
|
+
const lo = Math.floor(start * fps) - 1;
|
|
1750
|
+
const hi = Math.ceil(end * fps) + 1;
|
|
1751
|
+
for (let f = Math.max(0, lo); f <= hi; f++) {
|
|
1752
|
+
frames.add(f);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
const totalFrames = Math.max(1, Math.ceil(duration * fps));
|
|
1756
|
+
return { frames, totalFrames };
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* True for the drawElement `InvalidStateError: No cached paint record for element`
|
|
1760
|
+
* thrown when a subtree element has no paint record for the current frame (display
|
|
1761
|
+
* toggled / detached / freshly-shown at a clip-cut boundary). Per-frame, not
|
|
1762
|
+
* whole-comp — callers fall back to screenshot for the single frame.
|
|
1763
|
+
*/
|
|
1764
|
+
function isNoCachedPaintRecordError(err) {
|
|
1765
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1766
|
+
return msg.includes("No cached paint record");
|
|
1767
|
+
}
|
|
1322
1768
|
async function captureFrameCore(session, frameIndex, time) {
|
|
1323
1769
|
const { page, options } = session;
|
|
1324
1770
|
const startTime = Date.now();
|
|
@@ -1358,6 +1804,84 @@ async function captureFrameCore(session, frameIndex, time) {
|
|
|
1358
1804
|
session.beginFrameNoDamageCount++;
|
|
1359
1805
|
screenshotBuffer = result.buffer;
|
|
1360
1806
|
}
|
|
1807
|
+
else if (session.captureMode === "drawelement" &&
|
|
1808
|
+
session.clipBoundaryFrames?.has(frameIndex) &&
|
|
1809
|
+
process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true") {
|
|
1810
|
+
// Lim 6 (serial path): proactively screenshotting clip-boundary frames is now
|
|
1811
|
+
// OPT-IN (was default-on). It is net-harmful: drawElement renders most boundary
|
|
1812
|
+
// frames correctly, but Page.captureScreenshot in drawElement mode captures the
|
|
1813
|
+
// injected canvas (unpainted at render start → white; mid-render → ~1-frame
|
|
1814
|
+
// stale), so the "fallback" REPLACES good frames with damaged ones (validated:
|
|
1815
|
+
// 35e8fa9f 462→0 damaged frames, 4001da8e 11→0, when this is off). The two real
|
|
1816
|
+
// boundary failure modes are now caught reactively below — the throw case by
|
|
1817
|
+
// isNoCachedPaintRecordError, the silent-solid-black case by the small-frame
|
|
1818
|
+
// blank-guard (a solid frame is a tiny JPEG) — without touching frames drawElement
|
|
1819
|
+
// handles. Force the old behavior with HF_FAST_CAPTURE_BOUNDARY_SS=true. The worker
|
|
1820
|
+
// path keeps proactive boundary-SS (it has no blank-guard); see
|
|
1821
|
+
// captureFrameToBufferPipelined and docs/fast-capture-limitations.md.
|
|
1822
|
+
screenshotBuffer = await pageScreenshotCapture(page, options);
|
|
1823
|
+
}
|
|
1824
|
+
else if (session.captureMode === "drawelement") {
|
|
1825
|
+
// Advance compositor state via BeginFrame when available (Linux headless-shell);
|
|
1826
|
+
// on macOS the compositor advances naturally without BeginFrame.
|
|
1827
|
+
if (session.beginFrameTimeTicks > 0) {
|
|
1828
|
+
const client = await getCdpSession(page);
|
|
1829
|
+
await client.send("HeadlessExperimental.beginFrame", {
|
|
1830
|
+
frameTimeTicks: session.beginFrameTimeTicks + frameIndex * session.beginFrameIntervalMs,
|
|
1831
|
+
interval: session.beginFrameIntervalMs,
|
|
1832
|
+
noDisplayUpdates: false,
|
|
1833
|
+
// no screenshot param — we capture via canvas
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
try {
|
|
1837
|
+
screenshotBuffer = await captureDrawElementFrame(page, options.width, options.height, options.format ?? "jpeg", options.quality ?? 80,
|
|
1838
|
+
// Paint-event sync only without BeginFrame (macOS / screenshot-launched):
|
|
1839
|
+
// under BeginFrame control the per-frame beginFrame above already painted
|
|
1840
|
+
// a fresh snapshot, and no further paint would arrive during a wait.
|
|
1841
|
+
session.beginFrameTimeTicks === 0);
|
|
1842
|
+
// Silent-blank-drop guard: drawElement occasionally returns a blank/dropped
|
|
1843
|
+
// frame WITHOUT throwing (paint-record miss; the throw case is handled below).
|
|
1844
|
+
// Such a frame's JPEG is anomalously tiny vs the comp's running median (a blank
|
|
1845
|
+
// 1080p frame ~5-9 KB; content frames 50 KB-1 MB). Re-capture via screenshot
|
|
1846
|
+
// (ground truth) — harmless for legitimately simple frames (screenshot matches).
|
|
1847
|
+
// Catches scattered intermittent drops (e.g. 4001da8e: 11 blanks in 9300 frames,
|
|
1848
|
+
// 9.7 dB) that no static gate can see. PNG/transparent excluded (alpha sizing
|
|
1849
|
+
// differs and that path is its own).
|
|
1850
|
+
if ((options.format ?? "jpeg") !== "png" && process.env.HF_FORCE_DRAWELEMENT !== "1") {
|
|
1851
|
+
const sizes = (session.deFrameSizes ??= []);
|
|
1852
|
+
const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
|
|
1853
|
+
const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
|
|
1854
|
+
const floor = Math.max(20000, median * 0.12);
|
|
1855
|
+
if (screenshotBuffer.length < floor) {
|
|
1856
|
+
console.log(`[engine] fast capture: frame ${frameIndex} — drawElement frame anomalously ` +
|
|
1857
|
+
`small (${screenshotBuffer.length}B < ${Math.round(floor)}B, likely a silent ` +
|
|
1858
|
+
`paint-record drop); screenshot fallback (see fast-capture-limitations.md)`);
|
|
1859
|
+
screenshotBuffer = await pageScreenshotCapture(page, options);
|
|
1860
|
+
}
|
|
1861
|
+
else {
|
|
1862
|
+
if (sizes.length >= 60)
|
|
1863
|
+
sizes.shift();
|
|
1864
|
+
sizes.push(screenshotBuffer.length);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
catch (err) {
|
|
1869
|
+
// drawElementImage throws `InvalidStateError: No cached paint record for
|
|
1870
|
+
// element` when an element in the subtree has no paint record this frame
|
|
1871
|
+
// (display toggled / detached / freshly-shown at a clip-cut boundary). This
|
|
1872
|
+
// is a per-frame condition, not a whole-comp one — fall back to screenshot
|
|
1873
|
+
// for THIS frame instead of aborting the render. See fast-capture-limitations.md.
|
|
1874
|
+
if (isNoCachedPaintRecordError(err)) {
|
|
1875
|
+
session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
|
|
1876
|
+
console.log(`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
|
|
1877
|
+
`screenshot fallback for this frame (see fast-capture-limitations.md)`);
|
|
1878
|
+
screenshotBuffer = await pageScreenshotCapture(page, options);
|
|
1879
|
+
}
|
|
1880
|
+
else {
|
|
1881
|
+
throw err;
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1361
1885
|
else {
|
|
1362
1886
|
screenshotBuffer = await pageScreenshotCapture(page, options);
|
|
1363
1887
|
}
|
|
@@ -1368,6 +1892,7 @@ async function captureFrameCore(session, frameIndex, time) {
|
|
|
1368
1892
|
session.capturePerf.beforeCaptureMs += beforeCaptureMs;
|
|
1369
1893
|
session.capturePerf.screenshotMs += screenshotMs;
|
|
1370
1894
|
session.capturePerf.totalMs += captureTimeMs;
|
|
1895
|
+
session.capturePerf.frameMs.push(captureTimeMs);
|
|
1371
1896
|
// Retain this freshly-captured buffer so the following static frames can reuse it.
|
|
1372
1897
|
if (session.staticFrames)
|
|
1373
1898
|
session.lastFrameBuffer = screenshotBuffer;
|
|
@@ -1381,14 +1906,24 @@ async function captureFrameCore(session, frameIndex, time) {
|
|
|
1381
1906
|
}
|
|
1382
1907
|
}
|
|
1383
1908
|
export async function captureFrame(session, frameIndex, time) {
|
|
1384
|
-
const { options, outputDir } = session;
|
|
1385
1909
|
const { buffer, quantizedTime, captureTimeMs } = await captureFrameCore(session, frameIndex, time);
|
|
1386
|
-
const
|
|
1387
|
-
const frameName = `frame_${String(frameIndex).padStart(6, "0")}.${ext}`;
|
|
1388
|
-
const framePath = join(outputDir, frameName);
|
|
1389
|
-
writeFileSync(framePath, buffer);
|
|
1910
|
+
const framePath = writeCapturedFrame(session, frameIndex, buffer);
|
|
1390
1911
|
return { frameIndex, time: quantizedTime, path: framePath, captureTimeMs };
|
|
1391
1912
|
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Write an already-captured frame buffer to the session's output dir using the
|
|
1915
|
+
* canonical `frame_NNNNNN.{jpg,png}` naming. `fileIndex` is the ENCODER-facing
|
|
1916
|
+
* index (0-based within the captured range), which may differ from the absolute
|
|
1917
|
+
* composition frame index used for seeking/boundary lookups. Extracted so the
|
|
1918
|
+
* disk worker-encode pipeline can write a buffer produced by
|
|
1919
|
+
* `captureFrameToBufferPipelined` without duplicating the naming convention.
|
|
1920
|
+
*/
|
|
1921
|
+
export function writeCapturedFrame(session, fileIndex, buffer) {
|
|
1922
|
+
const ext = session.options.format === "png" ? "png" : "jpg";
|
|
1923
|
+
const framePath = join(session.outputDir, `frame_${String(fileIndex).padStart(6, "0")}.${ext}`);
|
|
1924
|
+
writeFileSync(framePath, buffer);
|
|
1925
|
+
return framePath;
|
|
1926
|
+
}
|
|
1392
1927
|
/**
|
|
1393
1928
|
* Capture a frame and return the screenshot as a Buffer instead of writing to disk.
|
|
1394
1929
|
* Used by the streaming encode pipeline to pipe frames directly to FFmpeg stdin.
|
|
@@ -1397,6 +1932,178 @@ export async function captureFrameToBuffer(session, frameIndex, time) {
|
|
|
1397
1932
|
const { buffer, captureTimeMs } = await captureFrameCore(session, frameIndex, time);
|
|
1398
1933
|
return { buffer, captureTimeMs };
|
|
1399
1934
|
}
|
|
1935
|
+
/**
|
|
1936
|
+
* Pipelined drawElement frame capture for the worker-encode path.
|
|
1937
|
+
*
|
|
1938
|
+
* Performs seek prep + paint-wait + drawElementImage + composite +
|
|
1939
|
+
* `createImageBitmap` + transfers the bitmap to the in-page encode worker.
|
|
1940
|
+
* Returns `encodeResult` immediately (before the worker finishes encoding).
|
|
1941
|
+
* The caller overlaps frame N's encode with frame N+1's produce phase.
|
|
1942
|
+
*
|
|
1943
|
+
* Requirements:
|
|
1944
|
+
* - `session.workerEncodeEnabled` must be true (set by initializeSession when
|
|
1945
|
+
* `config.enableDrawElementWorkerEncode` is true and mode resolved to drawelement).
|
|
1946
|
+
* - JPEG format only. PNG falls back to `captureFrameToBuffer`.
|
|
1947
|
+
* - macOS hardware GPU path (syncToPaintEvent=true, beginFrameTimeTicks=0).
|
|
1948
|
+
* BeginFrame (Linux) uses the standard synchronous path unchanged.
|
|
1949
|
+
*/
|
|
1950
|
+
export async function captureFrameToBufferPipelined(session, frameIndex, time) {
|
|
1951
|
+
const { page, options } = session;
|
|
1952
|
+
const startTime = Date.now();
|
|
1953
|
+
// Task B: static-frame dedup (worker path). Reuse the prior frame's encode result
|
|
1954
|
+
// and skip the seek + drawElement + encode entirely. Same predicate as the serial
|
|
1955
|
+
// path; clip-cut frames are excluded from staticFrames so they always capture.
|
|
1956
|
+
if (session.staticFrames?.has(frameIndex) && session.lastEncodeResult) {
|
|
1957
|
+
session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
|
|
1958
|
+
session.capturePerf.frames += 1;
|
|
1959
|
+
return { encodeResult: session.lastEncodeResult, captureTimeMs: Date.now() - startTime };
|
|
1960
|
+
}
|
|
1961
|
+
try {
|
|
1962
|
+
const { quantizedTime, seekMs, beforeCaptureMs } = await prepareFrameForCapture(session, frameIndex, time);
|
|
1963
|
+
void quantizedTime;
|
|
1964
|
+
// Lim 6: clip-cut boundary frame — screenshot ONLY when opt-in, matching the serial
|
|
1965
|
+
// path (captureFrameCore). Proactive boundary screenshots in drawElement mode are
|
|
1966
|
+
// net-HARMFUL: with `<canvas layoutsubtree>` the child composition root is laid out
|
|
1967
|
+
// but not painted to screen — only the canvas 2D bitmap is visible — so
|
|
1968
|
+
// Page.captureScreenshot captures the injected canvas holding the LAST drawElement
|
|
1969
|
+
// frame (stale by ≥1 scene at a hard cut), REPLACING a good frame with a stale one.
|
|
1970
|
+
// Measured on 0531c45f: worker boundary frames showed the previous scene's video.
|
|
1971
|
+
// Default OFF → boundary frames fall through to produceDrawElementFrame, which draws
|
|
1972
|
+
// the CURRENT frame into the canvas. (Force old behavior with
|
|
1973
|
+
// HF_FAST_CAPTURE_BOUNDARY_SS=true.) See captureFrameCore for the serial rationale.
|
|
1974
|
+
if (session.clipBoundaryFrames?.has(frameIndex) &&
|
|
1975
|
+
process.env.HF_FAST_CAPTURE_BOUNDARY_SS === "true") {
|
|
1976
|
+
const buffer = await pageScreenshotCapture(page, options);
|
|
1977
|
+
session.capturePerf.frames += 1;
|
|
1978
|
+
session.capturePerf.seekMs += seekMs;
|
|
1979
|
+
session.capturePerf.beforeCaptureMs += beforeCaptureMs;
|
|
1980
|
+
{
|
|
1981
|
+
const boundaryMs = Date.now() - startTime;
|
|
1982
|
+
session.capturePerf.totalMs += boundaryMs;
|
|
1983
|
+
session.capturePerf.frameMs.push(boundaryMs);
|
|
1984
|
+
}
|
|
1985
|
+
const boundaryResult = Promise.resolve(buffer);
|
|
1986
|
+
if (session.staticFrames)
|
|
1987
|
+
session.lastEncodeResult = boundaryResult;
|
|
1988
|
+
return { encodeResult: boundaryResult, captureTimeMs: Date.now() - startTime };
|
|
1989
|
+
}
|
|
1990
|
+
// Worker-encode is gated to the macOS GPU path (beginFrameTimeTicks === 0,
|
|
1991
|
+
// syncToPaintEvent = true); see initDrawElementOrTransparentBackground. The
|
|
1992
|
+
// BeginFrame branch present in the synchronous captureFrameCore is therefore
|
|
1993
|
+
// unreachable here and intentionally omitted.
|
|
1994
|
+
const { encodeResult } = await produceDrawElementFrame(page, options.width, options.height, options.quality ?? 80, true);
|
|
1995
|
+
const captureTimeMs = Date.now() - startTime;
|
|
1996
|
+
session.capturePerf.frames += 1;
|
|
1997
|
+
session.capturePerf.seekMs += seekMs;
|
|
1998
|
+
session.capturePerf.beforeCaptureMs += beforeCaptureMs;
|
|
1999
|
+
// screenshotMs reflects produce time only (encode is async, not tracked here)
|
|
2000
|
+
session.capturePerf.screenshotMs += captureTimeMs - seekMs - beforeCaptureMs;
|
|
2001
|
+
session.capturePerf.totalMs += captureTimeMs;
|
|
2002
|
+
session.capturePerf.frameMs.push(captureTimeMs);
|
|
2003
|
+
// Task B: retain this encode result so a following static frame can reuse it.
|
|
2004
|
+
if (session.staticFrames)
|
|
2005
|
+
session.lastEncodeResult = encodeResult;
|
|
2006
|
+
return { encodeResult, captureTimeMs };
|
|
2007
|
+
}
|
|
2008
|
+
catch (captureError) {
|
|
2009
|
+
// Per-frame `No cached paint record`: fall back to screenshot for THIS frame
|
|
2010
|
+
// instead of aborting the render (clip-cut boundary / freshly-shown element).
|
|
2011
|
+
// The worker isn't involved for this frame; return a resolved encodeResult so
|
|
2012
|
+
// the pipeline loop writes it like any other. See fast-capture-limitations.md.
|
|
2013
|
+
if (isNoCachedPaintRecordError(captureError)) {
|
|
2014
|
+
session.deNcprFallbacks = (session.deNcprFallbacks ?? 0) + 1;
|
|
2015
|
+
console.log(`[engine] fast capture: frame ${frameIndex} — No cached paint record; ` +
|
|
2016
|
+
`screenshot fallback for this frame (see fast-capture-limitations.md)`);
|
|
2017
|
+
const buffer = await pageScreenshotCapture(page, options);
|
|
2018
|
+
return { encodeResult: Promise.resolve(buffer), captureTimeMs: Date.now() - startTime };
|
|
2019
|
+
}
|
|
2020
|
+
// Mirror captureFrameCore: capture per-frame diagnostics (frame-error
|
|
2021
|
+
// PNG/HTML/JSON + console tail) before rethrowing so pipelined-path
|
|
2022
|
+
// failures are debuggable like the serial path.
|
|
2023
|
+
if (session.isInitialized) {
|
|
2024
|
+
await captureFrameErrorDiagnostics(session, frameIndex, time, captureError instanceof Error ? captureError : new Error(String(captureError)));
|
|
2025
|
+
}
|
|
2026
|
+
throw captureError;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
/**
|
|
2030
|
+
* Verification-grade single-frame recapture for the producer's blank-frame
|
|
2031
|
+
* guard. Unlike {@link captureFrameToBufferPipelined} it takes NO shortcuts
|
|
2032
|
+
* and has NO fallbacks, both of which can return the WRONG FRAME's pixels at
|
|
2033
|
+
* drain time:
|
|
2034
|
+
* - the static-dedup fast path returns session.lastEncodeResult, which by
|
|
2035
|
+
* drain time can hold a frame several indices AHEAD of the suspect frame;
|
|
2036
|
+
* - the per-frame "No cached paint record" screenshot fallback captures the
|
|
2037
|
+
* injected canvas — i.e. the LAST drawn drawElement frame, not this one.
|
|
2038
|
+
* Any failure here throws; the caller treats that as verification failure and
|
|
2039
|
+
* falls back the whole render (correct, never wrong-frame).
|
|
2040
|
+
*/
|
|
2041
|
+
export async function recaptureDrawElementFrameForVerify(session, frameIndex, time) {
|
|
2042
|
+
const { page, options } = session;
|
|
2043
|
+
if (!session.isInitialized) {
|
|
2044
|
+
throw new Error("[FrameCapture] Session not initialized");
|
|
2045
|
+
}
|
|
2046
|
+
await prepareFrameForCapture(session, frameIndex, time);
|
|
2047
|
+
const { encodeResult } = await produceDrawElementFrame(page, options.width, options.height, options.quality ?? 80, true);
|
|
2048
|
+
return encodeResult;
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* P6 prototype (HF_DE_BATCH): capture N consecutive frames in one CDP
|
|
2052
|
+
* round-trip via {@link produceDrawElementFrameBatch}. The caller pre-plans the
|
|
2053
|
+
* batch (consecutive frame indices, none static-dedup'd, none opt-in
|
|
2054
|
+
* boundary-screenshot). On a mid-batch in-page failure the remaining frames are
|
|
2055
|
+
* re-captured through {@link captureFrameToBufferPipelined}, which owns the
|
|
2056
|
+
* per-frame screenshot-fallback semantics — so failure behavior is identical to
|
|
2057
|
+
* the unbatched path, just discovered at batch granularity.
|
|
2058
|
+
*/
|
|
2059
|
+
export async function captureFramesBatchPipelined(session, frameIndices, times) {
|
|
2060
|
+
const { page, options } = session;
|
|
2061
|
+
if (!session.isInitialized) {
|
|
2062
|
+
throw new Error("[FrameCapture] Session not initialized");
|
|
2063
|
+
}
|
|
2064
|
+
const startTime = Date.now();
|
|
2065
|
+
const fps = fpsToNumber(options.fps);
|
|
2066
|
+
const quantized = times.map((t) => quantizeTimeToFrame(t, fps));
|
|
2067
|
+
const { encodeResults, failedAt, error } = await produceDrawElementFrameBatch(page, quantized, options.width, options.height, options.quality ?? 80);
|
|
2068
|
+
const okCount = failedAt === null ? frameIndices.length : failedAt;
|
|
2069
|
+
const elapsed = Date.now() - startTime;
|
|
2070
|
+
session.capturePerf.frames += okCount;
|
|
2071
|
+
// Round-trips are fused — attribute the whole batch to produce time; each
|
|
2072
|
+
// frame gets the batch mean for the per-frame sample series.
|
|
2073
|
+
session.capturePerf.screenshotMs += elapsed;
|
|
2074
|
+
session.capturePerf.totalMs += elapsed;
|
|
2075
|
+
if (okCount > 0) {
|
|
2076
|
+
const perFrame = elapsed / okCount;
|
|
2077
|
+
for (let s2 = 0; s2 < okCount; s2++)
|
|
2078
|
+
session.capturePerf.frameMs.push(perFrame);
|
|
2079
|
+
}
|
|
2080
|
+
const results = [];
|
|
2081
|
+
for (let i = 0; i < okCount; i++) {
|
|
2082
|
+
const frameIndex = frameIndices[i];
|
|
2083
|
+
const encodeResult = encodeResults[i];
|
|
2084
|
+
if (frameIndex === undefined || !encodeResult)
|
|
2085
|
+
break;
|
|
2086
|
+
results.push({ frameIndex, encodeResult });
|
|
2087
|
+
}
|
|
2088
|
+
if (failedAt !== null) {
|
|
2089
|
+
console.log(`[engine] fast capture: batch produce failed at frame ` +
|
|
2090
|
+
`${frameIndices[failedAt] ?? "?"} (${error ?? "?"}); ` +
|
|
2091
|
+
`re-capturing ${frameIndices.length - failedAt} frame(s) per-frame`);
|
|
2092
|
+
for (let i = failedAt; i < frameIndices.length; i++) {
|
|
2093
|
+
const frameIndex = frameIndices[i];
|
|
2094
|
+
const time = times[i];
|
|
2095
|
+
if (frameIndex === undefined || time === undefined)
|
|
2096
|
+
break;
|
|
2097
|
+
const { encodeResult } = await captureFrameToBufferPipelined(session, frameIndex, time);
|
|
2098
|
+
results.push({ frameIndex, encodeResult });
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
// Task B: retain the last encode result so a following static frame can reuse it.
|
|
2102
|
+
const last = results[results.length - 1];
|
|
2103
|
+
if (session.staticFrames && last)
|
|
2104
|
+
session.lastEncodeResult = last.encodeResult;
|
|
2105
|
+
return results;
|
|
2106
|
+
}
|
|
1400
2107
|
/**
|
|
1401
2108
|
* Perform one capture, throw away the buffer, and restore any session
|
|
1402
2109
|
* side-effects (perf counters, BeginFrame damage tallies) so downstream
|
|
@@ -1478,6 +2185,9 @@ export async function closeCaptureSession(session) {
|
|
|
1478
2185
|
// Example: page release succeeds, browser release throws → pageReleased=true
|
|
1479
2186
|
// but browserReleased=false → second call no-ops on page and retries browser.
|
|
1480
2187
|
// This matches the orchestrator's intent for HDR cleanup.
|
|
2188
|
+
if (session.workerEncodeEnabled && session.page && !session.pageReleased) {
|
|
2189
|
+
cleanupDrawElementWorkerEncode(session.page);
|
|
2190
|
+
}
|
|
1481
2191
|
if (!session.pageReleased && session.page) {
|
|
1482
2192
|
const pageClosed = await waitForCloseWithTimeout(session.page.close());
|
|
1483
2193
|
if (!pageClosed) {
|
|
@@ -1509,6 +2219,7 @@ export function prepareCaptureSessionForReuse(session, outputDir, onBeforeCaptur
|
|
|
1509
2219
|
beforeCaptureMs: 0,
|
|
1510
2220
|
screenshotMs: 0,
|
|
1511
2221
|
totalMs: 0,
|
|
2222
|
+
frameMs: [],
|
|
1512
2223
|
};
|
|
1513
2224
|
session.beginFrameHasDamageCount = 0;
|
|
1514
2225
|
session.beginFrameNoDamageCount = 0;
|
|
@@ -1526,6 +2237,118 @@ export async function getCompositionDuration(session) {
|
|
|
1526
2237
|
return window.__hf?.duration ?? 0;
|
|
1527
2238
|
});
|
|
1528
2239
|
}
|
|
2240
|
+
/**
|
|
2241
|
+
* Ungated-release safety net, part 1: capture K screenshot ground-truth frames
|
|
2242
|
+
* BEFORE the drawElement canvas is injected (the only window where a page
|
|
2243
|
+
* screenshot shows the live DOM). The producer's drain compares each DE frame
|
|
2244
|
+
* at these indices against its screenshot; a breach (or a blank frame that
|
|
2245
|
+
* survives one retry) throws DrawElementVerificationError, and the orchestrator
|
|
2246
|
+
* re-renders the whole job via the screenshot path.
|
|
2247
|
+
*
|
|
2248
|
+
* Env: HF_DE_VERIFY = sample count (default 4, clamp 0..8; 0 disables).
|
|
2249
|
+
* Deterministic index selection: fixed fractions of the timeline, nudged off
|
|
2250
|
+
* clip-cut boundaries (screenshot-vs-DE is legitimately ±1-frame desynced
|
|
2251
|
+
* there — Lim 6). Skipped for tiny comps (<10 frames) and under
|
|
2252
|
+
* HF_FORCE_DRAWELEMENT (debug escape hatch).
|
|
2253
|
+
*
|
|
2254
|
+
* False-positive bias is intentional: a nondeterministic comp may mismatch its
|
|
2255
|
+
* init-time screenshot → the render falls back to the screenshot path (slower,
|
|
2256
|
+
* never wrong). Cost when passing: ~K×(seek+screenshot) ≈ 150–300ms at init.
|
|
2257
|
+
*/
|
|
2258
|
+
async function captureDeVerificationFrames(session, page, logInitPhase) {
|
|
2259
|
+
const kRaw = Number(process.env.HF_DE_VERIFY ?? "4");
|
|
2260
|
+
const k = Number.isFinite(kRaw) ? Math.max(0, Math.min(8, Math.floor(kRaw))) : 4;
|
|
2261
|
+
if (k === 0 || process.env.HF_FORCE_DRAWELEMENT === "1")
|
|
2262
|
+
return;
|
|
2263
|
+
if (session.options.format === "png")
|
|
2264
|
+
return; // worker-encode drain (the consumer) is jpeg-only
|
|
2265
|
+
const fps = fpsToNumber(session.options.fps);
|
|
2266
|
+
// Prefer the producer-resolved duration (the range that will actually be
|
|
2267
|
+
// drained). The page's raw __hf.duration can exceed it — timelines outrun
|
|
2268
|
+
// their data-duration, and infinite-repeat GSAP reports a huge sentinel —
|
|
2269
|
+
// and indices derived from it would never be drained, silently disarming
|
|
2270
|
+
// verification for exactly the comps that need it.
|
|
2271
|
+
const duration = session.options.compositionDurationSeconds ??
|
|
2272
|
+
(await page.evaluate(() => window.__hf?.duration ?? 0));
|
|
2273
|
+
const totalFrames = Math.floor(duration * fps);
|
|
2274
|
+
if (totalFrames < 10)
|
|
2275
|
+
return;
|
|
2276
|
+
if (duration > 3600) {
|
|
2277
|
+
// No producer duration and the page reports an implausible one.
|
|
2278
|
+
logInitPhase(`drawElement self-verify skipped: implausible duration ${duration}s`);
|
|
2279
|
+
return;
|
|
2280
|
+
}
|
|
2281
|
+
// Ground truth must show what the real capture paths would show: <video>
|
|
2282
|
+
// pixels come from the onBeforeCapture injector. When this session has no
|
|
2283
|
+
// injector (e.g. a probe session initialized before the render wires one
|
|
2284
|
+
// up) a video comp's truth would screenshot black boxes and every sample
|
|
2285
|
+
// would false-positive into the screenshot fallback — skip instead.
|
|
2286
|
+
if (!session.onBeforeCapture) {
|
|
2287
|
+
const hasVideos = await page.evaluate(() => document.querySelector("video") !== null);
|
|
2288
|
+
if (hasVideos) {
|
|
2289
|
+
logInitPhase("drawElement self-verify skipped: video comp without frame injector");
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
const boundary = await computeClipBoundaryFrames(page, fps);
|
|
2294
|
+
// Ascending order, and seek frame 0 first: GSAP .from()/overlapping tweens
|
|
2295
|
+
// lazily record their start values on FIRST seek — scrubbing mid-timeline
|
|
2296
|
+
// before the render's frame-0 seek corrupts those caches for the whole
|
|
2297
|
+
// render (the detectCssEffectRisk lesson), and because DE frames and truth
|
|
2298
|
+
// would share the corruption, PSNR would pass on the damaged output.
|
|
2299
|
+
// Seeking 0 → ascending reproduces the render's own seek order.
|
|
2300
|
+
const fractions = Array.from({ length: k }, (_, i) => (i + 1) / (k + 1));
|
|
2301
|
+
const seekTo = async (t) => {
|
|
2302
|
+
await page.evaluate((tt) => {
|
|
2303
|
+
const hf = window.__hf;
|
|
2304
|
+
if (hf && typeof hf.seek === "function")
|
|
2305
|
+
hf.seek(tt);
|
|
2306
|
+
}, t);
|
|
2307
|
+
};
|
|
2308
|
+
await seekTo(quantizeTimeToFrame(0, fps));
|
|
2309
|
+
// Force one frame so lazy tween initialization paints at t=0 state.
|
|
2310
|
+
await pageScreenshotCapture(page, session.options);
|
|
2311
|
+
const frames = new Map();
|
|
2312
|
+
for (const f of fractions) {
|
|
2313
|
+
let idx = Math.min(totalFrames - 1, Math.max(1, Math.round(totalFrames * f)));
|
|
2314
|
+
// Nudge off clip-cut boundaries (±1-frame desync is legitimate there);
|
|
2315
|
+
// if the nudge saturates on a boundary index, skip the sample entirely.
|
|
2316
|
+
let guard = 0;
|
|
2317
|
+
while (boundary.has(idx) && guard++ < 6)
|
|
2318
|
+
idx = Math.min(totalFrames - 1, idx + 2);
|
|
2319
|
+
if (boundary.has(idx))
|
|
2320
|
+
continue;
|
|
2321
|
+
if (frames.has(idx))
|
|
2322
|
+
continue;
|
|
2323
|
+
const t = quantizeTimeToFrame(idx / fps, fps);
|
|
2324
|
+
await seekTo(t);
|
|
2325
|
+
// Video frame injection (same hook the real capture paths run) — without
|
|
2326
|
+
// it, <video> elements screenshot black and every video comp would
|
|
2327
|
+
// false-positive into the screenshot fallback.
|
|
2328
|
+
if (session.onBeforeCapture)
|
|
2329
|
+
await session.onBeforeCapture(page, t);
|
|
2330
|
+
// Double-capture: the first screenshot forces a frame, which is what runs
|
|
2331
|
+
// rAF-driven callbacks (count-up text counters land a tick after seek()
|
|
2332
|
+
// returns — a single immediate screenshot captures stale text and
|
|
2333
|
+
// false-positives the verify: 3bea8c73 28.7dB vs a truth missing its stat
|
|
2334
|
+
// values while the DE frame was correct). NOTE: waiting on rAF via
|
|
2335
|
+
// evaluate instead deadlocks — headless only fires rAF when a frame is
|
|
2336
|
+
// produced, and nothing produces one until a screenshot asks.
|
|
2337
|
+
await pageScreenshotCapture(page, session.options);
|
|
2338
|
+
frames.set(idx, await pageScreenshotCapture(page, session.options));
|
|
2339
|
+
}
|
|
2340
|
+
// Leave the page at frame 0 so the render's first seek starts from the
|
|
2341
|
+
// same state as an unverified render.
|
|
2342
|
+
await seekTo(quantizeTimeToFrame(0, fps));
|
|
2343
|
+
session.deVerifyFrames = frames;
|
|
2344
|
+
logInitPhase(`drawElement self-verify armed: ${frames.size} ground-truth frame(s) @ [${[...frames.keys()].join(", ")}] of ${totalFrames}`);
|
|
2345
|
+
}
|
|
2346
|
+
function medianOf(samples) {
|
|
2347
|
+
if (samples.length === 0)
|
|
2348
|
+
return 0;
|
|
2349
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
2350
|
+
return Math.round(sorted[Math.floor(sorted.length / 2)] ?? 0);
|
|
2351
|
+
}
|
|
1529
2352
|
export function getCapturePerfSummary(session) {
|
|
1530
2353
|
const frames = Math.max(1, session.capturePerf.frames);
|
|
1531
2354
|
return {
|
|
@@ -1534,12 +2357,20 @@ export function getCapturePerfSummary(session) {
|
|
|
1534
2357
|
avgSeekMs: Math.round(session.capturePerf.seekMs / frames),
|
|
1535
2358
|
avgBeforeCaptureMs: Math.round(session.capturePerf.beforeCaptureMs / frames),
|
|
1536
2359
|
avgScreenshotMs: Math.round(session.capturePerf.screenshotMs / frames),
|
|
2360
|
+
p50TotalMs: medianOf(session.capturePerf.frameMs),
|
|
1537
2361
|
staticDedupReused: session.staticDedupCount ?? 0,
|
|
1538
2362
|
staticDedupEnabled: session.staticDedupEnabled ?? false,
|
|
1539
2363
|
// armed ⟺ a non-empty static set survived verification; predicted === its size.
|
|
1540
2364
|
staticDedupArmed: (session.staticFrames?.size ?? 0) > 0,
|
|
1541
2365
|
staticDedupPredicted: session.staticFrames?.size ?? 0,
|
|
1542
2366
|
staticDedupSkipReason: session.staticDedupSkipReason,
|
|
2367
|
+
captureMode: session.captureMode,
|
|
2368
|
+
deGateReason: session.deGateReason,
|
|
2369
|
+
deWorkerEncode: session.workerEncodeEnabled ?? false,
|
|
2370
|
+
deVerifyArmed: session.deVerifyFrames?.size ?? 0,
|
|
2371
|
+
deVerifyInitMs: session.deVerifyInitMs ?? 0,
|
|
2372
|
+
deBoundaryFrames: session.clipBoundaryFrames?.size ?? 0,
|
|
2373
|
+
deNcprFallbacks: session.deNcprFallbacks ?? 0,
|
|
1543
2374
|
};
|
|
1544
2375
|
}
|
|
1545
2376
|
// ── Transient browser error classification ─────────────────────────────────
|