refactor(engine,producer): adopt requestPaint contract, retire autoAlpha rewrite (#2021)

* refactor(engine,producer): adopt requestPaint contract, retire autoAlpha rewrite

crbug 529829538 was closed "working as intended": the html-in-canvas API's
contract is mutate -> canvas.requestPaint() -> await the canvas paint event ->
drawElementImage, which refreshes the subtree's paint records including
compositor-applied properties. Verified on the pinned 151 floor and 152
canary: root opacity, root filter, nested group opacity, and child transforms
(incl. will-change-promoted) all capture exactly; the root element's own
TRANSFORM is the one property still never baked.

- Paint invalidation: all three paint-wait sites (serial capture, worker
  produce, batch produce) now call canvas.requestPaint() when available and
  fall back to the __hf_de_tick sentinel background toggle on builds without
  it. The 250ms unsynchronized-draw safety net is unchanged.
- Root-opacity ratio correction REMOVED (all three draw sites + base-opacity
  recording at injection). Since 151 the paint wait bakes current root opacity
  into the snapshot as pixel alpha, so the ratio correction DOUBLE-APPLIED
  animated root fades: a root-fade A/B tripped the runtime self-verify at
  30.1dB (frame 24, ~0.92 expected vs ~0.85 rendered). Post-removal the same
  comp self-verifies at inf and matches the screenshot render at PSNR=inf.
  The root TRANSFORM correction stays — verified still required.
- autoAlpha rewrite machinery DELETED: the opt-in opacity->autoAlpha tween
  rewrite (default-off since the retraction fix; measured ~28dB damage on
  comps whose fades it touched), its flush-time transparent-target hiding,
  the __HF_FAST_CAPTURE_AUTOALPHA__ flag plumbing, and the deferral-time
  retract/re-assert dance. The stub keeps tween-target tracking (3D
  projection + at-risk scans depend on it).

Validation: canary suite 7/7 with PSNRs identical to baseline (58.30 /
43.13 / 54.15 dB); root-fade A/B PSNR=inf vs screenshot; engine suite 905
passed (1 pre-existing color-grading failure); tsc/oxlint/oxfmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(engine,producer): review fixes — gate opacity correction by paint mechanism

Max code-review findings on the requestPaint adoption:

- Root-opacity ratio correction RESTORED, gated per frame on how the paint
  was produced: it applies on BeginFrame (sync=false) captures and on builds
  without canvas.requestPaint() — the two paths where the snapshot holds the
  root's load-time opacity — and is skipped only on requestPaint-driven
  paints, where the snapshot bakes the current opacity and the ratio
  double-applies (the proven 30.1dB root-fade failure). Base opacity is
  recorded at injection again.
- Invalidation extracted to a page-scope helper (__hfDeInvalidate, installed
  by injectDrawElementCanvas) shared by all three paint-wait sites: sentinel
  toggle ALWAYS (a paint is guaranteed even if requestPaint elides one on a
  clean subtree) + requestPaint() in a try/catch (a throwing implementation
  degrades to sentinel-only instead of rejecting the capture). Returns
  whether requestPaint ran, feeding the opacity-correction gate. Also removes
  the triplicated inline block and its three anonymous `as T` casts.
- HF_FAST_CAPTURE_AUTOALPHA now logs a retirement warning instead of being a
  silent no-op (the deleted rewrite's comment documented it as an operator
  escape hatch).
- Batch producer docstring updated (still described the tick-toggle-only
  paint wait); stub tween observer reshaped to a void fn (observeTweenCall)
  so no arg-rewriting seam survives.

Validation: canary suite 7/7 (58.30/43.13/54.15dB, d95f20b6 clean);
root-fade A/B self-verify 4x inf + whole-video PSNR=inf; engine suite 905
passed (1 pre-existing); tsc/oxlint/oxfmt clean; stub regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: WaterrrForever <miao.yang@heygen.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-07 01:52:26 -07:00
committed by GitHub
co-authored by Claude Fable 5 WaterrrForever
parent 574da2b215
commit 337d0b51bc
4 changed files with 164 additions and 269 deletions
@@ -142,10 +142,11 @@ export async function injectDrawElementCanvas(
({ w, h }: { w: number; h: number }) => {
const root = document.querySelector("[data-composition-id]") as HTMLElement | null;
if (!root || document.getElementById("__hf_de_canvas")) return;
// Record the root's base opacity now (timeline at 0, before any entrance/
// outro tween) so the per-frame capture can correct drawElementImage's stale
// opacity by the ratio current/base. (Transform is corrected unconditionally
// and needs no base; see captureDrawElementFrame.)
// Record the root's base opacity (timeline at 0, before any entrance/
// outro tween) for the LEGACY root-opacity ratio correction. The
// correction only runs on paths whose paint does not bake the root's
// current opacity into the snapshot — BeginFrame (sync=false) captures
// and builds without canvas.requestPaint(); see __hfDeInvalidate below.
try {
(window as unknown as { __HF_ROOT_BASE_OPACITY__?: number }).__HF_ROOT_BASE_OPACITY__ =
parseFloat(getComputedStyle(root).opacity) || 1;
@@ -165,14 +166,46 @@ export async function injectDrawElementCanvas(
parent.insertBefore(canvas, root);
canvas.appendChild(root);
// Invalidation sentinel: a canvas child OUTSIDE the captured root.
// Toggling its `left` each capture dirties the layoutsubtree so a paint
// (and a fresh snapshot) is guaranteed even for static frames — without
// ever appearing in drawElementImage(root) output.
// Toggling its background each capture is a PAINT-level dirty
// (layout/transform toggles do NOT fire the canvas `paint` event), so a
// paint — and a fresh snapshot — is guaranteed even for static frames,
// without the sentinel ever appearing in drawElementImage(root) output.
const tick = document.createElement("div");
tick.id = "__hf_de_tick";
tick.style.cssText =
"position:absolute;left:0px;top:0;width:1px;height:1px;background:#000;opacity:0.01;pointer-events:none";
canvas.appendChild(tick);
// Per-frame invalidation helper shared by every paint-wait site (serial
// capture, worker produce, batch produce). Two mechanisms, both applied:
// - the sentinel toggle guarantees a paint even if requestPaint() were
// to elide one on a clean subtree, and remains the sole mechanism on
// builds without requestPaint;
// - canvas.requestPaint() is the html-in-canvas API's intended
// invalidation (crbug 529829538 triage): it refreshes the subtree's
// paint records — compositor-applied props included — where the
// sentinel's dirty alone would not on pre-151 builds. Guarded so a
// throwing implementation degrades to sentinel-only instead of
// rejecting the capture.
// Returns true when requestPaint was called — the snapshot then bakes
// the root's CURRENT compositor-applied opacity (measured on 151/152),
// so callers must skip the legacy root-opacity ratio correction.
interface RequestPaintCanvas extends HTMLCanvasElement {
requestPaint?: () => void;
}
(window as Window & { __hfDeInvalidate?: () => boolean }).__hfDeInvalidate = () => {
tick.style.backgroundColor =
tick.style.backgroundColor === "rgb(0, 0, 0)" ? "rgb(1, 1, 1)" : "rgb(0, 0, 0)";
const cvp: RequestPaintCanvas = canvas;
if (typeof cvp.requestPaint === "function") {
try {
cvp.requestPaint();
return true;
} catch {
// Feature drift — sentinel dirty above still forces the paint.
}
}
return false;
};
},
{ w: width, h: height },
);
@@ -245,8 +278,15 @@ export async function captureDrawElementFrame(
__hf_accel_canvases?: HTMLCanvasElement[];
__hf_canvas_2d?: HTMLCanvasElement[];
__hf3d?: { update: () => void };
__hfDeInvalidate?: () => boolean;
__HF_ROOT_PROPS__?: boolean;
__HF_ROOT_BASE_OPACITY__?: number;
};
const aw = window as AccelWindow;
// True when this frame's paint was requested via canvas.requestPaint()
// — the snapshot then bakes the root's current compositor-applied
// opacity, so the legacy ratio correction below must be skipped.
let usedRequestPaint = false;
// Re-project CSS 3D contexts for THIS frame (threeDProjection.ts) so
// their WebGL canvases are fresh before being drawImage-composited
// below. Must run before the paint wait for the same reason as the
@@ -319,38 +359,36 @@ export async function captureDrawElementFrame(
// Zero-sized or not-yet-configured canvas — skip this frame.
}
}
// drawElementImage does not reflect post-paint changes to compositor-
// applied properties on the captured element ITSELF — opacity, transform,
// and filter on the root are applied by its parent at composite time and
// never enter the root's content snapshot (baked at the load-time/base
// value). Re-apply them to the 2D context, comparing against the base
// recorded at injection so a static root is a no-op (no double-apply / no
// regression) and an animated root is corrected.
// Two distinct behaviours, both measured:
// • opacity — the content snapshot DOES bake the root's load-time
// opacity, so correct by the ratio current/base (static ⇒ ratio 1 ⇒
// no-op; animated ⇒ corrected).
// • transform — the snapshot NEVER bakes the root's own transform (even
// a static transform renders unscaled), so apply the current matrix
// unconditionally about the transform-origin (no transform ⇒ no-op).
// filter is intentionally NOT corrected: the per-frame sentinel repaint
// already bakes it into the snapshot (correcting it double-applies).
const __rw = window as unknown as {
__HF_ROOT_PROPS__?: boolean;
__HF_ROOT_BASE_OPACITY__?: number;
};
let __appliedAlpha = false;
// Root compositor-prop corrections. Two distinct behaviours:
// • transform — the snapshot NEVER bakes the captured element's
// own transform (even a static one renders unscaled — the
// parent applies it at composite time; verified still true
// under the requestPaint contract, crbug 529829538 triage,
// probed on 151 + 152 canary 2026-07-07). Apply the current
// matrix unconditionally about the transform-origin.
// • opacity — DEPENDS on how this frame's paint was produced.
// A requestPaint()-driven paint bakes the root's CURRENT
// opacity into the snapshot (pixel alpha) — the legacy ratio
// correction DOUBLE-APPLIES there (~0.92 → 0.85 effective,
// 30.1 dB self-verify failure on a root-fade A/B) and must be
// skipped. BeginFrame captures (sync=false) and builds without
// requestPaint keep the pre-existing ratio correction: on
// those paths the snapshot holds the load-time opacity.
// filter is never corrected (paint wait bakes it).
let __appliedTransform = false;
if (__rw.__HF_ROOT_PROPS__) {
let __appliedAlpha = false;
if (aw.__HF_ROOT_PROPS__) {
try {
const rcs = getComputedStyle(root);
const baseOp = __rw.__HF_ROOT_BASE_OPACITY__ ?? 1;
const curOp = parseFloat(rcs.opacity);
if (baseOp > 0.001 && Number.isFinite(curOp)) {
const ratio = curOp / baseOp;
if (Math.abs(ratio - 1) > 0.002) {
ctx.globalAlpha = Math.max(0, Math.min(1, ratio));
__appliedAlpha = true;
if (!usedRequestPaint) {
const baseOp = aw.__HF_ROOT_BASE_OPACITY__ ?? 1;
const curOp = parseFloat(rcs.opacity);
if (baseOp > 0.001 && Number.isFinite(curOp)) {
const ratio = curOp / baseOp;
if (Math.abs(ratio - 1) > 0.002) {
ctx.globalAlpha = Math.max(0, Math.min(1, ratio));
__appliedAlpha = true;
}
}
}
const curTransform = rcs.transform;
@@ -418,20 +456,14 @@ export async function captureDrawElementFrame(
canvas.addEventListener("paint", onPaint);
// Force an invalidation so a paint is guaranteed even when this frame's
// seek produced no paint-level change (static scene, or transform-only
// GSAP updates that are compositor-side and never repaint). The sentinel
// is a 1x1 canvas child OUTSIDE the captured root (see
// injectDrawElementCanvas): toggling its background is a PAINT-level
// change (layout/transform toggles do NOT fire the paint event), so a
// paint + fresh snapshot follow promptly — without the sentinel ever
// appearing in drawElementImage(root) output.
const tick = document.getElementById("__hf_de_tick");
if (tick) {
tick.style.backgroundColor =
tick.style.backgroundColor === "rgb(0, 0, 0)" ? "rgb(1, 1, 1)" : "rgb(0, 0, 0)";
}
// GSAP updates that are compositor-side and never repaint). Sentinel
// dirty + requestPaint(), installed by injectDrawElementCanvas — see
// __hfDeInvalidate there for the full mechanism/rationale.
usedRequestPaint = aw.__hfDeInvalidate?.() === true;
// Safety net: if the paint event doesn't arrive (feature drift /
// throttled page), fall back to an unsynchronized draw after 250 ms —
// worst case one-frame-stale content rather than a hung render.
// worst case one-frame-stale content (the root's alpha may lag its
// transform by that frame) rather than a hung render.
setTimeout(() => {
canvas.removeEventListener("paint", onPaint);
drawAndEncode();
@@ -682,6 +714,9 @@ export async function produceDrawElementFrame(
__hf_accel_canvases?: HTMLCanvasElement[];
__hf_canvas_2d?: HTMLCanvasElement[];
__hf3d?: { update: () => void };
__hfDeInvalidate?: () => boolean;
__HF_ROOT_PROPS__?: boolean;
__HF_ROOT_BASE_OPACITY__?: number;
};
const aw = window as AccelWindow;
aw.__hf3d?.update();
@@ -700,6 +735,7 @@ export async function produceDrawElementFrame(
return new Promise<void>((resolveCapture, rejectCapture) => {
let settled = false;
let usedRequestPaint = false;
const drawAndKick = () => {
if (settled) return;
settled = true;
@@ -729,27 +765,23 @@ export async function produceDrawElementFrame(
// skip
}
}
// Re-apply the captured root's compositor-applied opacity/transform —
// identical to the serial path (see drawAndEncode). drawElementImage does
// not reflect post-paint changes to these on the root itself; without this
// the worker path damages comps with an animated root opacity/transform
// (the serial path corrects it, so worker-on diverged from serial DE).
const __rw = window as unknown as {
__HF_ROOT_PROPS__?: boolean;
__HF_ROOT_BASE_OPACITY__?: number;
};
let __appliedAlpha = false;
// Root compositor-prop corrections — identical to the serial path
// (see drawAndEncode's comment): transform always; opacity ratio
// only when this frame's paint was NOT requestPaint-driven.
let __appliedTransform = false;
if (__rw.__HF_ROOT_PROPS__) {
let __appliedAlpha = false;
if (aw.__HF_ROOT_PROPS__) {
try {
const rcs = getComputedStyle(root);
const baseOp = __rw.__HF_ROOT_BASE_OPACITY__ ?? 1;
const curOp = parseFloat(rcs.opacity);
if (baseOp > 0.001 && Number.isFinite(curOp)) {
const ratio = curOp / baseOp;
if (Math.abs(ratio - 1) > 0.002) {
ctx.globalAlpha = Math.max(0, Math.min(1, ratio));
__appliedAlpha = true;
if (!usedRequestPaint) {
const baseOp = aw.__HF_ROOT_BASE_OPACITY__ ?? 1;
const curOp = parseFloat(rcs.opacity);
if (baseOp > 0.001 && Number.isFinite(curOp)) {
const ratio = curOp / baseOp;
if (Math.abs(ratio - 1) > 0.002) {
ctx.globalAlpha = Math.max(0, Math.min(1, ratio));
__appliedAlpha = true;
}
}
}
const curTransform = rcs.transform;
@@ -816,11 +848,9 @@ export async function produceDrawElementFrame(
drawAndKick();
};
canvas.addEventListener("paint", onPaint);
const tick = document.getElementById("__hf_de_tick");
if (tick) {
tick.style.backgroundColor =
tick.style.backgroundColor === "rgb(0, 0, 0)" ? "rgb(1, 1, 1)" : "rgb(0, 0, 0)";
}
// Sentinel dirty + requestPaint() — see __hfDeInvalidate in
// injectDrawElementCanvas.
usedRequestPaint = aw.__hfDeInvalidate?.() === true;
setTimeout(() => {
canvas.removeEventListener("paint", onPaint);
drawAndKick();
@@ -835,8 +865,9 @@ export async function produceDrawElementFrame(
/**
* P6 prototype (HF_DE_BATCH): batch-produce N consecutive frames in ONE CDP
* round-trip. In-page loop per frame: `__hf.seek(t)` → paint-wait (tick toggle +
* canvas `paint` event) → drawElementImage composite → createImageBitmap →
* round-trip. In-page loop per frame: `__hf.seek(t)` → paint-wait
* (__hfDeInvalidate: sentinel dirty + requestPaint, then the canvas `paint`
* event) → drawElementImage composite → createImageBitmap →
* postMessage to the encode worker. Bitmaps are posted per-frame (encode starts
* immediately); only the CDP protocol round-trips are amortized N-fold.
* Micro-pipeline inside the batch: frame i+1's seek/paint-wait overlaps frame
@@ -910,11 +941,13 @@ export async function produceDrawElementFrameBatch(
__hf_accel_canvases?: HTMLCanvasElement[];
__hf3d?: { update: () => void };
__hf?: { seek?: (t: number) => void };
__hfDeInvalidate?: () => boolean;
__HF_ROOT_PROPS__?: boolean;
__HF_ROOT_BASE_OPACITY__?: number;
__hfEncWorker?: Worker;
};
const aw = window as AccelWindow;
let usedRequestPaint = false;
const waitPaint = (): Promise<void> =>
new Promise((res) => {
@@ -926,11 +959,9 @@ export async function produceDrawElementFrameBatch(
res();
};
canvas.addEventListener("paint", settle);
const tick = document.getElementById("__hf_de_tick");
if (tick) {
tick.style.backgroundColor =
tick.style.backgroundColor === "rgb(0, 0, 0)" ? "rgb(1, 1, 1)" : "rgb(0, 0, 0)";
}
// Sentinel dirty + requestPaint() — see __hfDeInvalidate in
// injectDrawElementCanvas.
usedRequestPaint = aw.__hfDeInvalidate?.() === true;
setTimeout(settle, 250);
});
@@ -978,20 +1009,23 @@ export async function produceDrawElementFrameBatch(
// skip
}
}
// Root compositor-applied opacity/transform correction — mirrors
// produceDrawElementFrame (see its comment).
let appliedAlpha = false;
// Root compositor-prop corrections — mirrors produceDrawElementFrame
// (see drawAndEncode's comment): transform always; opacity ratio only
// when this frame's paint was NOT requestPaint-driven.
let appliedTransform = false;
let appliedAlpha = false;
if (aw.__HF_ROOT_PROPS__) {
try {
const rcs = getComputedStyle(root);
const baseOp = aw.__HF_ROOT_BASE_OPACITY__ ?? 1;
const curOp = parseFloat(rcs.opacity);
if (baseOp > 0.001 && Number.isFinite(curOp)) {
const ratio = curOp / baseOp;
if (Math.abs(ratio - 1) > 0.002) {
ctx.globalAlpha = Math.max(0, Math.min(1, ratio));
appliedAlpha = true;
if (!usedRequestPaint) {
const baseOp = aw.__HF_ROOT_BASE_OPACITY__ ?? 1;
const curOp = parseFloat(rcs.opacity);
if (baseOp > 0.001 && Number.isFinite(curOp)) {
const ratio = curOp / baseOp;
if (Math.abs(ratio - 1) > 0.002) {
ctx.globalAlpha = Math.max(0, Math.min(1, ratio));
appliedAlpha = true;
}
}
}
const curTransform = rcs.transform;
+17 -46
View File
@@ -501,18 +501,6 @@ async function initDrawElementOrTransparentBackground(
"hint forced screenshot capture (e.g. raw requestAnimationFrame composition).",
);
}
// Retract the per-page autoAlpha rewrite flag when a runtime gate routes the
// session to screenshot mode. evaluateOnNewDocument already fired; a follow-up
// evaluate overrides it in the live page context so hideTransparentAutoAlpha-
// Targets does not hide elements on the fallback screenshot render
// (up to 21 dB damage if not retracted, A/B proven 2026-06-12).
async function retractAutoAlphaFlag(): Promise<void> {
await page.evaluate(() => {
(
window as Window & { __HF_FAST_CAPTURE_AUTOALPHA__?: boolean }
).__HF_FAST_CAPTURE_AUTOALPHA__ = false;
});
}
if (useDrawElement) {
session.isSwiftShader = await detectSwiftShader(page);
const transparent = session.options.format === "png";
@@ -521,7 +509,6 @@ async function initDrawElementOrTransparentBackground(
if (transparent) {
await initTransparentBackground(session.page);
}
await retractAutoAlphaFlag();
// Static-frame dedup is capture-mode-independent (the serial path reuses
// lastFrameBuffer regardless of how the frame was captured) and lossless
// (anchor-verified). A comp only reaches THIS fallback with useDrawElement=true
@@ -646,14 +633,10 @@ async function initDrawElementOrTransparentBackground(
// screenshots would capture black <video> boxes, and once the canvas is
// injected they can never be retaken. The capture stage completes the
// init after prepareCaptureSessionForReuse attaches the injector.
// Retract the autoAlpha rewrite flag while deferred — if no path ever
// completes the init (e.g. the disk path takes over), the session
// captures via screenshot, where the armed flag causes measured damage.
if (!session.onBeforeCapture && !forceDE) {
const hasVideos = await page.evaluate(() => document.querySelector("video") !== null);
if (hasVideos) {
session.deInitDeferred = true;
await retractAutoAlphaFlag();
logInitPhase("drawElement init deferred: video comp awaiting frame injector");
return;
}
@@ -721,15 +704,10 @@ async function finalizeDrawElementInit(
* Complete a deferred drawElement init (see CaptureSession.deInitDeferred).
* Call after prepareCaptureSessionForReuse has attached the video-frame
* injector; no-op when the session is not deferred or still has no injector.
* Re-asserts the autoAlpha rewrite flag retracted at deferral time.
*/
export async function completeDeferredDrawElementInit(session: CaptureSession): Promise<void> {
if (!session.deInitDeferred || !session.onBeforeCapture) return;
const page = session.page;
await page.evaluate(() => {
(window as Window & { __HF_FAST_CAPTURE_AUTOALPHA__?: boolean }).__HF_FAST_CAPTURE_AUTOALPHA__ =
true;
});
const logInitPhase = (phase: string) =>
console.log(`[initSession:${session.captureMode}] ${phase} (deferred drawElement init)`);
await finalizeDrawElementInit(session, page, logInitPhase, {
@@ -844,31 +822,24 @@ export async function createCaptureSession(
if (useDrawElement) {
await page.evaluateOnNewDocument(instrumentAcceleratedCanvases);
}
// Signal the producer's GSAP stub to rewrite `opacity``autoAlpha` in tween
// vars (stacked opacity-0 caption layers break drawElementImage capture).
//
// DEFAULT OFF (opt in with HF_FAST_CAPTURE_AUTOALPHA=true). The rewrite is baked
// at tween-creation (page load) and `retractAutoAlphaFlag` (flag-only) can't
// un-bake it: GSAP autoAlpha sets visibility:hidden under seek capture and renders
// ~28 dB below a clean opacity baseline (corpus eval 2026-06-16, e.g.
// 05f22830/06167790: autoAlpha-off = ∞, autoAlpha-on = 28 dB). The rewrite was a
// workaround for the stacked-fade opacity-layer drop (crbug 521861819), now fixed
// in Chrome 151 — so it damages more than it fixes. Re-enable per render only if a
// drawElement comp shows transparent-layer drop on the pinned 151 floor.
if (useDrawElement && process.env.HF_FAST_CAPTURE_AUTOALPHA === "true") {
await page.evaluateOnNewDocument(() => {
(
window as Window & { __HF_FAST_CAPTURE_AUTOALPHA__?: boolean }
).__HF_FAST_CAPTURE_AUTOALPHA__ = true;
});
// The opacity → autoAlpha tween rewrite was RETIRED with the requestPaint
// contract adoption (crbug 529829538 closed WAI): a requestPaint-driven
// paint refreshes nested opacity layers natively, and the rewrite itself
// measured ~28 dB of damage on comps whose fades it touched. Warn instead
// of silently ignoring the old escape hatch.
if (process.env.HF_FAST_CAPTURE_AUTOALPHA !== undefined) {
console.warn(
"[engine] HF_FAST_CAPTURE_AUTOALPHA is retired and ignored — the requestPaint " +
"paint contract captures animated opacity natively (see drawElementService.ts).",
);
}
// Re-apply the captured root's own computed opacity to the 2D context:
// drawElementImage does not reflect post-paint changes to compositor-applied
// properties on the captured element itself (the root's opacity is applied by
// its parent at composite time, never baked into its content snapshot), so an
// animated root fade renders at full opacity. captureDrawElementFrame corrects
// this by the ratio current/base opacity (no-op for a static root). On by
// default; disable with HF_FAST_CAPTURE_ROOT_PROPS=false.
// Re-apply the captured root's own compositor-applied props to the 2D
// context where the snapshot does not carry them (see the correction
// comment in drawElementService.ts drawAndEncode: transform always —
// never baked, verified under the requestPaint contract 2026-07-07;
// opacity ratio only on non-requestPaint paints, where the snapshot holds
// the load-time value). On by default; disable with
// HF_FAST_CAPTURE_ROOT_PROPS=false.
if (useDrawElement && process.env.HF_FAST_CAPTURE_ROOT_PROPS !== "false") {
await page.evaluateOnNewDocument(() => {
(window as unknown as { __HF_ROOT_PROPS__?: boolean }).__HF_ROOT_PROPS__ = true;