Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions packages/studio/src/captions/parser.dom.test.ts
Original file line number Diff line number Diff line change
@@ -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),
]);
});
});
68 changes: 45 additions & 23 deletions packages/studio/src/captions/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CaptionSegment>();
const groups = new Map<string, CaptionGroup>();
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,
Expand Down Expand Up @@ -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<Element>) {
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.
*
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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;
Expand Down
Loading