/**
 * Video Frame Injector
 *
 * Creates a BeforeCaptureHook that replaces native <video> elements with
 * pre-extracted frame images during rendering. This is the Hyperframes-specific
 * video handling strategy — OSS users with different video pipelines can
 * provide their own hook or skip video injection entirely.
 */

import { type Page } from "puppeteer-core";
import { promises as fs } from "fs";
import { type FrameLookupTable } from "./videoFrameExtractor.js";
import { injectVideoFramesBatch, syncVideoFrameVisibility } from "./screenshotService.js";
import { type BeforeCaptureHook } from "./frameCapture.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { HF_COLOR_GRADING_CANVAS_ID_PREFIX } from "@hyperframes/core";

export interface VideoFrameInjectorOptions extends Partial<
  Pick<EngineConfig, "frameDataUriCacheLimit" | "frameDataUriCacheBytesLimitMb">
> {
  frameSrcResolver?: (framePath: string) => string | null;
}

interface FrameSourceCacheStats {
  entries: number;
  bytes: number;
  /** Total entries evicted since cache creation. A high count vs a small
   * composition signals the byte budget is too tight (cache thrash). */
  evictions: number;
  /** Total inserts rejected because the entry alone exceeds bytesLimit.
   * Non-zero means a single frame is bigger than the configured budget —
   * raise `frameDataUriCacheBytesLimitMb` if it recurs in production. */
  oversizedRejections: number;
}

interface FrameSourceCache {
  get: (framePath: string) => Promise<string>;
  /** Exposed for tests + telemetry; reflects current cache occupancy. */
  stats: () => FrameSourceCacheStats;
}

/**
 * Two-bound LRU keyed by frame path. Either bound triggers eviction of the
 * oldest entry — entry count protects against pathological many-tiny-frames
 * cases, and the byte budget keeps memory bounded when the per-frame data
 * URI grows (4K PNG frames are ~33 MB once base64-encoded).
 *
 * If a single entry's data URI exceeds `bytesLimit`, we skip caching it
 * (returning the URI directly to the caller). Without this guard, the
 * post-insert eviction loop would drop the entry we just inserted and the
 * cache would degrade into a CPU hot path — every subsequent `get()` would
 * re-read from disk and re-base64 the same frame.
 *
 * **Invariant**: cached values MUST be strings whose `.length` equals the
 * byte count we account for at insertion. We derive size on demand via
 * `cache.get(key)?.length` rather than maintaining a parallel `Map<string, number>`.
 * If you ever wrap the value (e.g. cache a Buffer or an object), the byte
 * accounting silently breaks — switch to a parallel size map first.
 */
