mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
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:
co-authored by
Claude Opus 4.6
parent
b00f062bdf
commit
5d2efb24c1
@@ -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(() => {});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user