diff --git a/packages/studio/src/captions/parser.dom.test.ts b/packages/studio/src/captions/parser.dom.test.ts new file mode 100644 index 0000000000..79f9b11b82 --- /dev/null +++ b/packages/studio/src/captions/parser.dom.test.ts @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; +import { generateCaptionHtml } from "./generator"; +import { parseCaptionComposition } from "./parser"; + +const transcript = "We asked what you needed. Forty-seven percent".split(" ").map((text, i) => ({ + id: `word-${i}`, + text, + start: i * 0.5, + end: i * 0.5 + 0.4, +})); +const source = `const TRANSCRIPT = ${JSON.stringify(transcript)};`; + +function createCaptionDocument( + groupSizes: number[], + groupClass = "caption-group", + wordClass = "word", +) { + const doc = document.implementation.createHTMLDocument(); + let offset = 0; + for (const size of groupSizes) { + const group = doc.createElement("div"); + group.className = groupClass; + for (const word of transcript.slice(offset, offset + size)) { + const span = doc.createElement("span"); + span.className = wordClass; + span.textContent = word.text; + group.appendChild(span); + } + doc.body.appendChild(group); + offset += size; + } + return doc; +} + +function importCaptions(doc: Document, captionSource = source) { + const model = parseCaptionComposition(doc, window, captionSource, 1920, 1080, 4); + expect(model).not.toBeNull(); + if (!model) throw new Error("Expected a caption model"); + const groups = model.groupOrder.map((id) => + model.groups.get(id)?.segmentIds.map((segmentId) => model.segments.get(segmentId)?.text), + ); + return { model, groups }; +} + +describe("parseCaptionComposition grouping", () => { + it.each([{ sizes: [5, 2] }, { sizes: [2, 4, 1] }])( + "preserves authored group sizes $sizes", + ({ sizes }) => { + const { model, groups } = importCaptions(createCaptionDocument(sizes)); + expect(groups.map((group) => group?.length)).toEqual(sizes); + expect(groups.flat()).toEqual(transcript.map((word) => word.text)); + expect( + [...model.segments.values()].map(({ wordId, text, start, end }) => ({ + id: wordId, + text, + start, + end, + })), + ).toEqual(transcript); + for (const group of model.groups.values()) { + expect(group.segmentIds.map((id) => model.segments.get(id)?.groupIndex)).toEqual( + group.segmentIds.map((_, index) => index), + ); + } + }, + ); + + it.each(["caption-line", "caption-block"])( + "supports %s with caption-word spans", + (groupClass) => { + const { groups } = importCaptions( + createCaptionDocument([2, 4, 1], groupClass, "caption-word"), + ); + expect(groups.map((group) => group?.length)).toEqual([2, 4, 1]); + }, + ); + + it("does not count nested caption lines as additional groups", () => { + const doc = createCaptionDocument([2, 4, 1]); + for (const group of doc.querySelectorAll(".caption-group")) { + const line = doc.createElement("div"); + line.className = "caption-line"; + line.append(...group.childNodes); + group.appendChild(line); + } + expect(importCaptions(doc).groups.map((group) => group?.length)).toEqual([2, 4, 1]); + }); + + it.each([ + { name: "absent", sizes: [] }, + { name: "incomplete", sizes: [2, 1] }, + { name: "empty", sizes: [0, 0] }, + ])("uses default grouping without losing words when DOM groups are $name", ({ sizes }) => { + const { groups } = importCaptions(createCaptionDocument(sizes)); + expect(groups).toEqual([ + transcript.slice(0, 5).map((word) => word.text), + transcript.slice(5).map((word) => word.text), + ]); + }); + + it("keeps authored boundaries when generated captions are imported again", () => { + const original = importCaptions(createCaptionDocument([2, 4, 1])); + const html = generateCaptionHtml(original.model); + const doc = document.implementation.createHTMLDocument(); + doc.body.innerHTML = html; + const template = doc.querySelector("template"); + if (!template) throw new Error("Expected generated caption template"); + doc.body.replaceChildren(template.content.cloneNode(true)); + const script = doc.querySelector("script")?.textContent; + if (!script) throw new Error("Expected generated caption script"); + // Execute only our generator's output to create its real group and word elements. + new Function("document", "window", "gsap", script)( + doc, + {}, + { + timeline: () => ({ to() {} }), + }, + ); + expect(importCaptions(doc, html).groups).toEqual([ + transcript.slice(0, 2).map((word) => word.text), + transcript.slice(2, 6).map((word) => word.text), + transcript.slice(6).map((word) => word.text), + ]); + }); +}); diff --git a/packages/studio/src/captions/parser.ts b/packages/studio/src/captions/parser.ts index e17471f727..5a41ea2da8 100644 --- a/packages/studio/src/captions/parser.ts +++ b/packages/studio/src/captions/parser.ts @@ -38,23 +38,32 @@ export function buildCaptionModel( transcript: TranscriptWord[], options: BuildOptions, ): CaptionModel { - const { width, height, duration, wordsPerGroup = 5 } = options; + const { wordsPerGroup = 5 } = options; + const wordGroups: TranscriptWord[][] = []; + for (let offset = 0; offset < transcript.length; offset += wordsPerGroup) { + wordGroups.push(transcript.slice(offset, offset + wordsPerGroup)); + } + return buildGroupedCaptionModel(wordGroups, options); +} +function buildGroupedCaptionModel( + wordGroups: TranscriptWord[][], + { width, height, duration }: BuildOptions, +): CaptionModel { const segments = new Map(); const groups = new Map(); const groupOrder: string[] = []; - // Chunk the transcript into groups of wordsPerGroup - for (let groupIdx = 0; groupIdx < transcript.length; groupIdx += wordsPerGroup) { - const chunk = transcript.slice(groupIdx, groupIdx + wordsPerGroup); - const groupId = `group-${groupIdx / wordsPerGroup}`; + for (const chunk of wordGroups) { + const groupId = `group-${groups.size}`; const segmentIds: string[] = []; chunk.forEach((word, wordIdx) => { - const segmentId = `segment-${groupIdx + wordIdx}`; + const segmentIndex = segments.size; + const segmentId = `segment-${segmentIndex}`; const segment: CaptionSegment = { id: segmentId, - wordId: word.id ?? `w${groupIdx + wordIdx}`, + wordId: word.id ?? `w${segmentIndex}`, text: word.text, start: word.start, end: word.end, @@ -96,6 +105,24 @@ export function buildCaptionModel( }; } +const GROUP_SELECTOR = ".caption-group, .caption-line, .caption-block"; +const WORD_SELECTOR = ".word, .caption-word"; + +/** Read outer groups in transcript order; return null if their word counts do not match. */ +function readTranscriptGroups(transcript: TranscriptWord[], groupEls: NodeListOf) { + const wordGroups: TranscriptWord[][] = []; + let offset = 0; + for (const group of groupEls) { + // Caption templates can nest layout lines inside a single timed group. + if (group.parentElement?.closest(GROUP_SELECTOR)) continue; + const size = group.querySelectorAll(WORD_SELECTOR).length; + if (size === 0) continue; + wordGroups.push(transcript.slice(offset, offset + size)); + offset += size; + } + return offset === transcript.length ? wordGroups : null; +} + /** * Extracts a transcript word array from caption composition source code. * @@ -130,7 +157,8 @@ export function extractTranscript(source: string): TranscriptWord[] { * from the source and reading computed styles from rendered elements. * * Runs in the Studio (outside the iframe). Reads computed styles from iframe DOM - * elements to build a fully-styled CaptionModel. + * elements to build a fully-styled CaptionModel. Preserves DOM group boundaries + * when they account for every transcript word; otherwise uses default grouping. * * Returns null if no transcript is found in the source. */ @@ -149,25 +177,19 @@ export function parseCaptionComposition( } // Step 2: Look for grouping and word elements in the iframe DOM - const groupEls = iframeDoc.querySelectorAll(".caption-group, .caption-line, .caption-block"); - const wordEls = iframeDoc.querySelectorAll(".word, .caption-word"); - - // Step 3: Infer wordsPerGroup from element counts - let wordsPerGroup = 5; // default - if (groupEls.length > 0 && wordEls.length > 0) { - wordsPerGroup = Math.round(wordEls.length / groupEls.length); - if (wordsPerGroup < 1) { - wordsPerGroup = 1; - } - } + const groupEls = iframeDoc.querySelectorAll(GROUP_SELECTOR); + const wordEls = iframeDoc.querySelectorAll(WORD_SELECTOR); - // Step 4: Build the caption model with inferred grouping - const model = buildCaptionModel(transcript, { + // Preserve authored boundaries when the DOM accounts for the complete transcript. + const wordGroups = readTranscriptGroups(transcript, groupEls); + const options = { width: compositionWidth, height: compositionHeight, duration: compositionDuration, - wordsPerGroup, - }); + }; + const model = wordGroups + ? buildGroupedCaptionModel(wordGroups, options) + : buildCaptionModel(transcript, options); // Step 5: Read computed styles from the first word or group element const firstWordEl = wordEls.item(0) as Element | null;