function createFrameSourceCache(
  entryLimit: number,
  bytesLimit: number,
  frameSrcResolver?: (framePath: string) => string | null,
): FrameSourceCache {
  const cache = new Map<string, string>();
  const inFlight = new Map<string, Promise<string>>();
  let totalBytes = 0;
  let evictions = 0;
  let oversizedRejections = 0;

  function evictOldest(): void {
    const oldestKey = cache.keys().next().value;
    if (!oldestKey) return;
    // Snapshot the value before deleting so the byte-size derivation can't
    // accidentally read post-delete (a future reorder would silently lose
    // accounting and surface as `totalBytes` drifting out of sync).
    const dropped = cache.get(oldestKey);
    cache.delete(oldestKey);
    totalBytes = Math.max(0, totalBytes - (dropped?.length ?? 0));
    evictions++;
  }

  // fallow-ignore-next-line complexity
  function remember(framePath: string, dataUri: string): string {
    // Skip caching entries that alone exceed the byte budget. Caching them
    // would trigger immediate self-eviction on insert and pollute LRU order
    // by displacing the previous entry's slot.
    if (dataUri.length > bytesLimit) {
      oversizedRejections++;
      // Drop any stale prior version so the caller sees consistent state.
      if (cache.has(framePath)) {
        totalBytes = Math.max(0, totalBytes - (cache.get(framePath)?.length ?? 0));
        cache.delete(framePath);
      }
      return dataUri;
    }
    if (cache.has(framePath)) {
      totalBytes = Math.max(0, totalBytes - (cache.get(framePath)?.length ?? 0));
      cache.delete(framePath);
    }
    cache.set(framePath, dataUri);
    totalBytes += dataUri.length;
    while ((cache.size > entryLimit || totalBytes > bytesLimit) && cache.size > 0) {
      evictOldest();
    }
    return dataUri;
  }

  async function get(framePath: string): Promise<string> {
    const servedSrc = frameSrcResolver?.(framePath);
    if (servedSrc) return servedSrc;

    const cached = cache.get(framePath);
    if (cached) {
      remember(framePath, cached);
      return cached;
    }

    const existing = inFlight.get(framePath);
    if (existing) {
      return existing;
    }

    const pending = fs
      .readFile(framePath)
      .then((frameData) => {
        const mimeType = framePath.endsWith(".png") ? "image/png" : "image/jpeg";
        const dataUri = `data:${mimeType};base64,${frameData.toString("base64")}`;
        return remember(framePath, dataUri);
      })
      .finally(() => {
        inFlight.delete(framePath);
      });
    inFlight.set(framePath, pending);
    return pending;
  }

  return {
    get,
    stats: () => ({
      entries: cache.size,
      bytes: totalBytes,
      evictions,
      oversizedRejections,
    }),
  };
}

export const __testing = { createFrameSourceCache };

async function redrawRuntimeColorGrading(page: Page): Promise<void> {
  await page.evaluate(() => {
    const hf = (
      window as Window & {
        __hf?: {
          colorGrading?: { redraw?: () => unknown };
        };
      }
    ).__hf;
    const redraw = hf?.colorGrading?.redraw;
    if (typeof redraw !== "function") return;
    try {
      redraw();
    } catch {
      // Optional page-side shader layer.
    }
  });
}

/**
 * Creates a BeforeCaptureHook that injects pre-extracted video frames
 * into the page, replacing native <video> elements with frame images.
 */
export function createVideoFrameInjector(
  frameLookup: FrameLookupTable | null,
  config?: VideoFrameInjectorOptions,
): BeforeCaptureHook | null {
  if (!frameLookup) return null;

  const entryLimit = Math.max(
    32,
    config?.frameDataUriCacheLimit ?? DEFAULT_CONFIG.frameDataUriCacheLimit,
  );
  const bytesLimitMb = Math.max(
    64,
    config?.frameDataUriCacheBytesLimitMb ?? DEFAULT_CONFIG.frameDataUriCacheBytesLimitMb,
  );
  const bytesLimit = bytesLimitMb * 1024 * 1024;
  const frameCache = createFrameSourceCache(entryLimit, bytesLimit, config?.frameSrcResolver);
  const lastInjectedFrameByVideo = new Map<string, number>();

  // fallow-ignore-next-line complexity
  return async (page: Page, time: number) => {
    const activePayloads = frameLookup.getActiveFramePayloads(time);

    const updates: Array<{ videoId: string; dataUri: string; frameIndex: number }> = [];
    const activeIds = new Set<string>();
    if (activePayloads.size > 0) {
      const pendingReads: Array<Promise<{ videoId: string; dataUri: string; frameIndex: number }>> =
        [];
      for (const [videoId, payload] of activePayloads) {
        activeIds.add(videoId);
        const lastFrameIndex = lastInjectedFrameByVideo.get(videoId);
        if (lastFrameIndex === payload.frameIndex) continue;
        pendingReads.push(
          frameCache
            .get(payload.framePath)
            .then((dataUri) => ({ videoId, dataUri, frameIndex: payload.frameIndex })),
        );
      }
      updates.push(...(await Promise.all(pendingReads)));
    }

    for (const videoId of Array.from(lastInjectedFrameByVideo.keys())) {
      if (!activeIds.has(videoId)) {
        lastInjectedFrameByVideo.delete(videoId);
      }
    }

    await syncVideoFrameVisibility(page, Array.from(activeIds));
    if (updates.length > 0) {
      // Only record cache entries for videos the page actually painted.
      // injectVideoFramesBatch skips any video whose visual ancestor is
      // hidden (sub-comp host out-of-window) and returns the subset of ids
      // it really wrote — recording the rest would short-circuit the next
      // call at the same frameIndex and leave the host's first visible
      // frame blank.
      const injectedIds = new Set(
        await injectVideoFramesBatch(
          page,
          updates.map((u) => ({ videoId: u.videoId, dataUri: u.dataUri })),
        ),
      );
      for (const update of updates) {
        if (injectedIds.has(update.videoId)) {
          lastInjectedFrameByVideo.set(update.videoId, update.frameIndex);
        }
      }
      if (injectedIds.size > 0) {
        // GPU compositions (WebGL / WebGPU) that sample these videos as
        // textures already rendered once on the pre-injection seek, reading a
        // stale/black frame. Now that the decoded `__render_frame__` images are
        // in the DOM, re-render the GPU adapters at the same time so they
        // re-upload their video textures from the correct frame. No-op in
        // compositions without a GPU adapter.
        await page.evaluate((t: number) => {
          (window as unknown as { __hfReseekGpu?: (n: number) => void }).__hfReseekGpu?.(t);
        }, time);
      }
    }
    await redrawRuntimeColorGrading(page);
  };
}

