diff --git a/packages/core/src/runtime/captionOverrides.test.ts b/packages/core/src/runtime/captionOverrides.test.ts new file mode 100644 index 000000000..15ff8c050 --- /dev/null +++ b/packages/core/src/runtime/captionOverrides.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { applyCaptionOverrides } from "./captionOverrides"; + +function installCaptionOverrideFetch(overrides: unknown[]) { + vi.stubGlobal("fetch", async () => ({ + ok: true, + async json() { + return overrides; + }, + })); +} + +function installGsapMock() { + const setCalls: Array<{ target: Element; vars: Record }> = []; + const gsap = { + set(target: Element, vars: Record) { + setCalls.push({ target, vars }); + for (const [key, value] of Object.entries(vars)) { + if (key === "fontSize" && typeof value === "string" && target instanceof HTMLElement) { + target.style.fontSize = value; + } + } + }, + killTweensOf() {}, + getTweensOf() { + return []; + }, + }; + Object.defineProperty(window, "gsap", { + configurable: true, + value: gsap, + }); + return { setCalls }; +} + +async function flushCaptionOverrides() { + for (let i = 0; i < 4; i++) { + await Promise.resolve(); + } +} + +afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ""; + Reflect.deleteProperty(window, "gsap"); +}); + +describe("applyCaptionOverrides", () => { + it("reuses existing caption wrappers when overrides are applied more than once", async () => { + const { setCalls } = installGsapMock(); + installCaptionOverrideFetch([{ wordIndex: 0, x: 12, y: -4, scale: 1.2 }]); + document.body.innerHTML = ` +
+ Hello +
+ `; + + applyCaptionOverrides(); + await flushCaptionOverrides(); + applyCaptionOverrides(); + await flushCaptionOverrides(); + + const group = document.querySelector(".caption-group"); + const word = document.getElementById("w0"); + const wrappers = group?.querySelectorAll('[data-caption-wrapper="true"]'); + const wrapper = wrappers?.item(0); + if (!group || !word || !wrapper) throw new Error("Expected wrapped caption word"); + + expect(wrappers).toHaveLength(1); + expect(wrapper.parentElement).toBe(group); + expect(word.parentElement).toBe(wrapper); + expect(setCalls.map((call) => call.target)).toEqual([wrapper, wrapper]); + }); + + it("treats a pre-wrapped word as the wordIndex target, not as another word", async () => { + installGsapMock(); + installCaptionOverrideFetch([{ wordIndex: 0, fontSize: 72 }]); + document.body.innerHTML = ` +
+ + Hello + +
+ `; + + applyCaptionOverrides(); + await flushCaptionOverrides(); + + const word = document.getElementById("w0"); + const wrapper = word?.parentElement; + if (!(word instanceof HTMLElement) || !wrapper) { + throw new Error("Expected pre-wrapped caption word"); + } + + expect(word.style.fontSize).toBe("72px"); + expect(wrapper.getAttribute("style") ?? "").not.toContain("font-size"); + }); + + it("resolves wordId overrides against the inner word of an existing wrapper", async () => { + const { setCalls } = installGsapMock(); + installCaptionOverrideFetch([{ wordId: "w0", x: 16 }]); + document.body.innerHTML = ` +
+ + Hello + +
+ `; + + applyCaptionOverrides(); + await flushCaptionOverrides(); + + const word = document.getElementById("w0"); + const wrapper = word?.parentElement; + if (!(word instanceof HTMLElement) || !wrapper) { + throw new Error("Expected pre-wrapped caption word"); + } + + expect(setCalls).toHaveLength(1); + expect(setCalls[0]?.target).toBe(wrapper); + expect(setCalls[0]?.vars).toEqual({ x: 16 }); + }); +}); diff --git a/packages/core/src/runtime/captionOverrides.ts b/packages/core/src/runtime/captionOverrides.ts index 31378b5b3..bb115fec4 100644 --- a/packages/core/src/runtime/captionOverrides.ts +++ b/packages/core/src/runtime/captionOverrides.ts @@ -38,6 +38,48 @@ interface GsapStatic { getTweensOf: (target: Element) => GsapTween[]; } +function resolveCaptionWordElement(el: Element | null): HTMLElement | null { + if (!(el instanceof HTMLElement)) return null; + if (el.dataset.captionWrapper !== "true") return el; + + const inner = el.querySelector(":scope > span"); + return inner ?? null; +} + +function getCaptionWordElements(): HTMLElement[] { + const wordEls: HTMLElement[] = []; + const groups = document.querySelectorAll(".caption-group"); + + for (const group of groups) { + for (const child of group.children) { + if (!(child instanceof HTMLElement)) continue; + + const wordEl = + child.dataset.captionWrapper === "true" + ? child.querySelector(":scope > span") + : child.tagName === "SPAN" + ? child + : null; + + if (wordEl) wordEls.push(wordEl); + } + } + + return wordEls; +} + +function getOrCreateCaptionWrapper(el: HTMLElement): HTMLElement { + const parent = el.parentElement; + if (parent?.dataset.captionWrapper === "true") return parent; + + const wrapper = document.createElement("span"); + wrapper.style.display = "inline-block"; + wrapper.dataset.captionWrapper = "true"; + el.parentNode?.insertBefore(wrapper, el); + wrapper.appendChild(el); + return wrapper; +} + export function applyCaptionOverrides(): void { const gsap = (window as unknown as { gsap?: GsapStatic }).gsap; if (!gsap) return; @@ -54,24 +96,17 @@ export function applyCaptionOverrides(): void { if (!data || !Array.isArray(data) || data.length === 0) return; // Build word element index for wordIndex fallback - 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); - } - } + const wordEls = getCaptionWordElements(); for (const override of data) { - let el: Element | null = null; + let el: HTMLElement | null = null; if (override.wordId) { - el = document.getElementById(override.wordId); + el = resolveCaptionWordElement(document.getElementById(override.wordId)); } if (!el && override.wordIndex !== undefined) { el = wordEls[override.wordIndex] ?? null; } - if (!el || !(el instanceof HTMLElement)) continue; + if (!el) continue; // Split into transform props (wrapper) and style props (word span) const transformProps: Record = {}; @@ -127,11 +162,7 @@ export function applyCaptionOverrides(): void { // 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"; - wrapper.dataset.captionWrapper = "true"; - el.parentNode?.insertBefore(wrapper, el); - wrapper.appendChild(el); + const wrapper = getOrCreateCaptionWrapper(el); gsap.set(wrapper, transformProps); } } diff --git a/packages/studio/src/captions/generator.test.ts b/packages/studio/src/captions/generator.test.ts index dabcec73b..f9b784242 100644 --- a/packages/studio/src/captions/generator.test.ts +++ b/packages/studio/src/captions/generator.test.ts @@ -137,6 +137,25 @@ describe("generateCaptionHtml", () => { expect(html).toContain('"end": 2.7'); }); + it("includes stable word ids in the transcript and generated word spans", () => { + const transcript: TranscriptWord[] = [ + { id: "word-a", text: "Hello", start: 0, end: 0.4 }, + { id: "word-b", text: "world", start: 0.5, end: 1 }, + ]; + const model = buildCaptionModel(transcript, { + width: 1920, + height: 1080, + duration: 2, + }); + + const html = generateCaptionHtml(model); + + expect(html).toContain('"id": "word-a"'); + expect(html).toContain('"id": "word-b"'); + expect(html).toContain('w_segment_0.id = "word-a";'); + expect(html).toContain('w_segment_1.id = "word-b";'); + }); + it("TRANSCRIPT contains all 7 words from the sample", () => { const model = buildTestModel(); const html = generateCaptionHtml(model); diff --git a/packages/studio/src/captions/generator.ts b/packages/studio/src/captions/generator.ts index e9e3369de..a0a2a45b6 100644 --- a/packages/studio/src/captions/generator.ts +++ b/packages/studio/src/captions/generator.ts @@ -261,14 +261,19 @@ function hexToRgba(color: string, opacity: number): string { function generateJs(model: CaptionModel): string { // Collect all segments across all groups in order - const allSegments: Array<{ text: string; start: number; end: number }> = []; + const allSegments: Array<{ id?: string; text: string; start: number; end: number }> = []; for (const groupId of model.groupOrder) { const group = model.groups.get(groupId); if (!group) continue; for (const segId of group.segmentIds) { const seg = model.segments.get(segId); if (!seg) continue; - allSegments.push({ text: seg.text, start: seg.start, end: seg.end }); + allSegments.push({ + ...(seg.wordId ? { id: seg.wordId } : {}), + text: seg.text, + start: seg.start, + end: seg.end, + }); } } @@ -300,9 +305,11 @@ function generateJs(model: CaptionModel): string { const wordLines: string[] = groupSegments.map((seg) => { const escaped = seg.text.replace(/\\/g, "\\\\").replace(/'/g, "\\'"); const segVar = `w_${seg.id.replace(/[^a-zA-Z0-9_]/g, "_")}`; + const idLine = seg.wordId ? `\n ${segVar}.id = ${JSON.stringify(seg.wordId)};` : ""; return ( ` const ${segVar} = document.createElement('span');` + `\n ${segVar}.className = 'word clip';` + + idLine + `\n ${segVar}.textContent = '${escaped}';` + `\n ${segVar}.dataset.start = '${seg.start}';` + `\n ${segVar}.dataset.end = '${seg.end}';` + diff --git a/packages/studio/src/captions/hooks/useCaptionSync.ts b/packages/studio/src/captions/hooks/useCaptionSync.ts index ee38f36ae..ad5b05221 100644 --- a/packages/studio/src/captions/hooks/useCaptionSync.ts +++ b/packages/studio/src/captions/hooks/useCaptionSync.ts @@ -124,17 +124,22 @@ export function useCaptionSync(projectId: string | null) { const model = state.model; const allSegIds: string[] = []; + const segIdByWordId = new Map(); 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 seg = model.segments.get(segId); + if (seg?.wordId) segIdByWordId.set(seg.wordId, segId); } } const newSegments = new Map(model.segments); for (const override of overrides) { - const segId = allSegIds[override.wordIndex]; + const segId = + (override.wordId ? segIdByWordId.get(override.wordId) : undefined) ?? + allSegIds[override.wordIndex]; if (!segId) continue; const seg = newSegments.get(segId); if (!seg) continue; diff --git a/packages/studio/src/captions/parser.test.ts b/packages/studio/src/captions/parser.test.ts index 0ce5314d4..2b3311ace 100644 --- a/packages/studio/src/captions/parser.test.ts +++ b/packages/studio/src/captions/parser.test.ts @@ -102,6 +102,20 @@ describe("extractTranscript", () => { expect(words).toHaveLength(1); expect(words[0]).toEqual({ text: "Hello", start: 0.0, end: 0.5 }); }); + + it("preserves stable word ids when present", () => { + const words = extractTranscript(` + const TRANSCRIPT = [ + { id: "word-a", text: "Hello", start: 0, end: 0.4 }, + { id: "word-b", text: "world", start: 0.5, end: 1 }, + ]; + `); + + expect(words).toEqual([ + { id: "word-a", text: "Hello", start: 0, end: 0.4 }, + { id: "word-b", text: "world", start: 0.5, end: 1 }, + ]); + }); }); describe("script variable name", () => { diff --git a/packages/studio/src/captions/parser.ts b/packages/studio/src/captions/parser.ts index 77e2c43d9..e17471f72 100644 --- a/packages/studio/src/captions/parser.ts +++ b/packages/studio/src/captions/parser.ts @@ -303,6 +303,7 @@ function parseTranscriptArray(arrayLiteral: string): TranscriptWord[] { ) { const entry = item as Record; words.push({ + ...(typeof entry.id === "string" ? { id: entry.id } : {}), text: entry.text as string, start: entry.start as number, end: entry.end as number,