Files
hyperframes/packages/engine/src/services/drawElementService.ts
T
337d0b51bc 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>
2026-07-07 01:52:26 -07:00

1106 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// fallow-ignore-file code-duplication complexity
/**
* DrawElement Capture Service
*
* `canvas.drawElementImage(element, x, y)` reads DOM paint records directly into
* a canvas, bypassing the full compositor pipeline. Requires the Chrome flag
* `--enable-features=CanvasDrawElement` (already added globally) and a
* `<canvas layoutsubtree>` wrapper around the composition root.
*
* Performance: ~46% faster than Page.captureScreenshot on local GPU.
* Alpha: pixel-perfect (PSNR=∞) on GPU. Falls back to screenshot in Docker
* (SwiftShader) when transparent output is requested — SwiftShader drops promoted
* compositor sub-layers on a transparent canvas destination (Chromium bug, filed
* Blink>Canvas, 2026-06-08).
*/
import type { Page } from "puppeteer-core";
/**
* Resolve which capture mode to use when `useDrawElement` is true.
*
* Cases that fall back to screenshot (see docs/fast-capture-limitations.md):
* - SwiftShader (software rasterizer, i.e. Docker/CI with no GPU): drawElement
* yields NO speedup here and is slightly slower. Its entire advantage is
* skipping the GPU→CPU screenshot readback IPC — on SwiftShader there is no
* GPU, so both paths block on identical software rasterization (measured
* parity: font-variant-numeric baseline 7822ms vs fast 7979ms, page-side
* draw/readback/encode all ~0ms). The drawElement path only adds a per-frame
* CDP round-trip on top of the same raster cost, and on a transparent
* destination additionally drops promoted sub-layers (Chromium bug
* 521434899). The speedup is real only on a hardware GPU (macOS 1.6×), so
* SwiftShader always routes to the platform baseline.
*
* The former <video> gate (a proxy for the word-by-word caption opacity pattern)
* was removed once Chrome 151 fixed crbug 521861819: video + nested-fade comps now
* render correctly on the drawElement path (verified PSNR=inf vs baseline). 151 is
* the pinned floor. See docs/fast-capture-limitations.md Lim 2.
*/
export function resolveDrawElementCaptureMode(
isSwiftShader: boolean,
transparent: boolean,
): "drawelement" | "screenshot" {
// `transparent` is retained for call-site clarity; SwiftShader blocks
// unconditionally now (no GPU egress to skip — parity at best), which
// subsumes the former transparent-only SwiftShader case.
void transparent;
if (isSwiftShader) return "screenshot";
return "drawelement";
}
/**
* Instrument `HTMLCanvasElement.getContext` before any page script runs.
*
* Accelerated canvas contexts (webgl/webgl2/webgpu) present via compositor
* texture swap — the canvas element never repaints, so its paint record never
* invalidates and drawElementImage serves the FIRST frame's snapshot for the
* whole render (confirmed: typegpu comp frozen at t=0, 21 dB; 2d canvas is
* unaffected at 56 dB). The fix is to composite those canvases manually:
* this wrapper records them in `window.__hf_accel_canvases` so
* captureDrawElementFrame can hide them from paint records and drawImage
* their live content underneath the drawElementImage output.
*
* WebGL contexts additionally get `preserveDrawingBuffer: true` forced —
* without it the drawing buffer is cleared after each compositor present and
* drawImage(glCanvas) reads blank.
*
* Must be registered via page.evaluateOnNewDocument BEFORE navigation.
*/
export function instrumentAcceleratedCanvases(): void {
type AccelWindow = Window & {
__hf_accel_canvases?: HTMLCanvasElement[];
__hf_canvas_2d?: HTMLCanvasElement[];
};
const w = window as AccelWindow;
w.__hf_accel_canvases = [];
w.__hf_canvas_2d = [];
const orig = HTMLCanvasElement.prototype.getContext;
// oxlint-disable-next-line no-explicit-any
(HTMLCanvasElement.prototype as any).getContext = function (
this: HTMLCanvasElement,
type: string,
attrs?: Record<string, unknown>,
) {
const isGl = type === "webgl" || type === "webgl2" || type === "experimental-webgl";
const isAccel = isGl || type === "webgpu";
const finalAttrs = isGl ? { ...attrs, preserveDrawingBuffer: true } : attrs;
// oxlint-disable-next-line no-explicit-any
const ctx = (orig as any).call(this, type, finalAttrs);
if (ctx && isAccel) {
const list = w.__hf_accel_canvases ?? [];
if (!list.includes(this)) list.push(this);
w.__hf_accel_canvases = list;
}
// 2d canvases are tracked separately: their paint records DO refresh on
// macOS (the sentinel forces a paint each frame), but under BeginFrame
// pacing (Linux headless-shell) canvas bitmap changes never dirty the
// record and the capture freezes at the first frame — so the BeginFrame
// path composites these too (see captureDrawElementFrame).
if (ctx && type === "2d") {
const list2d = w.__hf_canvas_2d ?? [];
if (!list2d.includes(this)) list2d.push(this);
w.__hf_canvas_2d = list2d;
}
return ctx;
};
}
/**
* Detect whether the page is running on SwiftShader (software rasterizer).
*
* Returns true inside Docker headless-shell with --use-angle=swiftshader.
* Returns false on macOS / Linux with a real GPU.
* Call once after window.__hf is ready; cache result on session.
*/
export async function detectSwiftShader(page: Page): Promise<boolean> {
return page.evaluate(() => {
const canvas = document.createElement("canvas");
const gl =
canvas.getContext("webgl") ||
(canvas.getContext("experimental-webgl") as WebGLRenderingContext | null);
if (!gl) return false;
const ext = gl.getExtension("WEBGL_debug_renderer_info");
if (!ext) return false;
const renderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) as string;
return renderer.toLowerCase().includes("swiftshader");
});
}
/**
* Inject a `<canvas layoutsubtree>` around the composition root.
*
* The canvas must wrap `[data-composition-id]` for drawElementImage to read
* its paint records. Idempotent — skips injection if `__hf_de_canvas` exists.
* Must be called after window.__hf is ready (so the composition root is in the DOM).
*/
export async function injectDrawElementCanvas(
page: Page,
width: number,
height: number,
): Promise<void> {
await page.evaluate(
({ 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 (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;
} catch {
/* leave undefined → ratio defaults to 1 */
}
const parent = root.parentNode;
if (!parent) throw new Error("drawElement: composition root has no parent node");
const canvas = document.createElement("canvas") as HTMLCanvasElement & {
layoutsubtree: boolean;
};
canvas.id = "__hf_de_canvas";
canvas.setAttribute("layoutsubtree", "");
canvas.width = w;
canvas.height = h;
canvas.style.cssText = "display:block;position:absolute;top:0;left:0;z-index:0";
parent.insertBefore(canvas, root);
canvas.appendChild(root);
// Invalidation sentinel: a canvas child OUTSIDE the captured root.
// 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 },
);
}
/**
* Capture one frame via canvas.drawElementImage, synchronized to the canvas
* `paint` event.
*
* `drawElementImage` draws from a snapshot recorded at the paint event; called
* outside one it returns the PREVIOUS frame's snapshot (WICG html-in-canvas).
* Capturing unsynchronized therefore yields one-frame-stale content, or an
* `InvalidStateError: No cached paint record` when no paint has landed since
* the last DOM mutation (the intermittent macOS crash). The fix is the API's
* intended usage: force an invalidation, await the canvas `paint` event, and
* draw inside its handler — the snapshot is then the CURRENT frame. Measured
* cost of the paint wait is ~1.3 ms/frame; the encode dominates.
*
* Encoding MUST match what the downstream encoder expects:
* - "png" → `toDataURL("image/png")` — preserves alpha (transparent output).
* - "jpeg" → `toDataURL("image/jpeg", q)` — opaque output. The producer's
* streaming encoder pipes frames to ffmpeg as mjpeg; feeding it PNG bytes
* makes ffmpeg's jpeg decoder fail ("Can not process SOS before SOF").
*
* Alpha (png) is preserved correctly on GPU (PSNR=∞ vs captureScreenshot). Do
* NOT call in Docker with transparent output — use the screenshot fallback
* instead (see routing in frameCapture.ts initializeSession).
*/
export async function captureDrawElementFrame(
page: Page,
width: number,
height: number,
format: "jpeg" | "png" = "jpeg",
quality = 80,
// Await the canvas `paint` event before drawing. Required on hosts with a
// free-running compositor (macOS / screenshot-launched browsers) where the
// capture call is unsynchronized with painting. MUST be false under
// BeginFrame control (Linux headless-shell): there, paints happen only on
// the per-frame HeadlessExperimental.beginFrame already issued before this
// call (snapshot is fresh), and no further paint would ever arrive — the
// wait would burn the fallback timeout on every frame.
syncToPaintEvent = true,
): Promise<Buffer> {
const dataUrl = await page.evaluate(
({
w,
h,
fmt,
q,
sync,
}: {
w: number;
h: number;
fmt: "jpeg" | "png";
q: number;
sync: boolean;
}) => {
const canvas = document.getElementById("__hf_de_canvas") as HTMLCanvasElement | null;
const root = document.querySelector("[data-composition-id]") as HTMLElement | null;
if (!canvas || !root) throw new Error("drawElement canvas not initialized");
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("drawElement: 2d context unavailable");
// Accelerated canvases (webgl/webgl2/webgpu) never repaint — their paint
// record stays frozen at the first frame (see instrumentAcceleratedCanvases).
// Hide them NOW, before this frame's paint, so the stale record stops
// painting; drawAndEncode composites their live content via drawImage
// underneath the drawElementImage output instead. Hiding must happen
// before the paint wait (sync mode) so the awaited paint reflects it.
type AccelWindow = Window & {
__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
// canvas hiding: the awaited paint should reflect the final state.
aw.__hf3d?.update();
const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
// Under BeginFrame pacing (sync=false) 2d canvas bitmaps also freeze in
// the paint records — composite them the same way. On paint-synced hosts
// (sync=true) the per-frame sentinel paint refreshes them natively.
if (!sync) {
for (const c of (aw.__hf_canvas_2d ?? []).filter((c2) => root.contains(c2))) {
if (!accel.includes(c)) accel.push(c);
}
// Stable z among composited canvases: document order.
accel.sort((a, b) =>
a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
);
}
for (const c of accel) {
if (c.style.visibility !== "hidden") c.style.visibility = "hidden";
}
return new Promise<string>((resolveCapture, rejectCapture) => {
let settled = false;
const drawAndEncode = () => {
if (settled) return;
settled = true;
try {
ctx.clearRect(0, 0, w, h);
// drawElementImage only paints the captured subtree. A background
// set on <body>/<html> (the common authoring pattern) lives OUTSIDE
// [data-composition-id], so without this fill those pixels stay
// transparent — and the jpeg encode below turns them black.
// Resolve the nearest non-transparent ancestor background-color and
// paint it first, matching what captureScreenshot composites.
// (Resolved per frame: compositions may set body background from JS.)
let bg = "";
for (let el = root.parentElement; el; el = el.parentElement) {
const c = getComputedStyle(el).backgroundColor;
if (c && c !== "transparent" && c !== "rgba(0, 0, 0, 0)") {
bg = c;
break;
}
}
// Opaque (jpeg) output with no author background anywhere:
// Page.captureScreenshot composites over the browser's default
// white viewport, but a cleared canvas encodes to BLACK in jpeg.
// Fill white for parity (transparent comps rendered to an opaque
// container — e.g. the webm-transparency test forced to mp4).
// png keeps true transparency.
if (!bg && fmt === "jpeg") bg = "#fff";
if (bg) {
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
}
// Composite live accelerated-canvas content UNDER the DOM paint.
// The canvases are visibility:hidden (transparent holes in the
// drawElementImage output), so DOM content above them (captions,
// overlays) still paints on top. Constraint: an opaque background
// on the composition root or an ancestor between root and the
// canvas would paint over this — backgrounds belong on <body> or
// inside the canvas for GPU comps.
const rootRect = root.getBoundingClientRect();
// fallow-ignore-next-line code-duplication
for (const c of accel) {
if (c.hasAttribute("data-hf-3d")) continue;
const r = c.getBoundingClientRect();
try {
ctx.drawImage(c, r.left - rootRect.left, r.top - rootRect.top, r.width, r.height);
} catch {
// Zero-sized or not-yet-configured canvas — skip this frame.
}
}
// 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;
let __appliedAlpha = false;
if (aw.__HF_ROOT_PROPS__) {
try {
const rcs = getComputedStyle(root);
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;
if (curTransform && curTransform !== "none") {
const m = new DOMMatrix(curTransform);
const origin = rcs.transformOrigin.split(" ");
const ox = parseFloat(origin[0] ?? "0") || 0;
const oy = parseFloat(origin[1] ?? "0") || 0;
ctx.translate(ox, oy);
ctx.transform(m.a, m.b, m.c, m.d, m.e, m.f);
ctx.translate(-ox, -oy);
__appliedTransform = true;
}
} catch {
/* leave context unchanged → uncorrected (no worse than before) */
}
}
(
ctx as unknown as { drawElementImage(el: Element, x: number, y: number): void }
).drawElementImage(root, 0, 0);
if (__appliedAlpha) ctx.globalAlpha = 1;
if (__appliedTransform) ctx.setTransform(1, 0, 0, 1, 0, 0);
// 3D-projection canvases (threeDProjection.ts) composite OVER the
// DOM paint: their content replaces clip-path-hidden foreground
// elements, and the under-pass above would bury them beneath the
// composition root's own background.
// fallow-ignore-next-line code-duplication
for (const c of accel) {
if (!c.hasAttribute("data-hf-3d")) continue;
const r = c.getBoundingClientRect();
try {
ctx.drawImage(c, r.left - rootRect.left, r.top - rootRect.top, r.width, r.height);
} catch {
// Zero-sized canvas — skip this frame.
}
}
} catch (e) {
rejectCapture(e instanceof Error ? e : new Error(String(e)));
return;
}
// Encode OUTSIDE the paint handler — heavy canvas work inside the
// paint event can stall the renderer.
setTimeout(() => {
try {
resolveCapture(
fmt === "png"
? canvas.toDataURL("image/png")
: canvas.toDataURL("image/jpeg", q / 100),
);
} catch (e) {
rejectCapture(e instanceof Error ? e : new Error(String(e)));
}
}, 0);
};
if (!sync) {
// BeginFrame mode: the per-frame beginFrame already painted a fresh
// snapshot before this call — draw immediately.
drawAndEncode();
return;
}
const onPaint = () => {
canvas.removeEventListener("paint", onPaint);
drawAndEncode();
};
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). 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 (the root's alpha may lag its
// transform by that frame) rather than a hung render.
setTimeout(() => {
canvas.removeEventListener("paint", onPaint);
drawAndEncode();
}, 250);
});
},
{ w: width, h: height, fmt: format, q: quality, sync: syncToPaintEvent },
);
const base64 = dataUrl.split(",")[1];
if (!base64) throw new Error("drawElement: toDataURL returned no base64 payload");
return Buffer.from(base64, "base64");
}
// ── Worker-encode pipeline ────────────────────────────────────────────────────
//
// Architecture: an in-page OffscreenCanvas Worker encodes JPEG frames off the
// main thread. The main thread does seek+paint+drawElement+createImageBitmap
// (the "produce" phase) and immediately transfers the bitmap to the worker.
// The worker encodes it concurrently while the main thread processes the next
// frame — hiding ~7.4ms of encode cost behind ~8.4ms of produce work.
//
// The worker posts the encoded bytes back by calling window.__hfFrameReady
// (a Puppeteer exposeFunction binding that calls a node-side callback).
// Node resolves the per-frame Promise from that callback.
interface WorkerEncodeEntry {
resolve: (buf: Buffer) => void;
reject: (err: Error) => void;
}
interface WorkerEncodeState {
nextId: number;
pending: Map<number, WorkerEncodeEntry>;
}
const workerEncodeStates = new WeakMap<Page, WorkerEncodeState>();
// Pages that already have the `__hfFrameReady` binding installed. The binding
// survives navigation and cannot be cleanly removed, so its lifetime is
// tracked separately from WorkerEncodeState (which is recreated per session).
// Without this, a re-init after cleanup would call exposeFunction twice and
// throw "already exists".
const workerEncodeBoundPages = new WeakSet<Page>();
/**
* Initialize the in-page JPEG encode Worker for a session. Must be called
* after page navigation (post-`initializeSession`) and before any
* `produceDrawElementFrame` calls.
*
* Safe to call multiple times for the same page (e.g. session reuse after
* navigation): the exposeFunction binding survives navigation, but the
* in-page Worker is re-created. Pending promises from a prior navigation are
* rejected with a "session reused" error.
*/
export async function initDrawElementWorkerEncode(page: Page): Promise<void> {
const existing = workerEncodeStates.get(page);
if (existing) {
// Session reused after navigation — reject stale pending promises and
// reset the frame-id counter so ids track the new render's frame indices.
for (const entry of existing.pending.values()) {
entry.reject(new Error("drawElement worker encode: session reused, frame dropped"));
}
existing.pending.clear();
existing.nextId = 0;
} else {
const state: WorkerEncodeState = { nextId: 0, pending: new Map() };
workerEncodeStates.set(page, state);
}
// Register the node-side callback ONCE per page. The exposeFunction binding
// survives navigation and cannot be re-added (throws "already exists"), so
// guard with workerEncodeBoundPages rather than the per-session state. The
// callback reads the CURRENT WorkerEncodeState live, so it works across
// re-inits where the state object is replaced.
if (!workerEncodeBoundPages.has(page)) {
workerEncodeBoundPages.add(page);
await page.exposeFunction("__hfFrameReady", (id: number, b64: string, error?: string) => {
const s = workerEncodeStates.get(page);
if (!s) return;
// id < 0 is a fatal worker signal (e.g. worker onerror): the worker is
// dead and no frame will ever come back — reject every in-flight frame
// so awaiters fail fast instead of hanging forever.
if (id < 0) {
for (const entry of s.pending.values()) {
entry.reject(new Error(`drawElement worker encode failed: ${error ?? "worker error"}`));
}
s.pending.clear();
return;
}
const entry = s.pending.get(id);
if (!entry) return;
s.pending.delete(id);
if (error) {
entry.reject(new Error(`drawElement worker encode failed: ${error}`));
} else if (!b64) {
// A success message with no payload would otherwise resolve a 0-byte
// Buffer and ffmpeg would write a corrupt/empty frame silently. Fail loud.
entry.reject(new Error(`drawElement worker encode returned empty frame (frame ${id})`));
} else {
entry.resolve(Buffer.from(b64, "base64"));
}
});
}
// Inject (or re-create) the in-page Worker after each navigation.
await page.evaluate(() => {
type EncWin = Window & {
__hfEncWorker?: Worker;
__hfFrameReady?: (id: number, b64: string, error?: string) => void;
};
const ew = window as EncWin;
if (ew.__hfEncWorker) {
ew.__hfEncWorker.terminate();
ew.__hfEncWorker = undefined;
}
// Base64 is done INSIDE the worker (off the main thread) so it never
// competes with the produce phase; the worker posts a string the page
// relays to node. On any encode failure the worker posts an error for that
// frame's id so the node-side promise rejects instead of hanging.
const workerSrc = `
// Reuse one OffscreenCanvas across frames (dimensions are constant for a
// render) — a fresh canvas per frame churns ~w*h*4 bytes of backing store
// every frame and pressures GC on the encode hot path.
let oc = null, c = null;
self.onmessage = async (e) => {
const { bmp, id, w, h, q } = e.data;
try {
if (!oc || oc.width !== w || oc.height !== h) {
oc = new OffscreenCanvas(w, h);
c = oc.getContext('2d');
}
if (!c) throw new Error('OffscreenCanvas 2d context unavailable');
c.drawImage(bmp, 0, 0);
bmp.close();
const blob = await oc.convertToBlob({ type: 'image/jpeg', quality: q });
const ab = await blob.arrayBuffer();
const u = new Uint8Array(ab);
let s = ''; const CH = 0x8000;
for (let i = 0; i < u.length; i += CH)
s += String.fromCharCode.apply(null, u.subarray(i, i + CH));
self.postMessage({ id, b64: btoa(s) });
} catch (err) {
try { if (bmp) bmp.close(); } catch (_) {}
self.postMessage({ id, error: (err && err.message) || String(err) });
}
};
`;
const url = URL.createObjectURL(new Blob([workerSrc], { type: "text/javascript" }));
const worker = new Worker(url);
URL.revokeObjectURL(url); // only needed for Worker construction
ew.__hfEncWorker = worker;
worker.onmessage = (ev: MessageEvent) => {
const d = ev.data as { id: number; b64?: string; error?: string };
ew.__hfFrameReady?.(d.id, d.b64 ?? "", d.error);
};
worker.onerror = (err: ErrorEvent) => {
// Fatal, not tied to a frame id — signal node (id = -1) to reject all
// in-flight frames so the pipeline fails fast instead of hanging.
ew.__hfFrameReady?.(-1, "", err.message || "worker fatal error");
};
});
}
/**
* Clean up the worker encode state for a session being closed. Rejects any
* pending frame promises and removes the WeakMap entry. Safe to call even if
* `initDrawElementWorkerEncode` was never called for this page.
*/
export function cleanupDrawElementWorkerEncode(page: Page): void {
const state = workerEncodeStates.get(page);
if (!state) return;
for (const entry of state.pending.values()) {
entry.reject(new Error("drawElement worker encode: session closed"));
}
state.pending.clear();
workerEncodeStates.delete(page);
}
/**
* Pipelined drawElement frame capture: produce phase only.
*
* Performs seek-prep, paint-wait, drawElementImage, compositing, and
* `createImageBitmap` on the main thread, then transfers the bitmap to the
* in-page encode worker. Returns as soon as the bitmap is transferred — the
* worker encodes asynchronously. The returned `encodeResult` resolves when
* the worker posts the encoded frame back to node.
*
* Call `initDrawElementWorkerEncode` once per page before using this function.
*
* JPEG only (png falls back to synchronous `captureDrawElementFrame`).
*/
export async function produceDrawElementFrame(
page: Page,
width: number,
height: number,
quality = 80,
syncToPaintEvent = true,
): Promise<{ encodeResult: Promise<Buffer> }> {
const state = workerEncodeStates.get(page);
if (!state) {
throw new Error(
"drawElement worker encode not initialized; call initDrawElementWorkerEncode first",
);
}
const frameId = ++state.nextId;
const encodeResult = new Promise<Buffer>((resolve, reject) => {
// Watchdog: worker.onerror (→ id=-1, reject-all) covers worker CRASHES, but
// a lost message (page navigation, OOM-killed worker with no ErrorEvent, a
// dropped postMessage) would never settle this promise — `drainPrev`'s
// `await encodeResult` would then hang the whole render to the protocol
// timeout. Bound it so the render fails with a clear error. Generous vs the
// ~10ms encode to avoid false positives on large frames.
const timer = setTimeout(() => {
if (state.pending.delete(frameId)) {
reject(new Error(`drawElement worker encode timed out (frame ${frameId})`));
}
}, 30_000);
state.pending.set(frameId, {
resolve: (b) => {
clearTimeout(timer);
resolve(b);
},
reject: (e) => {
clearTimeout(timer);
reject(e);
},
});
});
// Guard against an unhandled rejection if the caller never awaits this promise
// (the depth-2 pipeline loop orphans the just-produced frame's encode when an
// earlier frame's drain throws or the render aborts). The loop's own
// `await encodeResult` still observes rejections on its separate reaction
// chain; this only suppresses the no-awaiter case.
void encodeResult.catch(() => {});
// Do paint-wait + drawElement composite + createImageBitmap + postMessage.
// Resolves as soon as the bitmap is transferred (not when encode is done).
await page.evaluate(
({ w, h, q, sync, fid }: { w: number; h: number; q: number; sync: boolean; fid: number }) => {
const canvas = document.getElementById("__hf_de_canvas") as HTMLCanvasElement | null;
const root = document.querySelector("[data-composition-id]") as HTMLElement | null;
if (!canvas || !root) throw new Error("drawElement canvas not initialized");
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("drawElement: 2d context unavailable");
type AccelWindow = Window & {
__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();
const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
if (!sync) {
for (const c of (aw.__hf_canvas_2d ?? []).filter((c2) => root.contains(c2))) {
if (!accel.includes(c)) accel.push(c);
}
accel.sort((a, b) =>
a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
);
}
for (const c of accel) {
if (c.style.visibility !== "hidden") c.style.visibility = "hidden";
}
return new Promise<void>((resolveCapture, rejectCapture) => {
let settled = false;
let usedRequestPaint = false;
const drawAndKick = () => {
if (settled) return;
settled = true;
try {
ctx.clearRect(0, 0, w, h);
let bg = "";
for (let el = root.parentElement; el; el = el.parentElement) {
const c = getComputedStyle(el).backgroundColor;
if (c && c !== "transparent" && c !== "rgba(0, 0, 0, 0)") {
bg = c;
break;
}
}
if (!bg) bg = "#fff";
if (bg) {
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
}
// fallow-ignore-next-line code-duplication
const rootRect = root.getBoundingClientRect();
for (const c of accel) {
if (c.hasAttribute("data-hf-3d")) continue;
const r = c.getBoundingClientRect();
try {
ctx.drawImage(c, r.left - rootRect.left, r.top - rootRect.top, r.width, r.height);
} catch {
// skip
}
}
// 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;
let __appliedAlpha = false;
if (aw.__HF_ROOT_PROPS__) {
try {
const rcs = getComputedStyle(root);
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;
if (curTransform && curTransform !== "none") {
const m = new DOMMatrix(curTransform);
const origin = rcs.transformOrigin.split(" ");
const ox = parseFloat(origin[0] ?? "0") || 0;
const oy = parseFloat(origin[1] ?? "0") || 0;
ctx.translate(ox, oy);
ctx.transform(m.a, m.b, m.c, m.d, m.e, m.f);
ctx.translate(-ox, -oy);
__appliedTransform = true;
}
} catch {
/* leave context unchanged → uncorrected (no worse than before) */
}
}
(
ctx as unknown as { drawElementImage(el: Element, x: number, y: number): void }
).drawElementImage(root, 0, 0);
if (__appliedAlpha) ctx.globalAlpha = 1;
if (__appliedTransform) ctx.setTransform(1, 0, 0, 1, 0, 0);
// fallow-ignore-next-line code-duplication
for (const c of accel) {
if (!c.hasAttribute("data-hf-3d")) continue;
const r = c.getBoundingClientRect();
try {
ctx.drawImage(c, r.left - rootRect.left, r.top - rootRect.top, r.width, r.height);
} catch {
// skip
}
}
} catch (e) {
rejectCapture(e instanceof Error ? e : new Error(String(e)));
return;
}
// Snapshot the canvas and hand off to the encode worker. createImageBitmap
// is async; resolveCapture only fires after the bitmap is transferred, so
// the canvas is safe to overwrite for the next frame once this evaluate's
// promise resolves.
createImageBitmap(canvas)
.then((bmp) => {
type EncWin = Window & { __hfEncWorker?: Worker };
const ew = window as EncWin;
if (!ew.__hfEncWorker) {
bmp.close(); // don't leak the GPU-backed ImageBitmap on this reject path
rejectCapture(new Error("drawElement: encode worker not initialized"));
return;
}
ew.__hfEncWorker.postMessage({ bmp, id: fid, w, h, q: q / 100 }, [bmp]);
resolveCapture();
})
.catch((e: unknown) => {
rejectCapture(e instanceof Error ? e : new Error(String(e)));
});
};
if (!sync) {
drawAndKick();
return;
}
const onPaint = () => {
canvas.removeEventListener("paint", onPaint);
drawAndKick();
};
canvas.addEventListener("paint", onPaint);
// Sentinel dirty + requestPaint() — see __hfDeInvalidate in
// injectDrawElementCanvas.
usedRequestPaint = aw.__hfDeInvalidate?.() === true;
setTimeout(() => {
canvas.removeEventListener("paint", onPaint);
drawAndKick();
}, 250);
});
},
{ w: width, h: height, q: quality, sync: syncToPaintEvent, fid: frameId },
);
return { encodeResult };
}
/**
* 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
* (__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
* i's createImageBitmap (the canvas is only redrawn after i's bitmap resolves).
*
* macOS-GPU sync path only (the worker-encode gate guarantees this at the call
* site). On an in-page failure at frame k, frames < k are already at the worker
* (their promises resolve normally); pending entries for frames >= k are
* rejected here and `failedAt` tells the caller to re-capture k.. via the
* per-frame path (which owns the screenshot-fallback semantics).
*/
export async function produceDrawElementFrameBatch(
page: Page,
times: number[],
width: number,
height: number,
quality = 80,
): Promise<{ encodeResults: Array<Promise<Buffer>>; failedAt: number | null; error?: string }> {
const state = workerEncodeStates.get(page);
if (!state) {
throw new Error(
"drawElement worker encode not initialized; call initDrawElementWorkerEncode first",
);
}
const fids: number[] = [];
const encodeResults: Array<Promise<Buffer>> = [];
for (let i = 0; i < times.length; i++) {
const frameId = ++state.nextId;
fids.push(frameId);
const p = new Promise<Buffer>((resolve, reject) => {
const timer = setTimeout(() => {
if (state.pending.delete(frameId)) {
reject(new Error(`drawElement worker encode timed out (frame ${frameId})`));
}
}, 30_000);
state.pending.set(frameId, {
resolve: (b) => {
clearTimeout(timer);
resolve(b);
},
reject: (e) => {
clearTimeout(timer);
reject(e);
},
});
});
void p.catch(() => {}); // same orphan-rejection guard as produceDrawElementFrame
encodeResults.push(p);
}
const outcome = await page.evaluate(
async ({
frames,
w,
h,
q,
}: {
frames: Array<{ t: number; fid: number }>;
w: number;
h: number;
q: number;
}): Promise<{ failedAt: number | null; error?: string }> => {
const canvas = document.getElementById("__hf_de_canvas") as HTMLCanvasElement | null;
const root = document.querySelector("[data-composition-id]") as HTMLElement | null;
if (!canvas || !root) return { failedAt: 0, error: "drawElement canvas not initialized" };
const ctx = canvas.getContext("2d");
if (!ctx) return { failedAt: 0, error: "drawElement: 2d context unavailable" };
type AccelWindow = Window & {
__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) => {
let done = false;
const settle = () => {
if (done) return;
done = true;
canvas.removeEventListener("paint", settle);
res();
};
canvas.addEventListener("paint", settle);
// Sentinel dirty + requestPaint() — see __hfDeInvalidate in
// injectDrawElementCanvas.
usedRequestPaint = aw.__hfDeInvalidate?.() === true;
setTimeout(settle, 250);
});
let prevBitmap: Promise<void> = Promise.resolve();
let prevBitmapIdx = -1;
const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e));
for (let i = 0; i < frames.length; i++) {
const frame = frames[i];
if (!frame) return { failedAt: i, error: "batch frame missing" };
const { t, fid } = frame;
try {
if (aw.__hf && typeof aw.__hf.seek === "function") aw.__hf.seek(t);
aw.__hf3d?.update();
const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
for (const c of accel) {
if (c.style.visibility !== "hidden") c.style.visibility = "hidden";
}
await waitPaint();
// Wait for the previous frame's bitmap before overwriting the canvas.
try {
await prevBitmap;
} catch (e) {
return { failedAt: prevBitmapIdx, error: errMsg(e) };
}
ctx.clearRect(0, 0, w, h);
let bg = "";
for (let el = root.parentElement; el; el = el.parentElement) {
const c = getComputedStyle(el).backgroundColor;
if (c && c !== "transparent" && c !== "rgba(0, 0, 0, 0)") {
bg = c;
break;
}
}
ctx.fillStyle = bg || "#fff";
ctx.fillRect(0, 0, w, h);
const rootRect = root.getBoundingClientRect();
for (const c of accel) {
if (c.hasAttribute("data-hf-3d")) continue;
const r = c.getBoundingClientRect();
try {
ctx.drawImage(c, r.left - rootRect.left, r.top - rootRect.top, r.width, r.height);
} catch {
// skip
}
}
// 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);
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;
if (curTransform && curTransform !== "none") {
const m = new DOMMatrix(curTransform);
const origin = rcs.transformOrigin.split(" ");
const ox = parseFloat(origin[0] ?? "0") || 0;
const oy = parseFloat(origin[1] ?? "0") || 0;
ctx.translate(ox, oy);
ctx.transform(m.a, m.b, m.c, m.d, m.e, m.f);
ctx.translate(-ox, -oy);
appliedTransform = true;
}
} catch {
/* leave context unchanged → uncorrected (no worse than before) */
}
}
(
ctx as unknown as { drawElementImage(el: Element, x: number, y: number): void }
).drawElementImage(root, 0, 0);
if (appliedAlpha) ctx.globalAlpha = 1;
if (appliedTransform) ctx.setTransform(1, 0, 0, 1, 0, 0);
for (const c of accel) {
if (!c.hasAttribute("data-hf-3d")) continue;
const r = c.getBoundingClientRect();
try {
ctx.drawImage(c, r.left - rootRect.left, r.top - rootRect.top, r.width, r.height);
} catch {
// skip
}
}
prevBitmapIdx = i;
prevBitmap = createImageBitmap(canvas).then((bmp) => {
if (!aw.__hfEncWorker) {
bmp.close();
throw new Error("drawElement: encode worker not initialized");
}
aw.__hfEncWorker.postMessage({ bmp, id: fid, w, h, q: q / 100 }, [bmp]);
});
} catch (e) {
try {
await prevBitmap;
} catch {
/* prior frame's failure surfaces via its own pending timeout path */
}
return { failedAt: i, error: errMsg(e) };
}
}
try {
await prevBitmap;
} catch (e) {
return { failedAt: prevBitmapIdx, error: errMsg(e) };
}
return { failedAt: null };
},
{ frames: times.map((t, i) => ({ t, fid: fids[i] ?? 0 })), w: width, h: height, q: quality },
);
if (outcome.failedAt !== null) {
// Frames >= failedAt never reached the worker — reject their pendings now
// so nothing waits 30s on the watchdog.
for (let k = outcome.failedAt; k < fids.length; k++) {
const fid = fids[k];
if (fid === undefined) continue;
const entry = state.pending.get(fid);
if (entry) {
state.pending.delete(fid);
entry.reject(
new Error(`drawElement batch produce failed at frame ${k}: ${outcome.error ?? "?"}`),
);
}
}
}
return { encodeResults, failedAt: outcome.failedAt, error: outcome.error };
}