mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
* refactor: split useTimelinePlayer.ts and hyperframes-player.ts into focused modules (<500 LOC each) * fix(ci): scope 500 LOC check to packages/studio, add allowlist for grandfathered files * feat(cli): Linux ARM64 support — auto-install Chromium on DGX Spark / GB10 / Jetson Chrome Headless Shell has no Linux ARM64 binary. On arm64 Linux: - Detects the platform automatically - Tries to auto-install system Chromium via apt-get (works on Ubuntu/Debian ARM) - Falls back to clear manual instructions with exact commands - 'hyperframes browser ensure' guides through the setup interactively - After setup, all render commands work without any flags * fix(ci): disable Windows Defender real-time monitoring to prevent EPERM builds Path exclusions are insufficient — Defender re-scans new files created during bun install before the exclusion takes effect. Disable real-time monitoring for the entire job duration instead (standard CI practice). * refactor(studio): split all files >500 LOC + extract useToast, delete allowlist All 11 large files split into focused modules under 500 LOC. App.tsx extracted toast logic into useToast hook (493 LOC now). .filesize-allowlist deleted — no longer needed. * fix: remove unused imports from split files, extract useToast from App.tsx App.tsx: 504 → 493 lines (toast logic extracted to useToast hook) timelineDOM.ts: remove unused imports from re-export pattern MotionPanel.tsx: remove unused clampStudioCustomEasePoints import studioMotionOps.ts: remove unused StudioGsapMotionDirection import * fix(ci): use Set-MpPreference to fully disable Windows Defender (both jobs) * fix(producer): use node --experimental-strip-types instead of tsx for build:fonts Eliminates the tsx binary dependency that Windows Defender locks during bun install, causing EPERM errors. Node 22.6+ strips TypeScript types natively with no external binary. * chore: remove .filesize-allowlist — App.tsx is now 493 lines (<500) * fix(ci): disable Windows Defender before checkout to prevent all EPERM races * fix(producer): skip build:fonts if fontData.generated.ts already exists The generated file is tracked in git, so CI doesn't need to regenerate it. This avoids @fontsource/inter node_modules access on Windows which triggers EPERM from Defender scanning during bun install.
267 lines
9.3 KiB
TypeScript
267 lines
9.3 KiB
TypeScript
/**
|
|
* Low-level DOM primitives: type guards, style getters, CSS escaping,
|
|
* selector utilities, and composition source resolution.
|
|
* No imports from other domEditing* modules — safe to import from anywhere.
|
|
*/
|
|
import { CURATED_STYLE_PROPERTIES } from "./domEditingTypes";
|
|
|
|
// ─── Type guard ───────────────────────────────────────────────────────────────
|
|
|
|
export function isHtmlElement(value: unknown): value is HTMLElement {
|
|
return (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
"nodeType" in value &&
|
|
typeof (value as { nodeType?: unknown }).nodeType === "number" &&
|
|
(value as { nodeType: number }).nodeType === 1
|
|
);
|
|
}
|
|
|
|
// ─── Style parsing ────────────────────────────────────────────────────────────
|
|
|
|
export function parsePx(value: string | undefined): number | null {
|
|
if (!value) return null;
|
|
const trimmed = value.trim();
|
|
if (!trimmed.endsWith("px")) return null;
|
|
const parsed = parseFloat(trimmed);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
export function isIdentityTransform(value: string | undefined): boolean {
|
|
const transform = (value ?? "none").trim();
|
|
if (!transform || transform === "none") return true;
|
|
|
|
const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
|
|
if (matrix) {
|
|
const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
|
|
if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
|
|
return (
|
|
Math.abs(values[0] - 1) < 0.0001 &&
|
|
Math.abs(values[1]) < 0.0001 &&
|
|
Math.abs(values[2]) < 0.0001 &&
|
|
Math.abs(values[3] - 1) < 0.0001 &&
|
|
Math.abs(values[4]) < 0.0001 &&
|
|
Math.abs(values[5]) < 0.0001
|
|
);
|
|
}
|
|
|
|
const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
|
|
if (!matrix3d) return false;
|
|
const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
|
|
if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
|
|
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
|
|
}
|
|
|
|
export function isTextBearingTag(tagName: string): boolean {
|
|
return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName);
|
|
}
|
|
|
|
// ─── Style accessors ──────────────────────────────────────────────────────────
|
|
|
|
export function getCuratedComputedStyles(el: HTMLElement): Record<string, string> {
|
|
const styles: Record<string, string> = {};
|
|
const computed = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
if (!computed) return styles;
|
|
|
|
for (const prop of CURATED_STYLE_PROPERTIES) {
|
|
const value = computed.getPropertyValue(prop);
|
|
if (value) styles[prop] = value;
|
|
}
|
|
|
|
return styles;
|
|
}
|
|
|
|
export function getInlineStyles(el: HTMLElement): Record<string, string> {
|
|
const styles: Record<string, string> = {};
|
|
for (const property of CURATED_STYLE_PROPERTIES) {
|
|
const value = el.style.getPropertyValue(property);
|
|
if (value) styles[property] = value;
|
|
}
|
|
return styles;
|
|
}
|
|
|
|
export function getDataAttributes(el: HTMLElement): Record<string, string> {
|
|
const attrs: Record<string, string> = {};
|
|
for (const attr of el.attributes) {
|
|
if (attr.name.startsWith("data-")) {
|
|
attrs[attr.name.slice(5)] = attr.value;
|
|
}
|
|
}
|
|
return attrs;
|
|
}
|
|
|
|
// ─── DOM traversal ────────────────────────────────────────────────────────────
|
|
|
|
export function findClosestByAttribute(
|
|
el: HTMLElement,
|
|
attributeNames: string[],
|
|
): HTMLElement | null {
|
|
let current: HTMLElement | null = el;
|
|
while (current) {
|
|
const candidate = current;
|
|
if (attributeNames.some((attribute) => candidate.hasAttribute(attribute))) {
|
|
return candidate;
|
|
}
|
|
current = current.parentElement;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function getElementDepth(el: HTMLElement): number {
|
|
let depth = 0;
|
|
let current = el.parentElement;
|
|
while (current) {
|
|
depth += 1;
|
|
current = current.parentElement;
|
|
}
|
|
return depth;
|
|
}
|
|
|
|
// ─── Composition source resolution ───────────────────────────────────────────
|
|
|
|
export function getSourceFileForElement(
|
|
el: HTMLElement,
|
|
activeCompositionPath: string | null,
|
|
): { sourceFile: string; compositionPath: string } {
|
|
const sourceHost = findClosestByAttribute(el, ["data-composition-file", "data-composition-src"]);
|
|
const ownerRoot = findClosestByAttribute(el, ["data-composition-id"]);
|
|
const sourceFile =
|
|
sourceHost?.getAttribute("data-composition-file") ??
|
|
sourceHost?.getAttribute("data-composition-src") ??
|
|
ownerRoot?.getAttribute("data-composition-file") ??
|
|
ownerRoot?.getAttribute("data-composition-src") ??
|
|
activeCompositionPath ??
|
|
"index.html";
|
|
|
|
return {
|
|
sourceFile,
|
|
compositionPath: sourceFile,
|
|
};
|
|
}
|
|
|
|
export function normalizeTimelineCompositionSource(value: string | undefined): string | undefined {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) return undefined;
|
|
|
|
let pathname = trimmed;
|
|
try {
|
|
pathname = new URL(trimmed, "http://studio.local").pathname;
|
|
} catch {
|
|
pathname = trimmed;
|
|
}
|
|
|
|
for (const marker of ["/preview/comp/", "/preview/"]) {
|
|
const markerIndex = pathname.indexOf(marker);
|
|
if (markerIndex < 0) continue;
|
|
const sourcePath = pathname.slice(markerIndex + marker.length).replace(/^\/+/, "");
|
|
return sourcePath || trimmed;
|
|
}
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
// ─── CSS escaping ─────────────────────────────────────────────────────────────
|
|
|
|
export function escapeCssIdentifier(value: string): string {
|
|
const css = globalThis.CSS as { escape?: (input: string) => string } | undefined;
|
|
if (typeof css?.escape === "function") return css.escape(value);
|
|
|
|
if (value === "-") return "\\-";
|
|
|
|
let escaped = "";
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const char = value[index] ?? "";
|
|
const code = char.charCodeAt(0);
|
|
if (code === 0) {
|
|
escaped += "�";
|
|
continue;
|
|
}
|
|
|
|
const isDigit = code >= 48 && code <= 57;
|
|
const isUpperAlpha = code >= 65 && code <= 90;
|
|
const isLowerAlpha = code >= 97 && code <= 122;
|
|
const isControl = (code >= 1 && code <= 31) || code === 127;
|
|
const isLeadingDigit = index === 0 && isDigit;
|
|
const isSecondDigitAfterDash = index === 1 && value.startsWith("-") && isDigit;
|
|
if (isControl || isLeadingDigit || isSecondDigitAfterDash) {
|
|
escaped += `\\${code.toString(16)} `;
|
|
continue;
|
|
}
|
|
if (isUpperAlpha || isLowerAlpha || isDigit || char === "-" || char === "_" || code >= 128) {
|
|
escaped += char;
|
|
continue;
|
|
}
|
|
escaped += `\\${char}`;
|
|
}
|
|
return escaped;
|
|
}
|
|
|
|
export function escapeCssString(value: string): string {
|
|
return value
|
|
.replace(/\\/g, "\\\\")
|
|
.replace(/"/g, '\\"')
|
|
.replace(/\n/g, "\\a ")
|
|
.replace(/\r/g, "\\d ")
|
|
.replace(/\f/g, "\\c ");
|
|
}
|
|
|
|
export function querySelectorAllSafely(doc: Document, selector: string): Element[] {
|
|
try {
|
|
return Array.from(doc.querySelectorAll(selector));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function humanizeIdentifier(value: string): string {
|
|
return (
|
|
value
|
|
.replace(/\.html$/i, "")
|
|
.replace(/^compositions\//i, "")
|
|
.split("/")
|
|
.at(-1)
|
|
?.replace(/[-_]+/g, " ")
|
|
.replace(/\b\w/g, (char) => char.toUpperCase()) ?? value
|
|
);
|
|
}
|
|
|
|
// ─── CSS selector building ────────────────────────────────────────────────────
|
|
|
|
export function buildStableSelector(el: HTMLElement): string | undefined {
|
|
if (el.id) return `#${escapeCssIdentifier(el.id)}`;
|
|
|
|
const compositionId = el.getAttribute("data-composition-id");
|
|
if (compositionId) return `[data-composition-id="${escapeCssString(compositionId)}"]`;
|
|
|
|
return getPreferredClassSelector(el);
|
|
}
|
|
|
|
export function getPreferredClassSelector(el: HTMLElement): string | undefined {
|
|
const classes = Array.from(el.classList)
|
|
.map((value) => value.trim())
|
|
.filter(Boolean);
|
|
if (classes.length === 0) return undefined;
|
|
const preferred =
|
|
classes.find((value) => value !== "clip" && !value.startsWith("__hf-")) ?? classes[0];
|
|
return preferred ? `.${escapeCssIdentifier(preferred)}` : undefined;
|
|
}
|
|
|
|
export function getSelectorIndex(
|
|
doc: Document,
|
|
el: HTMLElement,
|
|
selector: string | undefined,
|
|
sourceFile: string,
|
|
activeCompositionPath: string | null,
|
|
): number | undefined {
|
|
if (!selector?.startsWith(".")) return undefined;
|
|
|
|
const candidates = querySelectorAllSafely(doc, selector).filter(
|
|
(candidate): candidate is HTMLElement =>
|
|
isHtmlElement(candidate) &&
|
|
getSourceFileForElement(candidate, activeCompositionPath).sourceFile === sourceFile,
|
|
);
|
|
const index = candidates.indexOf(el);
|
|
return index >= 0 ? index : undefined;
|
|
}
|