mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-12 20:47:30 +00:00
feat: wrapper-based caption overrides — preserve all GSAP animations
Instead of killing transform tweens, wrap each overridden word span in an inline-block wrapper span. Editor transforms (position, scale, rotation) are applied to the wrapper while the inner span keeps all GSAP entrance, exit, and karaoke animations intact. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7c0f825d72
commit
b6bdce065e
@@ -1,18 +1,17 @@
|
||||
/**
|
||||
* Caption Overrides — applies per-word style overrides from a JSON data file.
|
||||
*
|
||||
* Matching strategy (in priority order):
|
||||
* 1. `wordId` — matches by element ID (e.g. document.getElementById("w0"))
|
||||
* 2. `wordIndex` — fallback, matches by DOM traversal order across .caption-group > span
|
||||
* Strategy: wrap each overridden word span in an inline-block wrapper span,
|
||||
* then apply transforms to the wrapper. The inner span keeps all its original
|
||||
* GSAP animations (entrance, karaoke, exit) untouched. No tweens are killed.
|
||||
*
|
||||
* Using wordId is preferred because it's stable across grouping changes.
|
||||
* wordIndex is supported for backwards compat with older override files.
|
||||
* Matching (in priority order):
|
||||
* 1. `wordId` — matches by element ID (document.getElementById)
|
||||
* 2. `wordIndex` — fallback, DOM traversal order across .caption-group > span
|
||||
*/
|
||||
|
||||
export interface CaptionOverride {
|
||||
/** Stable word ID from transcript.json (e.g. "w0", "w42"). Preferred lookup key. */
|
||||
wordId?: string;
|
||||
/** Fallback: positional index across all .caption-group > span elements. */
|
||||
wordIndex?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
@@ -27,7 +26,6 @@ export interface CaptionOverride {
|
||||
|
||||
interface GsapStatic {
|
||||
set: (target: Element, vars: Record<string, unknown>) => void;
|
||||
killTweensOf: (target: Element, props: string) => void;
|
||||
}
|
||||
|
||||
export function applyCaptionOverrides(): void {
|
||||
@@ -53,7 +51,6 @@ export function applyCaptionOverrides(): void {
|
||||
}
|
||||
|
||||
for (const override of data) {
|
||||
// Resolve element: prefer wordId, fall back to wordIndex
|
||||
let el: Element | null = null;
|
||||
if (override.wordId) {
|
||||
el = document.getElementById(override.wordId);
|
||||
@@ -61,32 +58,37 @@ export function applyCaptionOverrides(): void {
|
||||
if (!el && override.wordIndex !== undefined) {
|
||||
el = wordEls[override.wordIndex] ?? null;
|
||||
}
|
||||
if (!el) continue;
|
||||
if (!el || !(el instanceof HTMLElement)) continue;
|
||||
|
||||
const props: Record<string, unknown> = {};
|
||||
if (override.x !== undefined) props.x = override.x;
|
||||
if (override.y !== undefined) props.y = override.y;
|
||||
if (override.scale !== undefined) props.scale = override.scale;
|
||||
if (override.rotation !== undefined) props.rotation = override.rotation;
|
||||
if (override.color !== undefined) props.color = override.color;
|
||||
if (override.opacity !== undefined) props.opacity = override.opacity;
|
||||
if (override.fontSize !== undefined) props.fontSize = `${override.fontSize}px`;
|
||||
if (override.fontWeight !== undefined) props.fontWeight = override.fontWeight;
|
||||
if (override.fontFamily !== undefined) props.fontFamily = override.fontFamily;
|
||||
// Split overrides into transform props (applied to wrapper)
|
||||
// and style props (applied directly to the word span)
|
||||
const transformProps: Record<string, unknown> = {};
|
||||
const styleProps: Record<string, unknown> = {};
|
||||
|
||||
if (Object.keys(props).length === 0) continue;
|
||||
if (override.x !== undefined) transformProps.x = override.x;
|
||||
if (override.y !== undefined) transformProps.y = override.y;
|
||||
if (override.scale !== undefined) transformProps.scale = override.scale;
|
||||
if (override.rotation !== undefined) transformProps.rotation = override.rotation;
|
||||
if (override.color !== undefined) styleProps.color = override.color;
|
||||
if (override.opacity !== undefined) styleProps.opacity = override.opacity;
|
||||
if (override.fontSize !== undefined) styleProps.fontSize = `${override.fontSize}px`;
|
||||
if (override.fontWeight !== undefined) styleProps.fontWeight = override.fontWeight;
|
||||
if (override.fontFamily !== undefined) styleProps.fontFamily = override.fontFamily;
|
||||
|
||||
// Kill conflicting transform tweens before applying overrides
|
||||
const killProps: string[] = [];
|
||||
if (props.x !== undefined) killProps.push("x");
|
||||
if (props.y !== undefined) killProps.push("y");
|
||||
if (props.scale !== undefined) killProps.push("scale");
|
||||
if (props.rotation !== undefined) killProps.push("rotation");
|
||||
if (killProps.length > 0) {
|
||||
gsap.killTweensOf(el, killProps.join(","));
|
||||
// Apply non-transform style props directly to the word (no conflict with GSAP tweens)
|
||||
if (Object.keys(styleProps).length > 0) {
|
||||
gsap.set(el, styleProps);
|
||||
}
|
||||
|
||||
gsap.set(el, props);
|
||||
// Wrap the word in an inline-block span and apply transforms to the wrapper.
|
||||
// This preserves all GSAP entrance/exit/karaoke animations on the inner span.
|
||||
if (Object.keys(transformProps).length > 0) {
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.style.display = "inline-block";
|
||||
el.parentNode?.insertBefore(wrapper, el);
|
||||
wrapper.appendChild(el);
|
||||
gsap.set(wrapper, transformProps);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
@@ -108,22 +108,39 @@ function readGsapTransform(el: HTMLElement, iframeWin: Window): { x: number; y:
|
||||
}
|
||||
|
||||
/**
|
||||
* Write transform values using gsap.set() so GSAP tracks them properly.
|
||||
* This prevents GSAP from overwriting our changes on next timeline tick.
|
||||
* 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.
|
||||
*/
|
||||
function getOrCreateWrapper(el: HTMLElement): HTMLElement {
|
||||
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.
|
||||
* The word keeps its GSAP animations; the wrapper handles editor transforms.
|
||||
*/
|
||||
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(el, { x, y, scale, rotation });
|
||||
gsap.set(wrapper, { x, y, scale, rotation });
|
||||
} else {
|
||||
// Fallback
|
||||
el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rotation.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
|
||||
wrapper.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rotation.toFixed(1)}deg) scale(${scale.toFixed(3)})`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync canvas state back to the Zustand store so the property panel reflects it */
|
||||
function syncToStore(segmentId: string, el: HTMLElement, iframeWin: Window) {
|
||||
const { x, y, scale, rotation } = readGsapTransform(el, iframeWin);
|
||||
const wrapper = getOrCreateWrapper(el);
|
||||
const { x, y, scale, rotation } = readGsapTransform(wrapper, iframeWin);
|
||||
useCaptionStore.getState().updateSegmentStyle(segmentId, {
|
||||
x, y, rotation, scaleX: scale, scaleY: scale,
|
||||
});
|
||||
@@ -185,7 +202,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({
|
||||
const wordEl = getWordEl(iframe, groupIndex, wordIndex);
|
||||
const win = iframe.contentWindow;
|
||||
if (!wordEl || !win) return;
|
||||
const state = readGsapTransform(wordEl, win);
|
||||
const state = readGsapTransform(getOrCreateWrapper(wordEl), win);
|
||||
interactionRef.current = {
|
||||
type: "move", wordEl, segmentId,
|
||||
startMX: e.clientX, startMY: e.clientY,
|
||||
@@ -205,7 +222,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({
|
||||
const win = iframe.contentWindow;
|
||||
if (!wordEl || !win) return;
|
||||
const rect = wordEl.getBoundingClientRect();
|
||||
const state = readGsapTransform(wordEl, win);
|
||||
const state = readGsapTransform(getOrCreateWrapper(wordEl), win);
|
||||
interactionRef.current = {
|
||||
type: "scale", wordEl, segmentId,
|
||||
startMX: e.clientX, startWidth: rect.width,
|
||||
@@ -227,7 +244,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({
|
||||
const cx = box.x + box.width / 2;
|
||||
const cy = box.y + box.height / 2;
|
||||
const startAngle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI);
|
||||
const state = readGsapTransform(wordEl, win);
|
||||
const state = readGsapTransform(getOrCreateWrapper(wordEl), win);
|
||||
interactionRef.current = {
|
||||
type: "rotate", wordEl, segmentId: box.segmentId,
|
||||
centerX: cx, centerY: cy,
|
||||
|
||||
Reference in New Issue
Block a user