Files
heygen-com--hyperframes/packages/studio/src/captions/components/CaptionOverlayUtils.ts
T
wehub-resource-sync 85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

222 lines
7.3 KiB
TypeScript

// DOM helpers for CaptionOverlay — word box reading, transform I/O, wrapper management
export interface WordBox {
segmentId: string;
groupId: string;
groupIndex: number;
wordIndex: number;
x: number;
y: number;
width: number;
height: number;
}
export function readWordBoxes(
iframe: HTMLIFrameElement,
model: {
groupOrder: string[];
groups: Map<string, { segmentIds: string[] }>;
},
overlayEl: HTMLElement,
): WordBox[] {
let doc: Document | null = null;
let win: Window | null = null;
try {
doc = iframe.contentDocument;
win = iframe.contentWindow;
} catch {
return [];
}
if (!doc || !win) return [];
const iframeDisplayRect = iframe.getBoundingClientRect();
const overlayRect = overlayEl.getBoundingClientRect();
const nativeW = parseFloat(iframe.style.width) || iframeDisplayRect.width;
const cssScale = iframeDisplayRect.width / nativeW;
const offsetX = iframeDisplayRect.left - overlayRect.left;
const offsetY = iframeDisplayRect.top - overlayRect.top;
const groupEls = doc.querySelectorAll<HTMLElement>(".caption-group");
const boxes: WordBox[] = [];
for (let gi = 0; gi < model.groupOrder.length; gi++) {
const groupId = model.groupOrder[gi];
const group = model.groups.get(groupId);
if (!group) continue;
const groupEl = groupEls[gi] as HTMLElement | undefined;
if (!groupEl) continue;
const computed = win.getComputedStyle(groupEl);
if (parseFloat(computed.opacity) <= 0.01 || computed.visibility === "hidden") continue;
const resolvedWordEls: HTMLElement[] = [];
for (const child of groupEl.children) {
const c = child as HTMLElement;
if (c.dataset.captionWrapper === "true") {
const inner = c.querySelector<HTMLElement>(":scope > span");
if (inner) resolvedWordEls.push(inner);
} else if (c.tagName === "SPAN") {
resolvedWordEls.push(c);
}
}
if (resolvedWordEls.length === 0 && groupEl.textContent?.trim()) {
const textNode = groupEl.childNodes[0];
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
const words = (textNode.textContent || "").split(/\s+/).filter(Boolean);
const frag = doc.createDocumentFragment();
for (const word of words) {
const span = doc.createElement("span");
span.textContent = word + " ";
span.style.display = "inline";
frag.appendChild(span);
resolvedWordEls.push(span);
}
groupEl.replaceChild(frag, textNode);
} else {
const singleSpan = groupEl.querySelector<HTMLElement>(":scope > span");
if (singleSpan && singleSpan.textContent?.trim()) {
const words = singleSpan.textContent.split(/\s+/).filter(Boolean);
const frag = doc.createDocumentFragment();
for (const word of words) {
const span = doc.createElement("span");
span.textContent = word + " ";
span.style.display = "inline";
frag.appendChild(span);
resolvedWordEls.push(span);
}
singleSpan.replaceWith(frag);
}
}
}
for (let wi = 0; wi < group.segmentIds.length; wi++) {
const segId = group.segmentIds[wi];
const wordEl = resolvedWordEls[wi] as HTMLElement | undefined;
if (!wordEl) continue;
const rect = wordEl.getBoundingClientRect();
boxes.push({
segmentId: segId,
groupId,
groupIndex: gi,
wordIndex: wi,
x: rect.left * cssScale + offsetX,
y: rect.top * cssScale + offsetY,
width: rect.width * cssScale,
height: rect.height * cssScale,
});
}
}
return boxes;
}
export function getWordEl(
iframe: HTMLIFrameElement,
groupIndex: number,
wordIndex: number,
): HTMLElement | null {
let doc: Document | null = null;
try {
doc = iframe.contentDocument;
} catch {
return null;
}
if (!doc) return null;
const groupEl = doc.querySelectorAll<HTMLElement>(".caption-group")[groupIndex];
if (!groupEl) return null;
const wordEls: HTMLElement[] = [];
for (const child of groupEl.children) {
const el = child as HTMLElement;
if (el.dataset.captionWrapper === "true") {
const inner = el.querySelector<HTMLElement>(":scope > span");
if (inner) wordEls.push(inner);
} else if (el.tagName === "SPAN") {
wordEls.push(el);
}
}
return wordEls[wordIndex] ?? null;
}
/**
* Read GSAP's internal transform state for an element.
* GSAP stores transforms in its own cache, not in el.style.transform.
*/
export function readGsapTransform(
el: HTMLElement,
iframeWin: Window,
): { x: number; y: number; scale: number; rotation: number } {
const gsap = (
iframeWin as unknown as { gsap?: { getProperty?: (el: HTMLElement, prop: string) => number } }
).gsap;
if (gsap && gsap.getProperty) {
return {
x: gsap.getProperty(el, "x") || 0,
y: gsap.getProperty(el, "y") || 0,
scale: gsap.getProperty(el, "scale") || 1,
rotation: gsap.getProperty(el, "rotation") || 0,
};
}
const t = el.style.transform || "";
const scaleMatch = t.match(/scale\(([^)]+)\)/);
const rotMatch = t.match(/rotate\(([^)]+)deg\)/);
const txyMatch = t.match(/translate\(([^,]+)px,\s*([^)]+)px\)/);
return {
x: txyMatch ? parseFloat(txyMatch[1]) : 0,
y: txyMatch ? parseFloat(txyMatch[2]) : 0,
scale: scaleMatch ? parseFloat(scaleMatch[1]) : 1,
rotation: rotMatch ? parseFloat(rotMatch[1]) : 0,
};
}
/**
* Get or create an inline-block wrapper span around a word element.
* Transforms are applied to the wrapper so the word's GSAP animations are preserved.
*/
export function getOrCreateWrapper(el: HTMLElement): HTMLElement {
if (el.dataset.captionWrapper === "true") return el;
const parent = el.parentElement;
if (parent && parent.dataset.captionWrapper === "true") return parent;
const doc = el.ownerDocument;
const wrapper = doc.createElement("span");
wrapper.style.display = "inline-block";
wrapper.dataset.captionWrapper = "true";
el.parentNode?.insertBefore(wrapper, el);
wrapper.appendChild(el);
return wrapper;
}
/**
* Write transform values to a wrapper span around the word element.
*/
export function writeTransform(
el: HTMLElement,
iframeWin: Window,
x: number,
y: number,
scale: number,
rotation: number,
) {
const wrapper = getOrCreateWrapper(el);
const gsap = (
iframeWin as unknown as {
gsap?: { set?: (el: HTMLElement, props: Record<string, number>) => void };
}
).gsap;
if (gsap && gsap.set) {
gsap.set(wrapper, { x, y, scale, rotation });
} else {
wrapper.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rotation.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
}
}
/** Compute style deltas from the current wrapper transform — used by syncToStore in the overlay. */
export function computeTransformStyle(el: HTMLElement, iframeWin: Window): Record<string, number> {
const wrapper = getOrCreateWrapper(el);
const { x, y, scale, rotation } = readGsapTransform(wrapper, iframeWin);
const style: Record<string, number> = {};
if (Math.abs(x) > 0.5) style.x = x;
if (Math.abs(y) > 0.5) style.y = y;
if (Math.abs(scale - 1) > 0.001) {
style.scaleX = scale;
style.scaleY = scale;
}
if (Math.abs(rotation) > 0.1) style.rotation = rotation;
return style;
}