@hyperframes/studio 0.2.0 → 0.2.2-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from "vitest";
3
+ import { generateCaptionHtml } from "./generator.js";
4
+ import { buildCaptionModel, TranscriptWord } from "./parser.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Fixtures
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const SAMPLE_TRANSCRIPT: TranscriptWord[] = [
11
+ { text: "We", start: 0.1, end: 0.3 },
12
+ { text: "asked", start: 0.4, end: 0.6 },
13
+ { text: "what", start: 0.7, end: 0.9 },
14
+ { text: "you", start: 1.0, end: 1.2 },
15
+ { text: "needed.", start: 1.3, end: 1.8 },
16
+ { text: "Forty-seven", start: 1.9, end: 2.3 },
17
+ { text: "percent", start: 2.4, end: 2.7 },
18
+ ];
19
+
20
+ function buildTestModel(wordsPerGroup = 5) {
21
+ return buildCaptionModel(SAMPLE_TRANSCRIPT, {
22
+ width: 1920,
23
+ height: 1080,
24
+ duration: 16,
25
+ wordsPerGroup,
26
+ });
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Tests
31
+ // ---------------------------------------------------------------------------
32
+
33
+ describe("generateCaptionHtml", () => {
34
+ describe("HTML structure", () => {
35
+ it("wraps output in a <template id='captions-template'>", () => {
36
+ const model = buildTestModel();
37
+ const html = generateCaptionHtml(model);
38
+ expect(html).toContain('<template id="captions-template">');
39
+ expect(html).toContain("</template>");
40
+ });
41
+
42
+ it("includes correct data-composition-id, data-width, data-height, data-duration attributes", () => {
43
+ const model = buildTestModel();
44
+ const html = generateCaptionHtml(model);
45
+ expect(html).toContain('data-composition-id="captions"');
46
+ expect(html).toContain('data-width="1920"');
47
+ expect(html).toContain('data-height="1080"');
48
+ expect(html).toContain('data-duration="16"');
49
+ });
50
+
51
+ it("includes the captions-container div", () => {
52
+ const model = buildTestModel();
53
+ const html = generateCaptionHtml(model);
54
+ expect(html).toContain('<div id="captions-container"></div>');
55
+ });
56
+
57
+ it("includes a <style> block", () => {
58
+ const model = buildTestModel();
59
+ const html = generateCaptionHtml(model);
60
+ expect(html).toContain("<style>");
61
+ expect(html).toContain("</style>");
62
+ });
63
+
64
+ it("includes a <script> block", () => {
65
+ const model = buildTestModel();
66
+ const html = generateCaptionHtml(model);
67
+ expect(html).toContain("<script>");
68
+ expect(html).toContain("</script>");
69
+ });
70
+ });
71
+
72
+ describe("CSS generation", () => {
73
+ it("includes composition base styles with correct dimensions", () => {
74
+ const model = buildTestModel();
75
+ const html = generateCaptionHtml(model);
76
+ expect(html).toContain('data-composition-id="captions"');
77
+ expect(html).toContain("width: 1920px");
78
+ expect(html).toContain("height: 1080px");
79
+ });
80
+
81
+ it("includes .caption-group base styles with opacity: 0", () => {
82
+ const model = buildTestModel();
83
+ const html = generateCaptionHtml(model);
84
+ expect(html).toContain(".caption-group");
85
+ expect(html).toContain("opacity: 0");
86
+ });
87
+
88
+ it("includes .word base styles with display: inline-block", () => {
89
+ const model = buildTestModel();
90
+ const html = generateCaptionHtml(model);
91
+ expect(html).toContain(".word");
92
+ expect(html).toContain("display: inline-block");
93
+ });
94
+
95
+ it("includes per-group CSS class with font styles from DEFAULT_STYLE", () => {
96
+ const model = buildTestModel();
97
+ const html = generateCaptionHtml(model);
98
+ // DEFAULT_STYLE has fontFamily: "sans-serif" and fontSize: 48
99
+ expect(html).toContain("font-family: sans-serif");
100
+ expect(html).toContain("font-size: 48px");
101
+ });
102
+
103
+ it("includes per-group CSS class for each group", () => {
104
+ const model = buildTestModel();
105
+ const html = generateCaptionHtml(model);
106
+ // Two groups: group-0 and group-1
107
+ expect(html).toContain("group-0");
108
+ expect(html).toContain("group-1");
109
+ });
110
+ });
111
+
112
+ describe("TRANSCRIPT array", () => {
113
+ it("includes a TRANSCRIPT array in the script block", () => {
114
+ const model = buildTestModel();
115
+ const html = generateCaptionHtml(model);
116
+ expect(html).toContain("const TRANSCRIPT =");
117
+ });
118
+
119
+ it("includes all word texts from the transcript", () => {
120
+ const model = buildTestModel();
121
+ const html = generateCaptionHtml(model);
122
+ expect(html).toContain('"We"');
123
+ expect(html).toContain('"asked"');
124
+ expect(html).toContain('"what"');
125
+ expect(html).toContain('"you"');
126
+ expect(html).toContain('"needed."');
127
+ expect(html).toContain('"Forty-seven"');
128
+ expect(html).toContain('"percent"');
129
+ });
130
+
131
+ it("includes start and end timing for words", () => {
132
+ const model = buildTestModel();
133
+ const html = generateCaptionHtml(model);
134
+ expect(html).toContain('"start": 0.1');
135
+ expect(html).toContain('"end": 0.3');
136
+ expect(html).toContain('"start": 1.9');
137
+ expect(html).toContain('"end": 2.7');
138
+ });
139
+
140
+ it("TRANSCRIPT contains all 7 words from the sample", () => {
141
+ const model = buildTestModel();
142
+ const html = generateCaptionHtml(model);
143
+ // Count occurrences of "start" property in the TRANSCRIPT JSON
144
+ const transcriptSection = html.slice(
145
+ html.indexOf("const TRANSCRIPT ="),
146
+ html.indexOf("const TRANSCRIPT =") + 1000,
147
+ );
148
+ const startCount = (transcriptSection.match(/"start":/g) ?? []).length;
149
+ expect(startCount).toBe(7);
150
+ });
151
+ });
152
+
153
+ describe("GSAP timeline", () => {
154
+ it("registers the timeline via window.__timelines", () => {
155
+ const model = buildTestModel();
156
+ const html = generateCaptionHtml(model);
157
+ expect(html).toContain('window.__timelines["captions"]');
158
+ });
159
+
160
+ it("creates a gsap.timeline() call", () => {
161
+ const model = buildTestModel();
162
+ const html = generateCaptionHtml(model);
163
+ expect(html).toContain("gsap.timeline(");
164
+ });
165
+
166
+ it("includes entrance tween with opacity: 1 for each group", () => {
167
+ const model = buildTestModel();
168
+ const html = generateCaptionHtml(model);
169
+ expect(html).toContain("opacity: 1");
170
+ });
171
+
172
+ it("includes exit tween with opacity: 0 for each group", () => {
173
+ const model = buildTestModel();
174
+ const html = generateCaptionHtml(model);
175
+ expect(html).toContain("opacity: 0");
176
+ });
177
+
178
+ it("uses group start time as position for entrance tween", () => {
179
+ const model = buildTestModel();
180
+ const html = generateCaptionHtml(model);
181
+ // First group starts at 0.1 (first word start)
182
+ expect(html).toContain(", 0.1)");
183
+ });
184
+
185
+ it("creates caption-group div elements with class='clip'", () => {
186
+ const model = buildTestModel();
187
+ const html = generateCaptionHtml(model);
188
+ expect(html).toContain("caption-group clip");
189
+ });
190
+
191
+ it("creates word span elements with class='word clip'", () => {
192
+ const model = buildTestModel();
193
+ const html = generateCaptionHtml(model);
194
+ expect(html).toContain("word clip");
195
+ });
196
+
197
+ it("sets data-start and data-end on group elements", () => {
198
+ const model = buildTestModel();
199
+ const html = generateCaptionHtml(model);
200
+ expect(html).toContain("dataset.start");
201
+ expect(html).toContain("dataset.end");
202
+ });
203
+
204
+ it("wraps everything in an IIFE", () => {
205
+ const model = buildTestModel();
206
+ const html = generateCaptionHtml(model);
207
+ expect(html).toContain("(function ()");
208
+ expect(html).toContain("})();");
209
+ });
210
+ });
211
+
212
+ describe("positioning", () => {
213
+ it("centers groups without explicit x/y using transform: translateX(-50%)", () => {
214
+ const model = buildTestModel();
215
+ // DEFAULT_STYLE has x: 0 and y: 0
216
+ const html = generateCaptionHtml(model);
217
+ expect(html).toContain("translateX(-50%)");
218
+ });
219
+
220
+ it("uses absolute left/top when group style has explicit x/y", () => {
221
+ const model = buildTestModel();
222
+ // Override the first group's style to have explicit position
223
+ const firstGroupId = model.groupOrder[0];
224
+ const firstGroup = model.groups.get(firstGroupId);
225
+ if (firstGroup) {
226
+ firstGroup.style = { ...firstGroup.style, x: 200, y: 300 };
227
+ }
228
+ const html = generateCaptionHtml(model);
229
+ expect(html).toContain("200px");
230
+ expect(html).toContain("300px");
231
+ });
232
+ });
233
+
234
+ describe("edge cases", () => {
235
+ it("handles an empty model (no segments or groups) without throwing", () => {
236
+ const model = buildCaptionModel([], {
237
+ width: 1280,
238
+ height: 720,
239
+ duration: 5,
240
+ });
241
+ expect(() => generateCaptionHtml(model)).not.toThrow();
242
+ });
243
+
244
+ it("empty model still produces valid template wrapper", () => {
245
+ const model = buildCaptionModel([], {
246
+ width: 1280,
247
+ height: 720,
248
+ duration: 5,
249
+ });
250
+ const html = generateCaptionHtml(model);
251
+ expect(html).toContain('<template id="captions-template">');
252
+ expect(html).toContain('data-composition-id="captions"');
253
+ });
254
+
255
+ it("handles custom dimensions correctly", () => {
256
+ const model = buildCaptionModel(SAMPLE_TRANSCRIPT, {
257
+ width: 1280,
258
+ height: 720,
259
+ duration: 30,
260
+ });
261
+ const html = generateCaptionHtml(model);
262
+ expect(html).toContain('data-width="1280"');
263
+ expect(html).toContain('data-height="720"');
264
+ expect(html).toContain('data-duration="30"');
265
+ expect(html).toContain("width: 1280px");
266
+ expect(html).toContain("height: 720px");
267
+ });
268
+
269
+ it("words with special characters are escaped in JS output", () => {
270
+ const transcript: TranscriptWord[] = [{ text: "it's", start: 0.0, end: 0.5 }];
271
+ const model = buildCaptionModel(transcript, {
272
+ width: 1920,
273
+ height: 1080,
274
+ duration: 5,
275
+ });
276
+ expect(() => generateCaptionHtml(model)).not.toThrow();
277
+ });
278
+ });
279
+ });
@@ -0,0 +1,376 @@
1
+ // Caption HTML Generator
2
+ // Serializes a CaptionModel into a complete captions.html HyperFrames composition.
3
+
4
+ import type {
5
+ CaptionModel,
6
+ CaptionSegment,
7
+ CaptionStyle,
8
+ CaptionContainerStyle,
9
+ CaptionShadow,
10
+ CaptionGlow,
11
+ } from "./types.js";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Public API
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Serializes a CaptionModel into a complete captions.html composition string.
19
+ *
20
+ * Output format:
21
+ * ```html
22
+ * <template id="captions-template">
23
+ * <div data-composition-id="captions" data-width="..." data-height="..." data-duration="...">
24
+ * <div id="captions-container"></div>
25
+ * <style>/* generated CSS *\/</style>
26
+ * <script>/* generated JS *\/</script>
27
+ * </div>
28
+ * </template>
29
+ * ```
30
+ */
31
+ export function generateCaptionHtml(model: CaptionModel): string {
32
+ const css = generateCss(model);
33
+ const js = generateJs(model);
34
+
35
+ const durationStr = model.duration.toString();
36
+
37
+ return [
38
+ `<template id="captions-template">`,
39
+ ` <div data-composition-id="captions" data-width="${model.width}" data-height="${model.height}" data-duration="${durationStr}">`,
40
+ ` <div id="captions-container"></div>`,
41
+ ` <style>`,
42
+ indent(css, 6),
43
+ ` </style>`,
44
+ ` <script>`,
45
+ indent(js, 6),
46
+ ` </script>`,
47
+ ` </div>`,
48
+ `</template>`,
49
+ ].join("\n");
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // CSS generation
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function generateCss(model: CaptionModel): string {
57
+ const lines: string[] = [];
58
+
59
+ // Base composition styles
60
+ lines.push(
61
+ `[data-composition-id="captions"] {`,
62
+ ` position: relative;`,
63
+ ` width: ${model.width}px;`,
64
+ ` height: ${model.height}px;`,
65
+ ` background: transparent;`,
66
+ ` overflow: hidden;`,
67
+ `}`,
68
+ ``,
69
+ );
70
+
71
+ // Container styles
72
+ lines.push(`#captions-container {`, ` position: absolute;`, ` inset: 0;`, `}`, ``);
73
+
74
+ // .caption-group base styles
75
+ lines.push(
76
+ `.caption-group {`,
77
+ ` position: absolute;`,
78
+ ` display: flex;`,
79
+ ` flex-wrap: wrap;`,
80
+ ` gap: 0.25em;`,
81
+ ` opacity: 0;`,
82
+ `}`,
83
+ ``,
84
+ );
85
+
86
+ // .word base styles
87
+ lines.push(`.word {`, ` display: inline-block;`, `}`, ``);
88
+
89
+ // Per-group CSS classes
90
+ for (const groupId of model.groupOrder) {
91
+ const group = model.groups.get(groupId);
92
+ if (!group) continue;
93
+
94
+ const className = groupId.replace(/[^a-zA-Z0-9-_]/g, "-");
95
+ const styleDecls = buildGroupStyleDecls(group.style, group.containerStyle);
96
+
97
+ if (styleDecls.length > 0) {
98
+ lines.push(`.caption-group.${className} {`);
99
+ for (const decl of styleDecls) {
100
+ lines.push(` ${decl}`);
101
+ }
102
+ lines.push(`}`, ``);
103
+ }
104
+ }
105
+
106
+ return lines.join("\n");
107
+ }
108
+
109
+ function buildGroupStyleDecls(
110
+ style: CaptionStyle,
111
+ containerStyle: CaptionContainerStyle,
112
+ ): string[] {
113
+ const decls: string[] = [];
114
+
115
+ // Typography
116
+ if (style.fontFamily) {
117
+ decls.push(`font-family: ${style.fontFamily};`);
118
+ }
119
+ if (style.fontSize) {
120
+ decls.push(`font-size: ${style.fontSize}px;`);
121
+ }
122
+ if (style.fontWeight) {
123
+ decls.push(`font-weight: ${style.fontWeight};`);
124
+ }
125
+ if (style.fontStyle && style.fontStyle !== "normal") {
126
+ decls.push(`font-style: ${style.fontStyle};`);
127
+ }
128
+ if (style.textDecoration && style.textDecoration !== "none") {
129
+ decls.push(`text-decoration: ${style.textDecoration};`);
130
+ }
131
+ if (style.textTransform && style.textTransform !== "none") {
132
+ decls.push(`text-transform: ${style.textTransform};`);
133
+ }
134
+ if (style.letterSpacing !== 0) {
135
+ decls.push(`letter-spacing: ${style.letterSpacing}em;`);
136
+ }
137
+ if (style.lineHeight) {
138
+ decls.push(`line-height: ${style.lineHeight};`);
139
+ }
140
+
141
+ // Color / fill
142
+ if (style.color) {
143
+ decls.push(`color: ${style.color};`);
144
+ }
145
+ if (style.opacity !== undefined && style.opacity !== 1) {
146
+ // opacity is managed by GSAP animations, but non-default base opacity can be declared
147
+ decls.push(`--caption-base-opacity: ${style.opacity};`);
148
+ }
149
+
150
+ // Stroke (via text-stroke / webkit-text-stroke)
151
+ if (style.strokeWidth > 0) {
152
+ decls.push(`-webkit-text-stroke: ${style.strokeWidth}px ${style.strokeColor};`);
153
+ }
154
+
155
+ // Shadows
156
+ if (style.shadows && style.shadows.length > 0) {
157
+ const shadowStr = style.shadows.map(shadowToCss).join(", ");
158
+ decls.push(`text-shadow: ${shadowStr};`);
159
+ }
160
+
161
+ // Glow (implemented as additional text-shadow)
162
+ if (style.glow) {
163
+ const glowStr = glowToCss(style.glow);
164
+ const existingShadow =
165
+ style.shadows && style.shadows.length > 0
166
+ ? style.shadows.map(shadowToCss).join(", ") + ", "
167
+ : "";
168
+ // Only emit if not already emitted shadows (override the text-shadow if both present)
169
+ if (!(style.shadows && style.shadows.length > 0)) {
170
+ decls.push(`text-shadow: ${glowStr};`);
171
+ } else {
172
+ // Replace the last text-shadow declaration with combined
173
+ const idx = decls.findLastIndex((d) => d.startsWith("text-shadow:"));
174
+ if (idx >= 0) {
175
+ decls[idx] = `text-shadow: ${existingShadow}${glowStr};`;
176
+ }
177
+ }
178
+ }
179
+
180
+ // Blend mode
181
+ if (style.blendMode && style.blendMode !== "normal") {
182
+ decls.push(`mix-blend-mode: ${style.blendMode};`);
183
+ }
184
+
185
+ // Container: background
186
+ if (
187
+ containerStyle.backgroundColor &&
188
+ containerStyle.backgroundColor !== "transparent" &&
189
+ containerStyle.backgroundOpacity > 0
190
+ ) {
191
+ const bg = hexToRgba(containerStyle.backgroundColor, containerStyle.backgroundOpacity);
192
+ decls.push(`background-color: ${bg};`);
193
+ }
194
+
195
+ // Container: padding
196
+ const { paddingTop, paddingRight, paddingBottom, paddingLeft } = containerStyle;
197
+ if (paddingTop > 0 || paddingRight > 0 || paddingBottom > 0 || paddingLeft > 0) {
198
+ decls.push(`padding: ${paddingTop}px ${paddingRight}px ${paddingBottom}px ${paddingLeft}px;`);
199
+ }
200
+
201
+ // Container: border radius
202
+ if (containerStyle.borderRadius > 0) {
203
+ decls.push(`border-radius: ${containerStyle.borderRadius}px;`);
204
+ }
205
+
206
+ // Container: border
207
+ if (containerStyle.borderWidth > 0) {
208
+ decls.push(
209
+ `border: ${containerStyle.borderWidth}px ${containerStyle.borderStyle} ${containerStyle.borderColor};`,
210
+ );
211
+ }
212
+
213
+ // Container: box shadow
214
+ if (containerStyle.boxShadow && containerStyle.boxShadow !== "none") {
215
+ decls.push(`box-shadow: ${containerStyle.boxShadow};`);
216
+ }
217
+
218
+ return decls;
219
+ }
220
+
221
+ function shadowToCss(shadow: CaptionShadow): string {
222
+ return `${shadow.offsetX}px ${shadow.offsetY}px ${shadow.blur}px ${shadow.color}`;
223
+ }
224
+
225
+ function glowToCss(glow: CaptionGlow): string {
226
+ // Glow is represented as a spread text-shadow with opacity applied to color
227
+ return `0 0 ${glow.blur}px ${hexToRgba(glow.color, glow.opacity)}`;
228
+ }
229
+
230
+ /** Converts a hex color and opacity into rgba(...) for CSS */
231
+ function hexToRgba(color: string, opacity: number): string {
232
+ // If it's already rgb/rgba, just return it (can't easily inject opacity)
233
+ if (color.startsWith("rgb")) {
234
+ return color;
235
+ }
236
+ // Named colors and other non-hex values — return as-is
237
+ if (!color.startsWith("#")) {
238
+ return color;
239
+ }
240
+ // Try to parse hex
241
+ const hex = color.replace("#", "");
242
+ if (hex.length === 3 || hex.length === 6) {
243
+ const full =
244
+ hex.length === 3
245
+ ? hex
246
+ .split("")
247
+ .map((c) => c + c)
248
+ .join("")
249
+ : hex;
250
+ const r = parseInt(full.slice(0, 2), 16);
251
+ const g = parseInt(full.slice(2, 4), 16);
252
+ const b = parseInt(full.slice(4, 6), 16);
253
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
254
+ }
255
+ return color;
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // JS generation
260
+ // ---------------------------------------------------------------------------
261
+
262
+ function generateJs(model: CaptionModel): string {
263
+ // Collect all segments across all groups in order
264
+ const allSegments: Array<{ text: string; start: number; end: number }> = [];
265
+ for (const groupId of model.groupOrder) {
266
+ const group = model.groups.get(groupId);
267
+ if (!group) continue;
268
+ for (const segId of group.segmentIds) {
269
+ const seg = model.segments.get(segId);
270
+ if (!seg) continue;
271
+ allSegments.push({ text: seg.text, start: seg.start, end: seg.end });
272
+ }
273
+ }
274
+
275
+ const transcriptJson = JSON.stringify(allSegments, null, 2);
276
+
277
+ const groupBlocks: string[] = [];
278
+
279
+ for (const groupId of model.groupOrder) {
280
+ const group = model.groups.get(groupId);
281
+ if (!group) continue;
282
+
283
+ const className = groupId.replace(/[^a-zA-Z0-9-_]/g, "-");
284
+
285
+ // Compute group start/end from its segments
286
+ const groupSegments = group.segmentIds
287
+ .map((id) => model.segments.get(id))
288
+ .filter((s): s is CaptionSegment => s !== undefined);
289
+
290
+ if (groupSegments.length === 0) continue;
291
+
292
+ const firstSeg = groupSegments[0];
293
+ const lastSeg = groupSegments[groupSegments.length - 1];
294
+ const groupStart = firstSeg.start;
295
+ const groupEnd = lastSeg.end;
296
+
297
+ const groupVar = className.replace(/[^a-zA-Z0-9_]/g, "_");
298
+
299
+ // Build word spans
300
+ const wordLines: string[] = groupSegments.map((seg) => {
301
+ const escaped = seg.text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
302
+ const segVar = `w_${seg.id.replace(/[^a-zA-Z0-9_]/g, "_")}`;
303
+ return (
304
+ ` const ${segVar} = document.createElement('span');` +
305
+ `\n ${segVar}.className = 'word clip';` +
306
+ `\n ${segVar}.textContent = '${escaped}';` +
307
+ `\n ${segVar}.dataset.start = '${seg.start}';` +
308
+ `\n ${segVar}.dataset.end = '${seg.end}';` +
309
+ `\n groupEl_${groupVar}.appendChild(${segVar});`
310
+ );
311
+ });
312
+
313
+ // Position: if x/y non-zero, use absolute with left/top; otherwise center
314
+ const groupVarName = `groupEl_${groupVar}`;
315
+ const hasExplicitPosition = group.style.x !== 0 || group.style.y !== 0;
316
+
317
+ let positionLines: string;
318
+ if (hasExplicitPosition) {
319
+ positionLines = [
320
+ ` ${groupVarName}.style.left = '${group.style.x}px';`,
321
+ ` ${groupVarName}.style.top = '${group.style.y}px';`,
322
+ ].join("\n");
323
+ } else {
324
+ positionLines = [
325
+ ` ${groupVarName}.style.left = '50%';`,
326
+ ` ${groupVarName}.style.top = '80%';`,
327
+ ` ${groupVarName}.style.transform = 'translateX(-50%) translateY(-50%)';`,
328
+ ` ${groupVarName}.style.justifyContent = 'center';`,
329
+ ` ${groupVarName}.style.maxWidth = '90%';`,
330
+ ].join("\n");
331
+ }
332
+
333
+ const block = [
334
+ `// Group: ${groupId}`,
335
+ `const ${groupVarName} = document.createElement('div');`,
336
+ `${groupVarName}.className = 'caption-group clip ${className}';`,
337
+ `${groupVarName}.dataset.start = '${groupStart}';`,
338
+ `${groupVarName}.dataset.end = '${groupEnd}';`,
339
+ `container.appendChild(${groupVarName});`,
340
+ wordLines.join("\n"),
341
+ positionLines,
342
+ `// Entrance: fade in at group start`,
343
+ `tl.to(${groupVarName}, { opacity: 1, duration: 0.2, ease: 'power2.out' }, ${groupStart});`,
344
+ `// Exit: fade out at group end`,
345
+ `tl.to(${groupVarName}, { opacity: 0, duration: 0.2, ease: 'power2.in' }, ${groupEnd} - 0.2);`,
346
+ ].join("\n");
347
+
348
+ groupBlocks.push(block);
349
+ }
350
+
351
+ return `(function () {
352
+ const TRANSCRIPT = ${transcriptJson};
353
+
354
+ const container = document.getElementById('captions-container');
355
+ if (!container) return;
356
+
357
+ const tl = gsap.timeline({ paused: true });
358
+
359
+ ${groupBlocks.join("\n\n ")}
360
+
361
+ if (!window.__timelines) window.__timelines = {};
362
+ window.__timelines["captions"] = tl;
363
+ })();`;
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Utilities
368
+ // ---------------------------------------------------------------------------
369
+
370
+ function indent(text: string, spaces: number): string {
371
+ const pad = " ".repeat(spaces);
372
+ return text
373
+ .split("\n")
374
+ .map((line) => (line.trim() === "" ? "" : pad + line))
375
+ .join("\n");
376
+ }