diff --git a/packages/core/src/runtime/captionOverrides.ts b/packages/core/src/runtime/captionOverrides.ts new file mode 100644 index 0000000000..40e461df11 --- /dev/null +++ b/packages/core/src/runtime/captionOverrides.ts @@ -0,0 +1,69 @@ +/** + * Caption Overrides — applies per-word style overrides from a JSON data file. + * + * When a project has a caption-overrides.json, the runtime loads it after all + * timelines are registered and applies gsap.set() to each overridden word + * element, matched by word index in DOM traversal order. + */ + +export interface CaptionOverride { + wordIndex: number; + x?: number; + y?: number; + scale?: number; + rotation?: number; + color?: string; + opacity?: number; + fontSize?: number; + fontWeight?: number; + fontFamily?: string; +} + +interface GsapStatic { + set: (target: Element, vars: Record) => void; +} + +export function applyCaptionOverrides(): void { + const gsap = (window as unknown as { gsap?: GsapStatic }).gsap; + if (!gsap) return; + + fetch("caption-overrides.json") + .then((r) => { + if (!r.ok) return null; + return r.json(); + }) + .then((data: CaptionOverride[] | null) => { + if (!data || !Array.isArray(data) || data.length === 0) return; + + const wordEls: Element[] = []; + const groups = document.querySelectorAll(".caption-group"); + for (const group of groups) { + const spans = group.querySelectorAll(":scope > span"); + for (const span of spans) { + wordEls.push(span); + } + } + if (wordEls.length === 0) return; + + for (const override of data) { + const el = wordEls[override.wordIndex]; + if (!el) 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; + + if (Object.keys(props).length > 0) { + gsap.set(el, props); + } + } + }) + .catch(() => {}); +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index a73739f37d..3d821ddef9 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -12,6 +12,7 @@ import { createRuntimeState } from "./state"; import { collectRuntimeTimelinePayload } from "./timeline"; import { createRuntimeStartTimeResolver } from "./startResolver"; import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader"; +import { applyCaptionOverrides } from "./captionOverrides"; import type { RuntimeDeterministicAdapter, RuntimeJson, RuntimeTimelineLike } from "./types"; import type { PlayerAPI } from "../core.types"; @@ -1316,6 +1317,7 @@ export function initSandboxRuntimeModular(): void { runAdapters("discover", state.currentTime); bindMediaMetadataListeners(); installAssetFailureDiagnostics(); + applyCaptionOverrides(); postTimeline(); postState(true); }); diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 964b19f8c5..3394a7a47a 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -592,7 +592,6 @@ export function StudioApp() { const store = useCaptionStore.getState(); if (store.isEditMode) { store.reset(); - captionSync.clearCache(); return; } const pid = projectIdRef.current; @@ -616,7 +615,7 @@ export function StudioApp() { store.setSourceFilePath(activeCompPath); store.setEditMode(true); setRightCollapsed(false); - captionSync.cacheOriginalSource(); + captionSync.loadOverrides(); }} className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${ captionEditMode diff --git a/packages/studio/src/captions/hooks/useCaptionSync.ts b/packages/studio/src/captions/hooks/useCaptionSync.ts index eceb4e47b5..c0f43ec833 100644 --- a/packages/studio/src/captions/hooks/useCaptionSync.ts +++ b/packages/studio/src/captions/hooks/useCaptionSync.ts @@ -2,158 +2,132 @@ import { useCallback, useRef } from "react"; import { useCaptionStore } from "../store"; import type { CaptionStyle } from "../types"; -/** - * Build gsap props object string for a word element's style overrides. - */ -function buildGsapProps(style: Partial): string | null { - const props: string[] = []; - if (style.x !== undefined) props.push(`x: ${style.x.toFixed(1)}`); - if (style.y !== undefined) props.push(`y: ${style.y.toFixed(1)}`); - if (style.scaleX !== undefined) props.push(`scale: ${style.scaleX.toFixed(3)}`); - if (style.rotation !== undefined) props.push(`rotation: ${style.rotation.toFixed(1)}`); - if (style.color !== undefined) props.push(`color: "${style.color}"`); - if (style.opacity !== undefined) props.push(`opacity: ${style.opacity}`); - if (style.fontSize !== undefined) props.push(`fontSize: "${style.fontSize}px"`); - if (style.fontWeight !== undefined) props.push(`fontWeight: ${style.fontWeight}`); - if (props.length === 0) return null; - return `{ ${props.join(", ")} }`; +interface CaptionOverrideEntry { + wordIndex: number; + x?: number; + y?: number; + scale?: number; + rotation?: number; + color?: string; + opacity?: number; + fontSize?: number; + fontWeight?: number; + fontFamily?: string; } -const PATCH_START = "// -- Caption Editor Overrides (auto-generated) --"; -const PATCH_END = "// -- End Caption Editor Overrides --"; - -/** - * Generate a patch block that adds tl.set() overrides to the GSAP timeline. - * Uses each word's start time from the transcript so the overrides apply - * AFTER the entrance animation, preventing the timeline from overwriting them. - * Also adds a standalone gsap.set() for the initial seek-to-0 state. - */ -function generatePatchBlock(model: { +function buildOverrides(model: { groupOrder: string[]; groups: Map; - segments: Map }>; -}): string | null { - const entries: Array<{ elementId: string; startTime: number; endTime: number; props: string }> = []; - for (let gi = 0; gi < model.groupOrder.length; gi++) { - const group = model.groups.get(model.groupOrder[gi]); + segments: Map }>; +}): CaptionOverrideEntry[] { + const entries: CaptionOverrideEntry[] = []; + let globalWordIndex = 0; + + for (const groupId of model.groupOrder) { + const group = model.groups.get(groupId); if (!group) continue; - for (let wi = 0; wi < group.segmentIds.length; wi++) { - const seg = model.segments.get(group.segmentIds[wi]); - if (!seg || Object.keys(seg.style).length === 0) continue; - const props = buildGsapProps(seg.style); - if (!props) continue; - entries.push({ elementId: `w-${gi}-${wi}`, startTime: seg.start, endTime: seg.end, props }); + for (const segId of group.segmentIds) { + const seg = model.segments.get(segId); + if (seg && Object.keys(seg.style).length > 0) { + const entry: CaptionOverrideEntry = { wordIndex: globalWordIndex }; + const s = seg.style; + if (s.x !== undefined) entry.x = s.x; + if (s.y !== undefined) entry.y = s.y; + if (s.scaleX !== undefined) entry.scale = s.scaleX; + if (s.rotation !== undefined) entry.rotation = s.rotation; + if (s.color !== undefined) entry.color = s.color; + if (s.opacity !== undefined) entry.opacity = s.opacity; + if (s.fontSize !== undefined) entry.fontSize = s.fontSize; + if (s.fontWeight !== undefined) entry.fontWeight = s.fontWeight as number; + if (s.fontFamily !== undefined) entry.fontFamily = s.fontFamily; + entries.push(entry); + } + globalWordIndex++; } } - if (entries.length === 0) return null; - // Determine which GSAP properties each entry overrides - function overriddenGsapProps(props: string): string[] { - const out: string[] = []; - if (props.includes("x:")) out.push("x"); - if (props.includes("y:")) out.push("y"); - if (props.includes("scale:")) out.push("scale"); - if (props.includes("rotation:")) out.push("rotation"); - return out; - } - - const lines: string[] = []; - lines.push(" " + PATCH_START); - lines.push(" // For each edited word: selectively kill only the transform properties"); - lines.push(" // that the editor overrides, preserving opacity/color/karaoke tweens."); - lines.push(" (function() {"); - lines.push(" var tl = window.__timelines && window.__timelines['captions'];"); - lines.push(" if (!tl) return;"); - for (const e of entries) { - const v = `el_${e.elementId.replace(/-/g, "_")}`; - const killProps = overriddenGsapProps(e.props); - lines.push(` var ${v} = document.getElementById("${e.elementId}");`); - lines.push(` if (${v}) {`); - // Use GSAP's property-specific kill: killTweensOf(target, "x,y") - // This kills only the specific properties, leaving opacity/color/textShadow intact - lines.push(` gsap.killTweensOf(${v}, "${killProps.join(",")}");`); - // Apply our values - lines.push(` gsap.set(${v}, ${e.props});`); - lines.push(" }"); - } - lines.push(" })();"); - lines.push(" " + PATCH_END); - return lines.join("\n"); -} - -function applyPatchToSource(source: string, patchBlock: string | null): string { - // Remove existing patch block - const startIdx = source.indexOf(PATCH_START); - const endIdx = source.indexOf(PATCH_END); - let cleaned = source; - if (startIdx >= 0 && endIdx >= 0) { - cleaned = source.slice(0, startIdx).trimEnd() + "\n" + source.slice(endIdx + PATCH_END.length); - } - if (!patchBlock) return cleaned; - const lastScriptClose = cleaned.lastIndexOf(""); - if (lastScriptClose < 0) return cleaned; - return ( - cleaned.slice(0, lastScriptClose).trimEnd() + - "\n\n" + patchBlock + "\n " + - cleaned.slice(lastScriptClose) - ); + return entries; } /** - * Hook that provides a manual save function for caption edits. - * Does NOT auto-save — call `save()` explicitly. + * Provides save/load for caption overrides as a JSON data file. + * Writes caption-overrides.json next to the composition — never modifies the HTML source. */ export function useCaptionSync(projectId: string | null) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; - const originalSourceRef = useRef(null); - /** Cache the original source when entering edit mode */ - const cacheOriginalSource = useCallback(async () => { - const state = useCaptionStore.getState(); - const pid = projectIdRef.current; - if (!pid || !state.sourceFilePath) return; - const res = await fetch( - `/api/projects/${pid}/files/${encodeURIComponent(state.sourceFilePath)}`, - ); - const data = await res.json(); - if (!data.content) return; - // Strip existing patches to get clean source - let clean = data.content as string; - const si = clean.indexOf(PATCH_START); - const ei = clean.indexOf(PATCH_END); - if (si >= 0 && ei >= 0) { - clean = clean.slice(0, si).trimEnd() + "\n" + clean.slice(ei + PATCH_END.length); - } - originalSourceRef.current = clean; - }, []); - - /** Save current edits as patches to the source file */ const save = useCallback(async () => { const state = useCaptionStore.getState(); if (!state.model || !state.sourceFilePath) return; const pid = projectIdRef.current; if (!pid) return; - // Ensure we have the original source - if (!originalSourceRef.current) { - await cacheOriginalSource(); - } - if (!originalSourceRef.current) return; - - const patchBlock = generatePatchBlock(state.model); - const patched = applyPatchToSource(originalSourceRef.current, patchBlock); + const overrides = buildOverrides(state.model); + const dir = state.sourceFilePath.replace(/[^/]+$/, ""); + const overridesPath = `${dir}caption-overrides.json`; await fetch( - `/api/projects/${pid}/files/${encodeURIComponent(state.sourceFilePath)}`, - { method: "PUT", headers: { "Content-Type": "text/plain" }, body: patched }, - ); - }, [cacheOriginalSource]); - - /** Clear cached source when exiting edit mode */ - const clearCache = useCallback(() => { - originalSourceRef.current = null; + `/api/projects/${pid}/files/${encodeURIComponent(overridesPath)}`, + { method: "PUT", headers: { "Content-Type": "text/plain" }, body: JSON.stringify(overrides, null, 2) }, + ).catch(() => {}); }, []); - return { save, cacheOriginalSource, clearCache }; + const loadOverrides = useCallback(async () => { + const state = useCaptionStore.getState(); + if (!state.model || !state.sourceFilePath) return; + const pid = projectIdRef.current; + if (!pid) return; + + const dir = state.sourceFilePath.replace(/[^/]+$/, ""); + const overridesPath = `${dir}caption-overrides.json`; + + try { + const res = await fetch( + `/api/projects/${pid}/files/${encodeURIComponent(overridesPath)}`, + ); + const data = await res.json(); + if (!data.content) return; + + const overrides: CaptionOverrideEntry[] = JSON.parse(data.content); + if (!Array.isArray(overrides)) return; + + const model = state.model; + const allSegIds: string[] = []; + for (const groupId of model.groupOrder) { + const group = model.groups.get(groupId); + if (!group) continue; + for (const segId of group.segmentIds) { + allSegIds.push(segId); + } + } + + const newSegments = new Map(model.segments); + for (const override of overrides) { + const segId = allSegIds[override.wordIndex]; + if (!segId) continue; + const seg = newSegments.get(segId); + if (!seg) continue; + + const style: Partial = { ...seg.style }; + if (override.x !== undefined) style.x = override.x; + if (override.y !== undefined) style.y = override.y; + if (override.scale !== undefined) { style.scaleX = override.scale; style.scaleY = override.scale; } + if (override.rotation !== undefined) style.rotation = override.rotation; + if (override.color !== undefined) style.color = override.color; + if (override.opacity !== undefined) style.opacity = override.opacity; + if (override.fontSize !== undefined) style.fontSize = override.fontSize; + if (override.fontWeight !== undefined) style.fontWeight = override.fontWeight; + if (override.fontFamily !== undefined) style.fontFamily = override.fontFamily; + + newSegments.set(segId, { ...seg, style }); + } + + useCaptionStore.getState().setModel({ ...model, segments: newSegments }); + } catch { + // No overrides file + } + }, []); + + return { save, loadOverrides }; } diff --git a/packages/studio/src/captions/parser.ts b/packages/studio/src/captions/parser.ts index 0535c30511..7e88d9c703 100644 --- a/packages/studio/src/captions/parser.ts +++ b/packages/studio/src/captions/parser.ts @@ -255,61 +255,9 @@ export function parseCaptionComposition( } } - // Step 8: Parse existing editor overrides from the source and apply to segments - parseExistingOverrides(source, model); - return model; } -/** - * Parse existing Caption Editor Override gsap.set() calls from the source - * and apply them to the model's segment styles. This preserves edits across - * edit mode re-entries. - */ -function parseExistingOverrides(source: string, model: CaptionModel): void { - // Match both formats: - // gsap.set("#w-0-1", { x: 10.0, ... }); - // gsap.set(el_w_0_1, { x: 10.0, ... }); - const setPattern = /gsap\.set\((?:"#w-(\d+)-(\d+)"|el_w_(\d+)_(\d+)),\s*\{([^}]+)\}\)/g; - let match; - while ((match = setPattern.exec(source)) !== null) { - // Groups 1,2 for "#w-gi-wi" format; groups 3,4 for el_w_gi_wi format - const gi = parseInt(match[1] ?? match[3], 10); - const wi = parseInt(match[2] ?? match[4], 10); - const propsStr = match[5]; - if (isNaN(gi) || isNaN(wi) || !propsStr) continue; - - // Find the segment - const groupId = model.groupOrder[gi]; - if (!groupId) continue; - const group = model.groups.get(groupId); - if (!group || wi >= group.segmentIds.length) continue; - const segId = group.segmentIds[wi]; - const seg = model.segments.get(segId); - if (!seg) continue; - - // Parse individual properties - const style: Partial = {}; - const xMatch = propsStr.match(/\bx:\s*(-?[\d.]+)/); - const yMatch = propsStr.match(/\by:\s*(-?[\d.]+)/); - const scaleMatch = propsStr.match(/\bscale:\s*(-?[\d.]+)/); - const rotMatch = propsStr.match(/\brotation:\s*(-?[\d.]+)/); - const colorMatch = propsStr.match(/\bcolor:\s*"([^"]+)"/); - const opacityMatch = propsStr.match(/\bopacity:\s*(-?[\d.]+)/); - - if (xMatch) style.x = parseFloat(xMatch[1]); - if (yMatch) style.y = parseFloat(yMatch[1]); - if (scaleMatch) { style.scaleX = parseFloat(scaleMatch[1]); style.scaleY = parseFloat(scaleMatch[1]); } - if (rotMatch) style.rotation = parseFloat(rotMatch[1]); - if (colorMatch) style.color = colorMatch[1]; - if (opacityMatch) style.opacity = parseFloat(opacityMatch[1]); - - if (Object.keys(style).length > 0) { - model.segments.set(segId, { ...seg, style: { ...seg.style, ...style } }); - } - } -} - /** * Parses a JS array literal containing `{ text, start, end }` objects. *