From b6bdce065ec640dc622bbaf8427f95698934705f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 1 Apr 2026 11:09:16 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20wrapper-based=20caption=20overrides=20?= =?UTF-8?q?=E2=80=94=20preserve=20all=20GSAP=20animations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/core/src/runtime/captionOverrides.ts | 62 ++++++++++--------- .../captions/components/CaptionOverlay.tsx | 35 ++++++++--- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/packages/core/src/runtime/captionOverrides.ts b/packages/core/src/runtime/captionOverrides.ts index a09f1758a0..4b60782a45 100644 --- a/packages/core/src/runtime/captionOverrides.ts +++ b/packages/core/src/runtime/captionOverrides.ts @@ -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) => 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 = {}; - 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 = {}; + const styleProps: Record = {}; - 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(() => {}); diff --git a/packages/studio/src/captions/components/CaptionOverlay.tsx b/packages/studio/src/captions/components/CaptionOverlay.tsx index ce915edf5a..ae64d3c6c5 100644 --- a/packages/studio/src/captions/components/CaptionOverlay.tsx +++ b/packages/studio/src/captions/components/CaptionOverlay.tsx @@ -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) => 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,