// 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 };
}
