mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 18:26:17 +00:00
Run `fallow fix --auto-fixable` to remove `export` keywords from symbols fallow's reachability analysis identifies as unused. Keeps only the cases where the symbol is still referenced internally in its own file (so removing `export` doesn't surface a new oxlint `no-unused-vars` error). Result: fallow dead-code findings drop from 276 → 208 (68 fewer unused exports), with no behavior change — each symbol is still defined and used exactly the same way within its file. Reverted ~20 files where fallow's auto-fix would have created cascading "declared but never used" lint errors — those are cases where the symbol isn't used at all, and properly cleaning them up means deleting the declaration, not just dropping `export`. Better to land that as a separate, narrower PR rather than mixing it into a mechanical de-export. Also reverted four false positives where fallow missed real consumers: - `captureCost.ts` (renderOrchestrator has two separate import blocks from the same module; fallow only saw the first) - `propertyPanelHelpers.ts`, `domEditingLayers.ts` (real internal uses fallow's reachability missed) - `render.ts` (functions imported via `await import()` dynamic import, which fallow's static analysis doesn't follow) Test plan: bun run --filter '*' typecheck (clean), oxlint + oxfmt clean, cli/core/studio/engine vitest suites pass (335 + 917 + 576 + 605 tests).
226 lines
7.7 KiB
TypeScript
226 lines
7.7 KiB
TypeScript
import html2canvas from "html2canvas";
|
|
import { DEFAULT_WIDTH, DEFAULT_HEIGHT } from "./webgl.js";
|
|
|
|
let patched = false;
|
|
const VOID_ELEMENT_TAGS = new Set([
|
|
"AREA",
|
|
"BASE",
|
|
"BR",
|
|
"COL",
|
|
"EMBED",
|
|
"HR",
|
|
"IMG",
|
|
"INPUT",
|
|
"LINK",
|
|
"META",
|
|
"PARAM",
|
|
"SOURCE",
|
|
"TRACK",
|
|
"WBR",
|
|
]);
|
|
|
|
function patchCreatePattern(): void {
|
|
if (patched) return;
|
|
patched = true;
|
|
const orig = CanvasRenderingContext2D.prototype.createPattern;
|
|
CanvasRenderingContext2D.prototype.createPattern = function (
|
|
image: CanvasImageSource,
|
|
repetition: string | null,
|
|
): CanvasPattern | null {
|
|
if (
|
|
image &&
|
|
"width" in image &&
|
|
"height" in image &&
|
|
((image as HTMLCanvasElement).width === 0 || (image as HTMLCanvasElement).height === 0)
|
|
) {
|
|
return null;
|
|
}
|
|
return orig.call(this, image, repetition);
|
|
};
|
|
}
|
|
|
|
export function initCapture(): void {
|
|
patchCreatePattern();
|
|
}
|
|
|
|
export interface CaptureSceneOptions {
|
|
forceVisible?: boolean;
|
|
preferBrowserPaint?: boolean;
|
|
scale?: number;
|
|
}
|
|
|
|
function forceSceneVisibleInClone(source: HTMLElement, cloneDoc: Document): void {
|
|
if (!source.id) return;
|
|
const clone = cloneDoc.getElementById(source.id);
|
|
if (!(clone instanceof HTMLElement)) return;
|
|
|
|
clone.style.opacity = "1";
|
|
clone.style.visibility = "visible";
|
|
clone.querySelectorAll<HTMLElement>("[data-start]").forEach((el) => {
|
|
el.style.visibility = "visible";
|
|
});
|
|
}
|
|
|
|
function stabilizeTransformedBoxShadows(root: HTMLElement): void {
|
|
const view = root.ownerDocument.defaultView;
|
|
if (!view) return;
|
|
|
|
[root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))].forEach((el) => {
|
|
if (VOID_ELEMENT_TAGS.has(el.tagName)) return;
|
|
const styles = view.getComputedStyle(el);
|
|
if (styles.boxShadow === "none" || styles.transform === "none") return;
|
|
|
|
const shadow = root.ownerDocument.createElement("div");
|
|
shadow.setAttribute("data-hyper-shader-shadow-shim", "");
|
|
shadow.style.cssText = [
|
|
"position:absolute",
|
|
"inset:0",
|
|
"border-radius:inherit",
|
|
`box-shadow:${styles.boxShadow}`,
|
|
"background:transparent",
|
|
"pointer-events:none",
|
|
"z-index:0",
|
|
].join(";");
|
|
|
|
if (styles.position === "static") {
|
|
el.style.position = "relative";
|
|
}
|
|
el.style.boxShadow = "none";
|
|
el.insertBefore(shadow, el.firstChild);
|
|
});
|
|
}
|
|
|
|
// ── HTML-in-Canvas (drawElementImage) native capture ──────────────────────
|
|
|
|
interface CanvasWithLayoutSubtree extends HTMLCanvasElement {
|
|
layoutSubtree: boolean;
|
|
requestPaint: () => void;
|
|
}
|
|
|
|
interface CanvasRenderingContext2DWithDrawElement extends CanvasRenderingContext2D {
|
|
drawElementImage: (element: Element, x: number, y: number, w: number, h: number) => void;
|
|
}
|
|
|
|
export function isHtmlInCanvasCaptureSupported(): boolean {
|
|
if (typeof document === "undefined") return false;
|
|
const probe = document.createElement("canvas") as HTMLCanvasElement & {
|
|
layoutSubtree?: boolean;
|
|
};
|
|
probe.setAttribute("layoutsubtree", "");
|
|
if (!("layoutSubtree" in probe)) return false;
|
|
const ctx = probe.getContext("2d") as CanvasRenderingContext2DWithDrawElement | null;
|
|
return ctx != null && typeof ctx.drawElementImage === "function";
|
|
}
|
|
|
|
async function captureSceneWithHtmlInCanvas(
|
|
sceneEl: HTMLElement,
|
|
bgColor: string,
|
|
width: number,
|
|
height: number,
|
|
): Promise<HTMLCanvasElement> {
|
|
const canvas = document.createElement("canvas") as CanvasWithLayoutSubtree;
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
canvas.setAttribute("layoutsubtree", "");
|
|
canvas.style.cssText = `position:fixed;top:0;left:0;width:${width}px;height:${height}px;z-index:-9999;pointer-events:none;opacity:0`;
|
|
canvas.appendChild(sceneEl.cloneNode(true));
|
|
document.body.appendChild(canvas);
|
|
|
|
try {
|
|
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())));
|
|
const ctx = canvas.getContext("2d") as CanvasRenderingContext2DWithDrawElement;
|
|
ctx.fillStyle = bgColor;
|
|
ctx.fillRect(0, 0, width, height);
|
|
const child = canvas.firstElementChild;
|
|
if (child) ctx.drawElementImage(child, 0, 0, width, height);
|
|
const result = document.createElement("canvas");
|
|
result.width = width;
|
|
result.height = height;
|
|
result.getContext("2d")!.drawImage(canvas, 0, 0);
|
|
canvas.remove();
|
|
return result;
|
|
} catch (err) {
|
|
canvas.remove();
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function captureScene(
|
|
sceneEl: HTMLElement,
|
|
bgColor: string,
|
|
width: number = DEFAULT_WIDTH,
|
|
height: number = DEFAULT_HEIGHT,
|
|
options: CaptureSceneOptions = {},
|
|
): Promise<HTMLCanvasElement> {
|
|
if (isHtmlInCanvasCaptureSupported() && !options.preferBrowserPaint) {
|
|
return captureSceneWithHtmlInCanvas(sceneEl, bgColor, width, height).catch(() =>
|
|
captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height, options),
|
|
);
|
|
}
|
|
return captureSceneWithHtml2Canvas(sceneEl, bgColor, width, height, options);
|
|
}
|
|
|
|
function captureSceneWithHtml2Canvas(
|
|
sceneEl: HTMLElement,
|
|
bgColor: string,
|
|
width: number,
|
|
height: number,
|
|
options: CaptureSceneOptions = {},
|
|
): Promise<HTMLCanvasElement> {
|
|
const captureWithRenderer = (foreignObjectRendering: boolean): Promise<HTMLCanvasElement> => {
|
|
return html2canvas(sceneEl, {
|
|
width,
|
|
height,
|
|
scale: options.scale ?? 1,
|
|
backgroundColor: bgColor,
|
|
logging: false,
|
|
foreignObjectRendering,
|
|
// Safari applies stricter canvas-taint rules than Chrome. SVG data URLs
|
|
// with <filter> elements (e.g. feTurbulence grain backgrounds), certain
|
|
// cross-origin images, and mask/clip-path url() refs can taint the
|
|
// output canvas on WebKit. Without these flags, html2canvas throws
|
|
// `SecurityError: The operation is insecure` during its own read-back
|
|
// path and every shader transition falls through to the catch handler
|
|
// — observed in Safari + Claude Design's cross-origin iframe sandbox.
|
|
//
|
|
// useCORS: send CORS headers on image fetches so cross-origin images
|
|
// with proper `Access-Control-Allow-Origin` don't taint the
|
|
// canvas in the first place. Strict improvement.
|
|
// allowTaint: let html2canvas complete and return a canvas even when it
|
|
// becomes tainted (instead of throwing). Important caveat:
|
|
// a tainted canvas CANNOT be uploaded to WebGL via
|
|
// `gl.texImage2D` — WebGL spec requires SecurityError on
|
|
// non-origin-clean sources, with no opt-out. So this flag
|
|
// only moves the failure point from html2canvas to the
|
|
// texImage2D call in webgl.ts. The caller catches the
|
|
// rejected promise and keeps the DOM fallback visible. Net
|
|
// effect: the end-user UX avoids blank frames either way,
|
|
// but we get a cleaner, more predictable error site and the
|
|
// flag is defensively correct for the non-taint branches
|
|
// where it genuinely helps (e.g.,
|
|
// `crossOrigin="anonymous"` image fetches that already had
|
|
// CORS headers).
|
|
useCORS: true,
|
|
allowTaint: true,
|
|
onclone: (cloneDoc) => {
|
|
if (!sceneEl.id) return;
|
|
const clone = cloneDoc.getElementById(sceneEl.id);
|
|
if (clone instanceof HTMLElement) {
|
|
stabilizeTransformedBoxShadows(clone);
|
|
}
|
|
if (options.forceVisible) {
|
|
forceSceneVisibleInClone(sceneEl, cloneDoc);
|
|
}
|
|
},
|
|
ignoreElements: (el: Element) =>
|
|
el.tagName === "CANVAS" || el.hasAttribute("data-no-capture"),
|
|
});
|
|
};
|
|
|
|
if (options.preferBrowserPaint === true) {
|
|
return captureWithRenderer(true).catch(() => captureWithRenderer(false));
|
|
}
|
|
|
|
return captureWithRenderer(false);
|
|
}
|