Skip to content

Commit 8d83d4f

Browse files
authored
fix: make caption overrides refresh-safe (#609)
## Summary This stacked PR makes caption overrides refresh-safe. Caption edits are still saved to `caption-overrides.json`, but override targets are now stable across preview refreshes and regenerated caption HTML. ## Architecture - **Stable word identity**: generated caption HTML preserves optional transcript word IDs in the `TRANSCRIPT` array and emits those IDs on word spans. - **Parser continuity**: the caption parser preserves existing transcript word `id` fields instead of regenerating index-only identity. - **Override loading**: Studio loads saved overrides by `wordId` first, with the existing `wordIndex` fallback kept for older overrides. - **Idempotent runtime wrapping**: transform overrides reuse an existing `data-caption-wrapper="true"` wrapper instead of nesting wrappers on every refresh. - **Animation compatibility**: overrides still wrap the word so inner word-level GSAP animation can continue to target the original span. ## User Impact Users can edit caption word position, scale, rotation, color, opacity, font size, font weight, and font family, then refresh without overrides drifting to the wrong word or accumulating nested wrappers. ## Main Files - `packages/core/src/runtime/captionOverrides.ts` - `packages/studio/src/captions/generator.ts` - `packages/studio/src/captions/parser.ts` - `packages/studio/src/captions/hooks/useCaptionSync.ts` ## Test Plan ```bash volta run --node 22.20.0 bun run --filter @hyperframes/core test -- src/runtime/captionOverrides.test.ts volta run --node 22.20.0 packages/studio/node_modules/.bin/vitest run --root packages/studio --config /dev/null src/captions/parser.test.ts src/captions/generator.test.ts volta run --node 22.20.0 bun run --filter @hyperframes/core typecheck volta run --node 22.20.0 bun run --filter @hyperframes/studio typecheck volta run --node 22.20.0 bunx oxlint <changed files> volta run --node 22.20.0 bunx oxfmt --check <changed files> git diff --check ```
1 parent d0abe90 commit 8d83d4f

7 files changed

Lines changed: 220 additions & 19 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { applyCaptionOverrides } from "./captionOverrides";
4+
5+
function installCaptionOverrideFetch(overrides: unknown[]) {
6+
vi.stubGlobal("fetch", async () => ({
7+
ok: true,
8+
async json() {
9+
return overrides;
10+
},
11+
}));
12+
}
13+
14+
function installGsapMock() {
15+
const setCalls: Array<{ target: Element; vars: Record<string, unknown> }> = [];
16+
const gsap = {
17+
set(target: Element, vars: Record<string, unknown>) {
18+
setCalls.push({ target, vars });
19+
for (const [key, value] of Object.entries(vars)) {
20+
if (key === "fontSize" && typeof value === "string" && target instanceof HTMLElement) {
21+
target.style.fontSize = value;
22+
}
23+
}
24+
},
25+
killTweensOf() {},
26+
getTweensOf() {
27+
return [];
28+
},
29+
};
30+
Object.defineProperty(window, "gsap", {
31+
configurable: true,
32+
value: gsap,
33+
});
34+
return { setCalls };
35+
}
36+
37+
async function flushCaptionOverrides() {
38+
for (let i = 0; i < 4; i++) {
39+
await Promise.resolve();
40+
}
41+
}
42+
43+
afterEach(() => {
44+
vi.unstubAllGlobals();
45+
document.body.innerHTML = "";
46+
Reflect.deleteProperty(window, "gsap");
47+
});
48+
49+
describe("applyCaptionOverrides", () => {
50+
it("reuses existing caption wrappers when overrides are applied more than once", async () => {
51+
const { setCalls } = installGsapMock();
52+
installCaptionOverrideFetch([{ wordIndex: 0, x: 12, y: -4, scale: 1.2 }]);
53+
document.body.innerHTML = `
54+
<div class="caption-group">
55+
<span id="w0">Hello</span>
56+
</div>
57+
`;
58+
59+
applyCaptionOverrides();
60+
await flushCaptionOverrides();
61+
applyCaptionOverrides();
62+
await flushCaptionOverrides();
63+
64+
const group = document.querySelector(".caption-group");
65+
const word = document.getElementById("w0");
66+
const wrappers = group?.querySelectorAll('[data-caption-wrapper="true"]');
67+
const wrapper = wrappers?.item(0);
68+
if (!group || !word || !wrapper) throw new Error("Expected wrapped caption word");
69+
70+
expect(wrappers).toHaveLength(1);
71+
expect(wrapper.parentElement).toBe(group);
72+
expect(word.parentElement).toBe(wrapper);
73+
expect(setCalls.map((call) => call.target)).toEqual([wrapper, wrapper]);
74+
});
75+
76+
it("treats a pre-wrapped word as the wordIndex target, not as another word", async () => {
77+
installGsapMock();
78+
installCaptionOverrideFetch([{ wordIndex: 0, fontSize: 72 }]);
79+
document.body.innerHTML = `
80+
<div class="caption-group">
81+
<span data-caption-wrapper="true">
82+
<span id="w0">Hello</span>
83+
</span>
84+
</div>
85+
`;
86+
87+
applyCaptionOverrides();
88+
await flushCaptionOverrides();
89+
90+
const word = document.getElementById("w0");
91+
const wrapper = word?.parentElement;
92+
if (!(word instanceof HTMLElement) || !wrapper) {
93+
throw new Error("Expected pre-wrapped caption word");
94+
}
95+
96+
expect(word.style.fontSize).toBe("72px");
97+
expect(wrapper.getAttribute("style") ?? "").not.toContain("font-size");
98+
});
99+
100+
it("resolves wordId overrides against the inner word of an existing wrapper", async () => {
101+
const { setCalls } = installGsapMock();
102+
installCaptionOverrideFetch([{ wordId: "w0", x: 16 }]);
103+
document.body.innerHTML = `
104+
<div class="caption-group">
105+
<span data-caption-wrapper="true">
106+
<span id="w0">Hello</span>
107+
</span>
108+
</div>
109+
`;
110+
111+
applyCaptionOverrides();
112+
await flushCaptionOverrides();
113+
114+
const word = document.getElementById("w0");
115+
const wrapper = word?.parentElement;
116+
if (!(word instanceof HTMLElement) || !wrapper) {
117+
throw new Error("Expected pre-wrapped caption word");
118+
}
119+
120+
expect(setCalls).toHaveLength(1);
121+
expect(setCalls[0]?.target).toBe(wrapper);
122+
expect(setCalls[0]?.vars).toEqual({ x: 16 });
123+
});
124+
});

packages/core/src/runtime/captionOverrides.ts

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,48 @@ interface GsapStatic {
3838
getTweensOf: (target: Element) => GsapTween[];
3939
}
4040

41+
function resolveCaptionWordElement(el: Element | null): HTMLElement | null {
42+
if (!(el instanceof HTMLElement)) return null;
43+
if (el.dataset.captionWrapper !== "true") return el;
44+
45+
const inner = el.querySelector<HTMLElement>(":scope > span");
46+
return inner ?? null;
47+
}
48+
49+
function getCaptionWordElements(): HTMLElement[] {
50+
const wordEls: HTMLElement[] = [];
51+
const groups = document.querySelectorAll(".caption-group");
52+
53+
for (const group of groups) {
54+
for (const child of group.children) {
55+
if (!(child instanceof HTMLElement)) continue;
56+
57+
const wordEl =
58+
child.dataset.captionWrapper === "true"
59+
? child.querySelector<HTMLElement>(":scope > span")
60+
: child.tagName === "SPAN"
61+
? child
62+
: null;
63+
64+
if (wordEl) wordEls.push(wordEl);
65+
}
66+
}
67+
68+
return wordEls;
69+
}
70+
71+
function getOrCreateCaptionWrapper(el: HTMLElement): HTMLElement {
72+
const parent = el.parentElement;
73+
if (parent?.dataset.captionWrapper === "true") return parent;
74+
75+
const wrapper = document.createElement("span");
76+
wrapper.style.display = "inline-block";
77+
wrapper.dataset.captionWrapper = "true";
78+
el.parentNode?.insertBefore(wrapper, el);
79+
wrapper.appendChild(el);
80+
return wrapper;
81+
}
82+
4183
export function applyCaptionOverrides(): void {
4284
const gsap = (window as unknown as { gsap?: GsapStatic }).gsap;
4385
if (!gsap) return;
@@ -54,24 +96,17 @@ export function applyCaptionOverrides(): void {
5496
if (!data || !Array.isArray(data) || data.length === 0) return;
5597

5698
// Build word element index for wordIndex fallback
57-
const wordEls: Element[] = [];
58-
const groups = document.querySelectorAll(".caption-group");
59-
for (const group of groups) {
60-
const spans = group.querySelectorAll(":scope > span");
61-
for (const span of spans) {
62-
wordEls.push(span);
63-
}
64-
}
99+
const wordEls = getCaptionWordElements();
65100

66101
for (const override of data) {
67-
let el: Element | null = null;
102+
let el: HTMLElement | null = null;
68103
if (override.wordId) {
69-
el = document.getElementById(override.wordId);
104+
el = resolveCaptionWordElement(document.getElementById(override.wordId));
70105
}
71106
if (!el && override.wordIndex !== undefined) {
72107
el = wordEls[override.wordIndex] ?? null;
73108
}
74-
if (!el || !(el instanceof HTMLElement)) continue;
109+
if (!el) continue;
75110

76111
// Split into transform props (wrapper) and style props (word span)
77112
const transformProps: Record<string, unknown> = {};
@@ -127,11 +162,7 @@ export function applyCaptionOverrides(): void {
127162
// Wrap the word in an inline-block span and apply transforms to the wrapper.
128163
// This preserves all GSAP entrance/exit/karaoke animations on the inner span.
129164
if (Object.keys(transformProps).length > 0) {
130-
const wrapper = document.createElement("span");
131-
wrapper.style.display = "inline-block";
132-
wrapper.dataset.captionWrapper = "true";
133-
el.parentNode?.insertBefore(wrapper, el);
134-
wrapper.appendChild(el);
165+
const wrapper = getOrCreateCaptionWrapper(el);
135166
gsap.set(wrapper, transformProps);
136167
}
137168
}

packages/studio/src/captions/generator.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,25 @@ describe("generateCaptionHtml", () => {
137137
expect(html).toContain('"end": 2.7');
138138
});
139139

140+
it("includes stable word ids in the transcript and generated word spans", () => {
141+
const transcript: TranscriptWord[] = [
142+
{ id: "word-a", text: "Hello", start: 0, end: 0.4 },
143+
{ id: "word-b", text: "world", start: 0.5, end: 1 },
144+
];
145+
const model = buildCaptionModel(transcript, {
146+
width: 1920,
147+
height: 1080,
148+
duration: 2,
149+
});
150+
151+
const html = generateCaptionHtml(model);
152+
153+
expect(html).toContain('"id": "word-a"');
154+
expect(html).toContain('"id": "word-b"');
155+
expect(html).toContain('w_segment_0.id = "word-a";');
156+
expect(html).toContain('w_segment_1.id = "word-b";');
157+
});
158+
140159
it("TRANSCRIPT contains all 7 words from the sample", () => {
141160
const model = buildTestModel();
142161
const html = generateCaptionHtml(model);

packages/studio/src/captions/generator.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,14 +261,19 @@ function hexToRgba(color: string, opacity: number): string {
261261

262262
function generateJs(model: CaptionModel): string {
263263
// Collect all segments across all groups in order
264-
const allSegments: Array<{ text: string; start: number; end: number }> = [];
264+
const allSegments: Array<{ id?: string; text: string; start: number; end: number }> = [];
265265
for (const groupId of model.groupOrder) {
266266
const group = model.groups.get(groupId);
267267
if (!group) continue;
268268
for (const segId of group.segmentIds) {
269269
const seg = model.segments.get(segId);
270270
if (!seg) continue;
271-
allSegments.push({ text: seg.text, start: seg.start, end: seg.end });
271+
allSegments.push({
272+
...(seg.wordId ? { id: seg.wordId } : {}),
273+
text: seg.text,
274+
start: seg.start,
275+
end: seg.end,
276+
});
272277
}
273278
}
274279

@@ -300,9 +305,11 @@ function generateJs(model: CaptionModel): string {
300305
const wordLines: string[] = groupSegments.map((seg) => {
301306
const escaped = seg.text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
302307
const segVar = `w_${seg.id.replace(/[^a-zA-Z0-9_]/g, "_")}`;
308+
const idLine = seg.wordId ? `\n ${segVar}.id = ${JSON.stringify(seg.wordId)};` : "";
303309
return (
304310
` const ${segVar} = document.createElement('span');` +
305311
`\n ${segVar}.className = 'word clip';` +
312+
idLine +
306313
`\n ${segVar}.textContent = '${escaped}';` +
307314
`\n ${segVar}.dataset.start = '${seg.start}';` +
308315
`\n ${segVar}.dataset.end = '${seg.end}';` +

packages/studio/src/captions/hooks/useCaptionSync.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,17 +124,22 @@ export function useCaptionSync(projectId: string | null) {
124124

125125
const model = state.model;
126126
const allSegIds: string[] = [];
127+
const segIdByWordId = new Map<string, string>();
127128
for (const groupId of model.groupOrder) {
128129
const group = model.groups.get(groupId);
129130
if (!group) continue;
130131
for (const segId of group.segmentIds) {
131132
allSegIds.push(segId);
133+
const seg = model.segments.get(segId);
134+
if (seg?.wordId) segIdByWordId.set(seg.wordId, segId);
132135
}
133136
}
134137

135138
const newSegments = new Map(model.segments);
136139
for (const override of overrides) {
137-
const segId = allSegIds[override.wordIndex];
140+
const segId =
141+
(override.wordId ? segIdByWordId.get(override.wordId) : undefined) ??
142+
allSegIds[override.wordIndex];
138143
if (!segId) continue;
139144
const seg = newSegments.get(segId);
140145
if (!seg) continue;

packages/studio/src/captions/parser.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,20 @@ describe("extractTranscript", () => {
102102
expect(words).toHaveLength(1);
103103
expect(words[0]).toEqual({ text: "Hello", start: 0.0, end: 0.5 });
104104
});
105+
106+
it("preserves stable word ids when present", () => {
107+
const words = extractTranscript(`
108+
const TRANSCRIPT = [
109+
{ id: "word-a", text: "Hello", start: 0, end: 0.4 },
110+
{ id: "word-b", text: "world", start: 0.5, end: 1 },
111+
];
112+
`);
113+
114+
expect(words).toEqual([
115+
{ id: "word-a", text: "Hello", start: 0, end: 0.4 },
116+
{ id: "word-b", text: "world", start: 0.5, end: 1 },
117+
]);
118+
});
105119
});
106120

107121
describe("script variable name", () => {

packages/studio/src/captions/parser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ function parseTranscriptArray(arrayLiteral: string): TranscriptWord[] {
303303
) {
304304
const entry = item as Record<string, unknown>;
305305
words.push({
306+
...(typeof entry.id === "string" ? { id: entry.id } : {}),
306307
text: entry.text as string,
307308
start: entry.start as number,
308309
end: entry.end as number,

0 commit comments

Comments
 (0)