// ── HDR compositing utilities ─────────────────────────────────────────────────

/**
 * Bounds and transform of a video element, queried from Chrome each frame.
 * Used by the two-pass HDR compositing pipeline to position native HDR frames.
 */
export interface VideoElementBounds {
  videoId: string;
  x: number;
  y: number;
  width: number;
  height: number;
  opacity: number;
  /** CSS transform matrix as a DOMMatrix-compatible string, e.g. "matrix(1,0,0,1,0,0)" */
  transform: string;
  zIndex: number;
  visible: boolean;
}

/**
 * Hide specific video elements by ID. Used in Pass 1 of the HDR pipeline so
 * Chrome screenshots only contain DOM content (text, overlays) with transparent
 * holes where the HDR videos go.
 */
export async function hideVideoElements(page: Page, videoIds: string[]): Promise<void> {
  await setVideoElementsVisibility(page, videoIds, false);
}

/**
 * Restore visibility of video elements after a DOM screenshot.
 */
export async function showVideoElements(page: Page, videoIds: string[]): Promise<void> {
  await setVideoElementsVisibility(page, videoIds, true);
}

async function setVideoElementsVisibility(
  page: Page,
  videoIds: string[],
  visible: boolean,
): Promise<void> {
  if (videoIds.length === 0) return;
  await page.evaluate(
    (ids: string[], canvasIdPrefix: string, shouldShow: boolean) => {
      const apply = (node: Element | null) => {
        if (!(node instanceof HTMLElement)) return;
        if (shouldShow) {
          node.style.removeProperty("visibility");
        } else {
          node.style.setProperty("visibility", "hidden", "important");
        }
      };
      for (const id of ids) {
        const video = document.getElementById(id);
        if (!video) continue;
        apply(video);
        apply(document.getElementById(`__render_frame_${id}__`));
        apply(document.getElementById(`${canvasIdPrefix}${id}`));
      }
    },
    videoIds,
    HF_COLOR_GRADING_CANVAS_ID_PREFIX,
    visible,
  );
}

/**
 * Query the current bounds, transform, and visibility of video elements.
 * Called after seeking (so GSAP has moved things) but before the screenshot.
 */
