feat: replace HTML patching with caption-overrides.json data file

- New caption-overrides.json format: [{wordIndex, x, y, scale, rotation, color, ...}]
- Runtime hook in packages/core applies overrides via gsap.set() after compositions load
- Studio writes/reads caption-overrides.json instead of injecting code into HTML
- Original composition source is never modified
- Remove old parseExistingOverrides and source patching code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-04-01 23:37:11 -07:00
co-authored by Claude Opus 4.6
parent b00f062bdf
commit 5d2efb24c1
5 changed files with 174 additions and 182 deletions
@@ -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<string, unknown>) => 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<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;
if (Object.keys(props).length > 0) {
gsap.set(el, props);
}
}
})
.catch(() => {});
}
+2
View File
@@ -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);
});
+1 -2
View File
@@ -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
@@ -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<CaptionStyle>): 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<string, { segmentIds: string[] }>;
segments: Map<string, { start: number; end: number; style: Partial<CaptionStyle> }>;
}): 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<string, { style: Partial<CaptionStyle> }>;
}): 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("</script>");
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<string | null>(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<CaptionStyle> = { ...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 };
}
-52
View File
@@ -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<CaptionStyle> = {};
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.
*