mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(engine): drawElementImage capture service (#1917)
* feat(engine): drawElementImage capture service * chore(ci): ignore drawElementService exports pending upstack consumers Fallow's per-PR audit diffs against the merge base, so the bottom of the fast-capture stack (#1917) sees drawElementService's exports as unused — their consumers (frameCapture) land in #1919, two PRs upstack. ignoreExports entry documents this and can be dropped once #1919 merges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
cd1adcb581
commit
795934adba
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* DrawElement integration coverage — record of browser-level validation.
|
||||
*
|
||||
* The unit tests in `drawElementService.test.ts` mock `page.evaluate`, so they
|
||||
* cannot exercise real frame capture. The behaviours below were validated with
|
||||
* local Docker harnesses (dev scaffolding under `spikes/`, not committed —
|
||||
* spikes are untracked in this repo) that drive the real engine functions
|
||||
* against a real Chrome/headless-shell. This file records what was checked and
|
||||
* the results, and is the place to add in-suite browser tests if/when the
|
||||
* package gains a headless-browser test runner.
|
||||
*
|
||||
* ── T1 + T2: Docker / SwiftShader (real engine fns vs real SwiftShader) ─────────
|
||||
* Last validated 2026-06-08 — ALL PASS:
|
||||
* T1 opaque drawElement frame vs Page.captureScreenshot baseline — PSNR = ∞
|
||||
* (pixel-identical) on SwiftShader, even with CSS transforms present.
|
||||
* T2a detectSwiftShader() === true inside headless-shell + --use-angle=swiftshader.
|
||||
* T2b transparent + SwiftShader → resolveDrawElementCaptureMode === "screenshot"
|
||||
* (fallback; the transparent drawElement path is broken on SwiftShader).
|
||||
* T2c opaque + SwiftShader → "drawelement" (T1 confirmed it is pixel-correct).
|
||||
*
|
||||
* NOTE (2026-06-12): the engine now routes ALL SwiftShader renders to
|
||||
* screenshot regardless of T2c's correctness result. drawElement is
|
||||
* pixel-correct on SwiftShader (T1, PSNR ∞) but yields NO speedup there —
|
||||
* its only advantage is skipping the GPU→CPU screenshot readback IPC, which
|
||||
* software rasterization doesn't have. Measured parity (font-variant-numeric
|
||||
* baseline 7822ms vs fast 7979ms); resolveDrawElementCaptureMode now returns
|
||||
* "screenshot" for any isSwiftShader. The speedup is GPU-only (macOS 1.6×).
|
||||
*
|
||||
* ── E2E: full producer pipeline (--experimental-fast-capture) ───────────────────
|
||||
* A real producer render (css-spinner composition → mp4) with
|
||||
* PRODUCER_EXPERIMENTAL_FAST_CAPTURE=true logged "drawElement canvas injected"
|
||||
* and produced a valid mp4 — proving env → resolveConfig → captureCfg →
|
||||
* createCaptureSession → drawelement mode, plus jpeg-frame encode through ffmpeg.
|
||||
*
|
||||
* ── T3: transparent drawElement on a real GPU host ──────────────────────────────
|
||||
* PSNR = ∞ vs screenshot on GPU (empty A=0, semi-transparent A≈128, opaque
|
||||
* A=255). Cannot run in Docker (SwiftShader-only); validated on a GPU host.
|
||||
*/
|
||||
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe.skip("drawElementService integration (browser/Docker — see validation record above)", () => {
|
||||
it.skip("T1+T2 validated against real SwiftShader (Docker)", () => {});
|
||||
it.skip("E2E validated through the producer pipeline (--experimental-fast-capture)", () => {});
|
||||
it.skip("T3 validated on a real-GPU host", () => {});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Page } from "puppeteer-core";
|
||||
import { detectSwiftShader, resolveDrawElementCaptureMode } from "./drawElementService.js";
|
||||
|
||||
// ── detectSwiftShader ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("detectSwiftShader", () => {
|
||||
function makePage(evaluateResult: unknown): Page {
|
||||
return {
|
||||
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
||||
} as unknown as Page;
|
||||
}
|
||||
|
||||
it("returns true when renderer includes 'swiftshader'", async () => {
|
||||
const page = makePage(true);
|
||||
expect(await detectSwiftShader(page)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for a standard GPU renderer string", async () => {
|
||||
const page = makePage(false);
|
||||
expect(await detectSwiftShader(page)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when WebGL is unavailable", async () => {
|
||||
const page = makePage(false);
|
||||
expect(await detectSwiftShader(page)).toBe(false);
|
||||
});
|
||||
|
||||
it("passes a function to page.evaluate", async () => {
|
||||
const page = makePage(false);
|
||||
await detectSwiftShader(page);
|
||||
expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveDrawElementCaptureMode ──────────────────────────────────────────────
|
||||
|
||||
describe("resolveDrawElementCaptureMode", () => {
|
||||
// signature: (isSwiftShader, transparent)
|
||||
it("opaque + SwiftShader → screenshot (no GPU egress to skip — parity at best)", () => {
|
||||
expect(resolveDrawElementCaptureMode(true, false)).toBe("screenshot");
|
||||
});
|
||||
|
||||
it("transparent + SwiftShader → screenshot (also drops sub-layers; crbug 521434899)", () => {
|
||||
expect(resolveDrawElementCaptureMode(true, true)).toBe("screenshot");
|
||||
});
|
||||
|
||||
it("transparent + GPU → drawelement (GPU handles transparent correctly)", () => {
|
||||
expect(resolveDrawElementCaptureMode(false, true)).toBe("drawelement");
|
||||
});
|
||||
|
||||
it("opaque + GPU → drawelement", () => {
|
||||
expect(resolveDrawElementCaptureMode(false, false)).toBe("drawelement");
|
||||
});
|
||||
|
||||
// The <video> gate (proxy for the caption-pattern bug, crbug 521861819) was
|
||||
// removed once Chrome 151 fixed it — video comps now take the drawElement path.
|
||||
});
|
||||
@@ -0,0 +1,834 @@
|
||||
// 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 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.)
|
||||
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 `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.
|
||||
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);
|
||||
},
|
||||
{ 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 };
|
||||
};
|
||||
const aw = window as AccelWindow;
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
let __appliedTransform = false;
|
||||
if (__rw.__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;
|
||||
}
|
||||
}
|
||||
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). 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)";
|
||||
}
|
||||
// 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.
|
||||
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 };
|
||||
};
|
||||
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;
|
||||
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
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
let __appliedTransform = false;
|
||||
if (__rw.__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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
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)";
|
||||
}
|
||||
setTimeout(() => {
|
||||
canvas.removeEventListener("paint", onPaint);
|
||||
drawAndKick();
|
||||
}, 250);
|
||||
});
|
||||
},
|
||||
{ w: width, h: height, q: quality, sync: syncToPaintEvent, fid: frameId },
|
||||
);
|
||||
|
||||
return { encodeResult };
|
||||
}
|
||||
Reference in New Issue
Block a user