export async function queryVideoElementBounds(
  page: Page,
  videoIds: string[],
): Promise<VideoElementBounds[]> {
  if (videoIds.length === 0) return [];
  return page.evaluate((ids: string[]): VideoElementBounds[] => {
    return ids.map((id) => {
      const el = document.getElementById(id) as HTMLVideoElement | null;
      if (!el) {
        return {
          videoId: id,
          x: 0,
          y: 0,
          width: 0,
          height: 0,
          opacity: 0,
          transform: "none",
          zIndex: 0,
          visible: false,
        };
      }
      const rect = el.getBoundingClientRect();
      const style = window.getComputedStyle(el);
      const zIndexParsed = parseInt(style.zIndex);
      const zIndex = Number.isNaN(zIndexParsed) ? 0 : zIndexParsed;
      const opacityParsed = parseFloat(style.opacity);
      const opacity = Number.isNaN(opacityParsed) ? 1 : opacityParsed;
      const transform = style.transform || "none";
      const visible =
        style.visibility !== "hidden" &&
        style.display !== "none" &&
        rect.width > 0 &&
        rect.height > 0;
      return {
        videoId: id,
        x: Math.round(rect.x),
        y: Math.round(rect.y),
        width: Math.round(rect.width),
        height: Math.round(rect.height),
        opacity,
        transform,
        zIndex,
        visible,
      };
    });
  }, videoIds);
}

/**
 * Stacking info for a single timed element, used by the z-ordered layer compositor.
 */
export interface ElementStackingInfo {
  id: string;
  zIndex: number;
  x: number;
  y: number;
  width: number;
  height: number;
  /** Layout dimensions before CSS transforms (offsetWidth/offsetHeight). */
  layoutWidth: number;
  layoutHeight: number;
  opacity: number;
  visible: boolean;
  /**
   * True when the SDR video replacement image injected beside this element is
   * currently paintable. Native videos are hidden during capture, so this lets
   * the layered HDR compositor keep their replacement frames in the right DOM
   * layer without reviving unrelated hidden elements.
   */
  renderFrameVisible: boolean;
  isHdr: boolean;
  transform: string; // CSS transform matrix string, e.g. "matrix(1,0,0,1,0,0)" or "none"
  borderRadius: [number, number, number, number]; // [tl, tr, br, bl] in CSS px from nearest clipping ancestor
  /**
   * CSS `object-fit` value for replaced elements (`<img>`, `<video>`).
   * One of: `fill` (default), `cover`, `contain`, `none`, `scale-down`.
   * The HDR compositor uses this to resample image/video buffers into the
   * element's layout box the same way the browser would.
   */
  objectFit: string;
  /**
   * CSS `object-position` value (e.g. `"50% 50%"`, `"center top"`).
   * Falls back to the CSS default `"50% 50%"` (center) when unset.
   */
  objectPosition: string;
  /**
   * Clip rect from the nearest ancestor with `overflow: hidden` (or
   * `clip`/`clip-path`). When set, the HDR compositor must scissor the
   * element's blit to this viewport-relative rectangle. `null` means no
   * clipping ancestor was found — render at full element bounds.
   */
  clipRect: { x: number; y: number; width: number; height: number } | null;
}

/**
 * Query Chrome for ALL timed elements' stacking context.
 * Returns z-index, bounds, opacity, and whether each element is a native HDR source.
 *
 * Queries every element with `data-start` (not just videos) so the layer compositor
 * can determine z-ordering between DOM content and HDR video/image elements.
 *
 * @param nativeHdrIds Combined set of HDR-tagged element IDs (videos AND images).
 */
