From 7b5cfea5e8a9b4d9d5dfc5c2fddfa4148e50a542 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 31 Mar 2026 22:28:21 -0700 Subject: [PATCH] feat(studio): build caption model from transcript words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `buildCaptionModel` to the caption parser — groups TranscriptWord arrays into CaptionGroups (default 5 words each), creates CaptionSegments with correct timing and groupIndex, and wires in DEFAULT_STYLE, DEFAULT_CONTAINER, and DEFAULT_ANIMATION_SET. Covers 23 tests (13 new). Co-Authored-By: Claude Sonnet 4.6 --- packages/studio/src/captions/parser.test.ts | 200 +++++++++++++++++++- packages/studio/src/captions/parser.ts | 89 ++++++++- 2 files changed, 286 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/captions/parser.test.ts b/packages/studio/src/captions/parser.test.ts index 8aa1fda3b5..36fb68e880 100644 --- a/packages/studio/src/captions/parser.test.ts +++ b/packages/studio/src/captions/parser.test.ts @@ -1,6 +1,7 @@ // @vitest-environment node import { describe, it, expect } from "vitest"; -import { extractTranscript } from "./parser.js"; +import { extractTranscript, buildCaptionModel, TranscriptWord } from "./parser.js"; +import { DEFAULT_STYLE, DEFAULT_CONTAINER, DEFAULT_ANIMATION_SET } from "./types.js"; // --------------------------------------------------------------------------- // Fixtures @@ -177,3 +178,200 @@ describe("extractTranscript", () => { }); }); }); + +// --------------------------------------------------------------------------- +// buildCaptionModel tests +// --------------------------------------------------------------------------- + +const SEVEN_WORDS: TranscriptWord[] = [ + { text: "We", start: 0.1, end: 0.3 }, + { text: "asked", start: 0.4, end: 0.6 }, + { text: "what", start: 0.7, end: 0.9 }, + { text: "you", start: 1.0, end: 1.2 }, + { text: "needed.", start: 1.3, end: 1.8 }, + { text: "Forty-seven", start: 1.9, end: 2.3 }, + { text: "percent", start: 2.4, end: 2.7 }, +]; + +describe("buildCaptionModel", () => { + describe("grouping", () => { + it("produces 2 groups for 7 words with wordsPerGroup=5 (5 + 2)", () => { + const model = buildCaptionModel(SEVEN_WORDS, { + width: 1920, + height: 1080, + duration: 10, + wordsPerGroup: 5, + }); + expect(model.groupOrder).toHaveLength(2); + expect(model.groups.size).toBe(2); + }); + + it("first group has 5 segments and second group has 2 segments", () => { + const model = buildCaptionModel(SEVEN_WORDS, { + width: 1920, + height: 1080, + duration: 10, + wordsPerGroup: 5, + }); + const firstGroupId = model.groupOrder[0]; + const secondGroupId = model.groupOrder[1]; + expect(model.groups.get(firstGroupId)?.segmentIds).toHaveLength(5); + expect(model.groups.get(secondGroupId)?.segmentIds).toHaveLength(2); + }); + + it("uses default wordsPerGroup of 5 when not specified", () => { + const model = buildCaptionModel(SEVEN_WORDS, { + width: 1280, + height: 720, + duration: 5, + }); + expect(model.groupOrder).toHaveLength(2); + }); + }); + + describe("segments", () => { + it("segments have correct text matching the transcript words", () => { + const model = buildCaptionModel(SEVEN_WORDS, { + width: 1920, + height: 1080, + duration: 10, + wordsPerGroup: 5, + }); + expect(model.segments.size).toBe(7); + + const firstGroupId = model.groupOrder[0]; + const firstGroup = model.groups.get(firstGroupId); + const firstSegmentId = firstGroup?.segmentIds[0]; + const firstSegment = firstSegmentId ? model.segments.get(firstSegmentId) : undefined; + expect(firstSegment?.text).toBe("We"); + + const secondGroupId = model.groupOrder[1]; + const secondGroup = model.groups.get(secondGroupId); + const sixthSegmentId = secondGroup?.segmentIds[0]; + const sixthSegment = sixthSegmentId ? model.segments.get(sixthSegmentId) : undefined; + expect(sixthSegment?.text).toBe("Forty-seven"); + }); + + it("segments have correct start and end timing from the transcript", () => { + const model = buildCaptionModel(SEVEN_WORDS, { + width: 1920, + height: 1080, + duration: 10, + wordsPerGroup: 5, + }); + const firstGroupId = model.groupOrder[0]; + const firstGroup = model.groups.get(firstGroupId); + const segId = firstGroup?.segmentIds[4]; + const fifthSegment = segId ? model.segments.get(segId) : undefined; + expect(fifthSegment?.start).toBe(1.3); + expect(fifthSegment?.end).toBe(1.8); + expect(fifthSegment?.text).toBe("needed."); + }); + + it("segments have correct groupIndex reflecting position within their group", () => { + const model = buildCaptionModel(SEVEN_WORDS, { + width: 1920, + height: 1080, + duration: 10, + wordsPerGroup: 5, + }); + const secondGroupId = model.groupOrder[1]; + const secondGroup = model.groups.get(secondGroupId); + const segId = secondGroup?.segmentIds[1]; + const segment = segId ? model.segments.get(segId) : undefined; + expect(segment?.groupIndex).toBe(1); + }); + }); + + describe("model dimensions", () => { + it("stores correct width, height, and duration on the model", () => { + const model = buildCaptionModel(SEVEN_WORDS, { + width: 1920, + height: 1080, + duration: 30.5, + wordsPerGroup: 5, + }); + expect(model.width).toBe(1920); + expect(model.height).toBe(1080); + expect(model.duration).toBe(30.5); + }); + }); + + describe("default styles", () => { + it("groups have DEFAULT_STYLE applied", () => { + const model = buildCaptionModel(SEVEN_WORDS, { + width: 1920, + height: 1080, + duration: 10, + wordsPerGroup: 5, + }); + const firstGroupId = model.groupOrder[0]; + const group = model.groups.get(firstGroupId); + expect(group?.style).toEqual(DEFAULT_STYLE); + }); + + it("groups have DEFAULT_CONTAINER applied", () => { + const model = buildCaptionModel(SEVEN_WORDS, { + width: 1920, + height: 1080, + duration: 10, + wordsPerGroup: 5, + }); + const firstGroupId = model.groupOrder[0]; + const group = model.groups.get(firstGroupId); + expect(group?.containerStyle).toEqual(DEFAULT_CONTAINER); + }); + + it("groups have DEFAULT_ANIMATION_SET applied", () => { + const model = buildCaptionModel(SEVEN_WORDS, { + width: 1920, + height: 1080, + duration: 10, + wordsPerGroup: 5, + }); + const firstGroupId = model.groupOrder[0]; + const group = model.groups.get(firstGroupId); + expect(group?.animation.entrance).toEqual(DEFAULT_ANIMATION_SET.entrance); + expect(group?.animation.highlight).toBe(DEFAULT_ANIMATION_SET.highlight); + expect(group?.animation.exit).toEqual(DEFAULT_ANIMATION_SET.exit); + }); + + it("model defaultAnimation matches DEFAULT_ANIMATION_SET", () => { + const model = buildCaptionModel(SEVEN_WORDS, { + width: 1920, + height: 1080, + duration: 10, + wordsPerGroup: 5, + }); + expect(model.defaultAnimation.entrance).toEqual(DEFAULT_ANIMATION_SET.entrance); + expect(model.defaultAnimation.highlight).toBe(DEFAULT_ANIMATION_SET.highlight); + expect(model.defaultAnimation.exit).toEqual(DEFAULT_ANIMATION_SET.exit); + }); + }); + + describe("edge cases", () => { + it("handles an empty transcript returning a model with no segments or groups", () => { + const model = buildCaptionModel([], { + width: 1920, + height: 1080, + duration: 10, + wordsPerGroup: 5, + }); + expect(model.segments.size).toBe(0); + expect(model.groups.size).toBe(0); + expect(model.groupOrder).toHaveLength(0); + }); + + it("handles transcript with exactly wordsPerGroup words producing 1 group", () => { + const fiveWords = SEVEN_WORDS.slice(0, 5); + const model = buildCaptionModel(fiveWords, { + width: 1920, + height: 1080, + duration: 10, + wordsPerGroup: 5, + }); + expect(model.groupOrder).toHaveLength(1); + expect(model.segments.size).toBe(5); + }); + }); +}); diff --git a/packages/studio/src/captions/parser.ts b/packages/studio/src/captions/parser.ts index 1019878b9c..73d9b9bb6d 100644 --- a/packages/studio/src/captions/parser.ts +++ b/packages/studio/src/captions/parser.ts @@ -1,5 +1,15 @@ -// Caption Parser — Extract Transcript -// Parses a caption composition's JavaScript source to extract the transcript word array. +// Caption Parser — Extract Transcript & Build Caption Model +// Parses a caption composition's JavaScript source to extract the transcript word array, +// and builds a CaptionModel from a TranscriptWord array. + +import { + CaptionModel, + CaptionSegment, + CaptionGroup, + DEFAULT_STYLE, + DEFAULT_CONTAINER, + DEFAULT_ANIMATION_SET, +} from "./types.js"; export interface TranscriptWord { text: string; @@ -7,6 +17,81 @@ export interface TranscriptWord { end: number; } +export interface BuildOptions { + width: number; + height: number; + duration: number; + wordsPerGroup?: number; // default 5 +} + +/** + * Builds a CaptionModel from a transcript word array and composition dimensions. + * + * Words are grouped into chunks of `wordsPerGroup` (default 5). Each word becomes a + * CaptionSegment with its original timing. Each chunk becomes a CaptionGroup with + * DEFAULT_STYLE, DEFAULT_ANIMATION_SET, and DEFAULT_CONTAINER. + */ +export function buildCaptionModel( + transcript: TranscriptWord[], + options: BuildOptions, +): CaptionModel { + const { width, height, duration, wordsPerGroup = 5 } = options; + + 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}`; + const segmentIds: string[] = []; + + chunk.forEach((word, wordIdx) => { + const segmentId = `segment-${groupIdx + wordIdx}`; + const segment: CaptionSegment = { + id: segmentId, + text: word.text, + start: word.start, + end: word.end, + groupIndex: wordIdx, + style: {}, + animation: {}, + }; + segments.set(segmentId, segment); + segmentIds.push(segmentId); + }); + + const group: CaptionGroup = { + id: groupId, + segmentIds, + style: { ...DEFAULT_STYLE }, + animation: { + entrance: { ...DEFAULT_ANIMATION_SET.entrance }, + highlight: DEFAULT_ANIMATION_SET.highlight, + exit: { ...DEFAULT_ANIMATION_SET.exit }, + }, + containerStyle: { ...DEFAULT_CONTAINER }, + }; + groups.set(groupId, group); + groupOrder.push(groupId); + } + + return { + width, + height, + duration, + segments, + groups, + groupOrder, + defaultAnimation: { + entrance: { ...DEFAULT_ANIMATION_SET.entrance }, + highlight: DEFAULT_ANIMATION_SET.highlight, + exit: { ...DEFAULT_ANIMATION_SET.exit }, + }, + }; +} + /** * Extracts a transcript word array from caption composition source code. *