export async function queryElementStacking(
  page: Page,
  nativeHdrIds: Set<string>,
): Promise<ElementStackingInfo[]> {
  const hdrIds = Array.from(nativeHdrIds);
  // fallow-ignore-next-line complexity
  return page.evaluate((hdrIdList: string[]): ElementStackingInfo[] => {
    const hdrSet = new Set(hdrIdList);
    const elements = document.querySelectorAll("[data-start]");
    const results: ElementStackingInfo[] = [];

    // Walk up the DOM to find the effective z-index from the nearest
    // positioned ancestor with a z-index. CSS z-index only applies to
    // positioned elements; video elements inside positioned wrappers
    // inherit the wrapper's stacking context.
    //
    // ## Supported subset
    //
    // This implementation looks for explicit `z-index` on positioned
    // (non-static) ancestors. It does NOT detect the CSS stacking contexts
    // created implicitly by other properties — including `opacity < 1`,
    // `transform`, `filter`, `will-change`, `isolation: isolate`, and
    // `mix-blend-mode`. GSAP routinely sets `transform` on wrappers, which
    // creates an implicit stacking context with auto z-index; an HDR video
    // inside such a wrapper with no explicit z-index will return the
    // wrapper-of-the-wrapper's z-index here, potentially reordering layers
    // incorrectly relative to sibling stacking contexts.
    //
    // The workaround is to set explicit `z-index` on the positioned wrapper
    // when you want it treated as a compositing layer root. This matches
    // what compositions need to do anyway for deterministic z-ordering.
    function getEffectiveZIndex(node: Element): number {
      let current: Element | null = node;
      while (current) {
        const cs = window.getComputedStyle(current);
        const pos = cs.position;
        const z = parseInt(cs.zIndex);
        if (!Number.isNaN(z) && pos !== "static") return z;
        current = current.parentElement;
      }
      return 0;
    }

    // Find border-radius that clips the element. Replaced elements like <video>
    // clip to their own border-radius; ancestors need overflow !== visible.
    // fallow-ignore-next-line complexity
    function getEffectiveBorderRadius(node: Element): [number, number, number, number] {
      // Resolve a CSS border-radius value to pixels. Chrome's getComputedStyle
      // returns percentages as-is (e.g. "50%"), not resolved to px.
      // Uses offsetWidth/offsetHeight (layout dimensions before CSS transforms)
      // because CSS resolves percentages against the padding box, not the
      // transformed bounding box.
      function resolveRadius(value: string, el: Element): number {
        if (value.includes("%")) {
          const pct = parseFloat(value) / 100;
          const w = el instanceof HTMLElement ? el.offsetWidth : 0;
          const h = el instanceof HTMLElement ? el.offsetHeight : 0;
          return pct * Math.min(w, h);
        }
        const parsed = parseFloat(value);
        return Number.isNaN(parsed) ? 0 : parsed;
      }

      // Check element itself (replaced elements clip to own border-radius)
      const selfCs = window.getComputedStyle(node);
      const selfRadii: [number, number, number, number] = [
        resolveRadius(selfCs.borderTopLeftRadius, node),
        resolveRadius(selfCs.borderTopRightRadius, node),
        resolveRadius(selfCs.borderBottomRightRadius, node),
        resolveRadius(selfCs.borderBottomLeftRadius, node),
      ];
      if (selfRadii[0] > 0 || selfRadii[1] > 0 || selfRadii[2] > 0 || selfRadii[3] > 0) {
        return selfRadii;
      }

      // Walk ancestors looking for clipping container
      let current: Element | null = node.parentElement;
      while (current) {
        const cs = window.getComputedStyle(current);
        if (cs.overflow !== "visible") {
          const tl = resolveRadius(cs.borderTopLeftRadius, current);
          const tr = resolveRadius(cs.borderTopRightRadius, current);
          const brr = resolveRadius(cs.borderBottomRightRadius, current);
          const bl = resolveRadius(cs.borderBottomLeftRadius, current);
          if (tl > 0 || tr > 0 || brr > 0 || bl > 0) {
            return [tl, tr, brr, bl];
          }
        }
        current = current.parentElement;
      }
      return [0, 0, 0, 0];
    }

    // Walk ancestors to find the tightest overflow:hidden clip rect.
    // Returns null if no clipping ancestor exists.
    function getClipRect(
      node: Element,
    ): { x: number; y: number; width: number; height: number } | null {
      let current: Element | null = node.parentElement;
      let clip: { x: number; y: number; width: number; height: number } | null = null;
      while (current) {
        const cs = window.getComputedStyle(current);
        if (cs.overflow === "hidden" || cs.overflow === "clip") {
          const r = current.getBoundingClientRect();
          const ancestor = {
            x: Math.round(r.x),
            y: Math.round(r.y),
            width: Math.round(r.width),
            height: Math.round(r.height),
          };
          if (!clip) {
            clip = ancestor;
          } else {
            // Intersect with existing clip
            const x1 = Math.max(clip.x, ancestor.x);
            const y1 = Math.max(clip.y, ancestor.y);
            const x2 = Math.min(clip.x + clip.width, ancestor.x + ancestor.width);
            const y2 = Math.min(clip.y + clip.height, ancestor.y + ancestor.height);
            clip = { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) };
          }
        }
        current = current.parentElement;
      }
      return clip;
    }

    // Walk up the DOM multiplying each ancestor's opacity. GSAP animates
    // opacity on wrapper divs, not directly on the video element, so the
    // element's own opacity is often 1.0. Multiplying ancestors gives the
    // true effective opacity.
    function getEffectiveOpacity(node: Element): number {
      let opacity = 1;
      let current: Element | null = node;
      while (current) {
        const cs = window.getComputedStyle(current);
        const val = parseFloat(cs.opacity);
        // Note: `val || 1` would turn opacity:0 into 1 (0 is falsy)
        opacity *= Number.isNaN(val) ? 1 : val;
        current = current.parentElement;
      }
      return opacity;
    }

    // Compute the full CSS transform matrix from element-local coords to
    // viewport coords by walking the offsetParent chain and accumulating
    // position offsets + CSS transforms. This correctly handles GSAP
    // animations on wrapper divs (rotation, scale) that getBoundingClientRect
    // conflates into an axis-aligned bounding box.
    // fallow-ignore-next-line complexity
    function getViewportMatrix(node: Element): string {
      const chain: HTMLElement[] = [];
      let current: Element | null = node;
      while (current instanceof HTMLElement) {
        chain.push(current);
        const next: Element | null =
          (current.offsetParent as Element | null) ?? current.parentElement;
        if (next === current) break;
        current = next;
      }
      let mat = new DOMMatrix();
      for (let i = chain.length - 1; i >= 0; i--) {
        const htmlEl = chain[i];
        if (!htmlEl) continue;
        mat = mat.translate(htmlEl.offsetLeft, htmlEl.offsetTop);
        const cs = window.getComputedStyle(htmlEl);
        const origin = cs.transformOrigin.split(" ");
        const ox = resolveLength(origin[0] ?? "0", htmlEl.offsetWidth);
        const oy = resolveLength(origin[1] ?? "0", htmlEl.offsetHeight);
        const individualTransform = composeIndividualTransforms(cs);
        const hasIndividual = individualTransform !== null;
        const hasTransform = cs.transform && cs.transform !== "none";
        if (hasIndividual || hasTransform) {
          mat = mat.translate(ox, oy);
          if (hasIndividual) mat = mat.multiply(individualTransform);
          if (hasTransform) {
            try {
              const t = new DOMMatrix(cs.transform);
              if (
                Number.isFinite(t.a) &&
                Number.isFinite(t.b) &&
                Number.isFinite(t.c) &&
                Number.isFinite(t.d) &&
                Number.isFinite(t.e) &&
                Number.isFinite(t.f)
              ) {
                mat = mat.multiply(t);
              }
            } catch {
              // DOMMatrix constructor throws on malformed input — skip.
            }
          }
          mat = mat.translate(-ox, -oy);
        }
      }
      return mat.toString();
    }

    // fallow-ignore-next-line complexity
    function composeIndividualTransforms(cs: CSSStyleDeclaration): DOMMatrix | null {
      const translate = cs.getPropertyValue("translate").trim();
      const rotate = cs.getPropertyValue("rotate").trim();
      const scale = cs.getPropertyValue("scale").trim();
      const hasTranslate = translate && translate !== "none";
      const hasRotate = rotate && rotate !== "none";
      const hasScale = scale && scale !== "none";
      if (!hasTranslate && !hasRotate && !hasScale) return null;
      let m = new DOMMatrix();
      if (hasTranslate) {
        const parts = translate.split(/\s+/);
        const tx = parseFloat(parts[0] ?? "0") || 0;
        const ty = parseFloat(parts[1] ?? "0") || 0;
        if (tx !== 0 || ty !== 0) m = m.translate(tx, ty);
      }
      if (hasRotate) {
        const deg = parseFloat(rotate) || 0;
        if (deg !== 0) m = m.rotate(deg);
      }
      if (hasScale) {
        const parts = scale.split(/\s+/);
        const sx = parseFloat(parts[0] ?? "1") || 1;
        const sy = parseFloat(parts[1] ?? String(sx)) || sx;
        if (sx !== 1 || sy !== 1) m = m.scale(sx, sy);
      }
      return m;
    }

    function resolveLength(value: string, basis: number): number {
      if (value.endsWith("%")) {
        const pct = parseFloat(value) / 100;
        return Number.isFinite(pct) ? pct * basis : 0;
      }
      const n = parseFloat(value);
      return Number.isFinite(n) ? n : 0;
    }

    function isElementPaintable(node: Element): boolean {
      const rect = node.getBoundingClientRect();
      const style = window.getComputedStyle(node);
      return (
        style.visibility !== "hidden" &&
        style.display !== "none" &&
        rect.width > 0 &&
        rect.height > 0
      );
    }

    for (const el of elements) {
      const id = el.id;
      if (!id) continue;
      const rect = el.getBoundingClientRect();
      const style = window.getComputedStyle(el);
      const zIndex = getEffectiveZIndex(el);
      const isHdrEl = hdrSet.has(id);
      // The frame injector now uses `visibility: hidden` (without `opacity: 0`)
      // to hide native <video> elements, so the element's own computed opacity
      // remains the GSAP-controlled value. Walk from the element itself to
      // multiply through any ancestor opacity stacks.
      const opacity = getEffectiveOpacity(el);
      const visible = isElementPaintable(el);
      const renderFrame = document.getElementById(`__render_frame_${id}__`);
      const renderFrameVisible = renderFrame ? isElementPaintable(renderFrame) : false;
      // offsetWidth/offsetHeight only exist on HTMLElement (not on
      // SVGElement, MathMLElement, etc.). Fall back to the bounding rect
      // dimensions for non-HTML elements so callers always get sensible
      // layout numbers.
      const htmlEl = el instanceof HTMLElement ? el : null;
      results.push({
        id,
        zIndex,
        x: Math.round(rect.x),
        y: Math.round(rect.y),
        width: Math.round(rect.width),
        height: Math.round(rect.height),
        layoutWidth: htmlEl?.offsetWidth || Math.round(rect.width),
        layoutHeight: htmlEl?.offsetHeight || Math.round(rect.height),
        opacity,
        visible,
        renderFrameVisible,
        isHdr: hdrSet.has(id),
        // For HDR elements, use the full accumulated viewport matrix so the
        // affine blit can apply rotation/scale/translate properly. For DOM
        // elements, the element-level transform is sufficient for reference.
        transform: isHdrEl ? getViewportMatrix(el) : style.transform || "none",
        borderRadius: isHdrEl ? getEffectiveBorderRadius(el) : [0, 0, 0, 0],
        // `getComputedStyle` returns "" when the property doesn't apply (e.g.
        // for non-replaced elements); normalize to the CSS defaults so callers
        // can rely on a populated value.
        objectFit: style.objectFit || "fill",
        objectPosition: style.objectPosition || "50% 50%",
        clipRect: isHdrEl ? getClipRect(el) : null,
      });
    }
    return results;
  }, hdrIds);
}
