@program-video/cli 0.1.12 → 0.2.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,322 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ getComposition,
4
+ outputError,
5
+ outputSuccess,
6
+ updateScene
7
+ } from "./chunk-2PMNEXGG.js";
8
+
9
+ // src/commands/audio/captions.ts
10
+ import { existsSync, readFileSync } from "fs";
11
+ import path from "path";
12
+ import ora from "ora";
13
+ var AUDIO_CACHE_DIR = "/tmp/program-video-cache/audio";
14
+ function findTranscriptForScene(sceneId) {
15
+ const patterns = [`${sceneId}-transcript.json`, `${sceneId}.json`];
16
+ for (const pattern of patterns) {
17
+ const filePath = path.join(AUDIO_CACHE_DIR, pattern);
18
+ if (existsSync(filePath)) {
19
+ try {
20
+ return JSON.parse(readFileSync(filePath, "utf-8"));
21
+ } catch {
22
+ continue;
23
+ }
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+ function buildCaptionEntries(audioScenes, transcripts, accentWords) {
29
+ const entries = [];
30
+ for (const scene of audioScenes) {
31
+ const transcript = transcripts.get(scene.id);
32
+ if (!transcript) continue;
33
+ const timeOffset = scene.start;
34
+ for (const segment of transcript.segments) {
35
+ if (segment.words && segment.words.length > 0) {
36
+ for (const word of segment.words) {
37
+ const cleanWord = word.word.trim();
38
+ if (!cleanWord) continue;
39
+ entries.push({
40
+ text: cleanWord,
41
+ start: word.start + timeOffset,
42
+ end: word.end + timeOffset,
43
+ accent: accentWords.has(
44
+ cleanWord.toLowerCase().replace(/[^a-z]/g, "")
45
+ )
46
+ });
47
+ }
48
+ } else {
49
+ entries.push({
50
+ text: segment.text.trim(),
51
+ start: segment.start + timeOffset,
52
+ end: segment.end + timeOffset
53
+ });
54
+ }
55
+ }
56
+ }
57
+ return entries;
58
+ }
59
+ function generateCaptionCode(entries, opts) {
60
+ const { style, fontSize, accentColor, totalDuration } = opts;
61
+ const captionFontFamily = 'Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
62
+ const captionFontFamilyLiteral = JSON.stringify(captionFontFamily);
63
+ const entriesJson = JSON.stringify(
64
+ entries.map((e) => ({
65
+ t: e.text,
66
+ s: Math.round(e.start * 1e3) / 1e3,
67
+ e: Math.round(e.end * 1e3) / 1e3,
68
+ ...e.accent ? { a: true } : {}
69
+ }))
70
+ );
71
+ if (style === "subtitle") {
72
+ return `import { useCurrentFrame, useVideoConfig, AbsoluteFill } from "remotion";
73
+
74
+ const entries = ${entriesJson};
75
+ const TOTAL = ${totalDuration};
76
+
77
+ export default function Captions() {
78
+ const frame = useCurrentFrame();
79
+ const { fps } = useVideoConfig();
80
+ const t = frame / fps;
81
+
82
+ const active = entries.filter(e => t >= e.s && t < e.e);
83
+ const text = active.map(e => e.t).join(" ");
84
+
85
+ return (
86
+ <AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", padding: "0 40px 80px" }}>
87
+ {text && (
88
+ <div style={{
89
+ fontFamily: ${captionFontFamilyLiteral},
90
+ fontSize: ${fontSize},
91
+ fontWeight: 800,
92
+ color: "#fff",
93
+ textShadow: "0 2px 8px rgba(0,0,0,0.8)",
94
+ textAlign: "center",
95
+ lineHeight: 1.3,
96
+ }}>
97
+ {text}
98
+ </div>
99
+ )}
100
+ <div style={{
101
+ position: "absolute", bottom: 0, left: 0, right: 0, height: 4,
102
+ background: "rgba(255,255,255,0.15)",
103
+ }}>
104
+ <div style={{
105
+ height: "100%", background: "${accentColor}",
106
+ width: \`\${Math.min(100, (t / TOTAL) * 100)}%\`,
107
+ }} />
108
+ </div>
109
+ </AbsoluteFill>
110
+ );
111
+ }`;
112
+ }
113
+ return `import { useCurrentFrame, useVideoConfig, AbsoluteFill, interpolate, Easing } from "remotion";
114
+
115
+ const entries = ${entriesJson};
116
+ const TOTAL = ${totalDuration};
117
+ const WORDS_PER_GROUP = 4;
118
+
119
+ // Pre-compute phrase groups: chunks of N words based on timing gaps
120
+ const groups = [];
121
+ let current = [];
122
+ for (let i = 0; i < entries.length; i++) {
123
+ current.push(entries[i]);
124
+ const gap = i < entries.length - 1 ? entries[i + 1].s - entries[i].e : 999;
125
+ if (current.length >= WORDS_PER_GROUP || gap > 0.4) {
126
+ groups.push({ words: [...current], s: current[0].s, e: current[current.length - 1].e });
127
+ current = [];
128
+ }
129
+ }
130
+ if (current.length > 0) {
131
+ groups.push({ words: [...current], s: current[0].s, e: current[current.length - 1].e });
132
+ }
133
+
134
+ export default function Captions() {
135
+ const frame = useCurrentFrame();
136
+ const { fps } = useVideoConfig();
137
+ const t = frame / fps;
138
+
139
+ const group = groups.find(g => t >= g.s - 0.05 && t < g.e + 0.1);
140
+ if (!group) return null;
141
+
142
+ const groupStartF = group.s * fps;
143
+ const groupEndF = Math.max(group.s + 0.2, group.e) * fps;
144
+ const fadeIn = Math.min(groupStartF + 3, groupEndF - 0.5);
145
+ const fadeOut = Math.max(fadeIn + 0.5, groupEndF);
146
+ const opacity = interpolate(frame, [groupStartF, fadeIn, fadeOut, fadeOut + 2], [0, 1, 1, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
147
+
148
+ return (
149
+ <AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: "14%" }}>
150
+ <div style={{
151
+ display: "flex", flexWrap: "wrap", justifyContent: "center", gap: "0 10px",
152
+ maxWidth: "85%", opacity,
153
+ fontFamily: ${captionFontFamilyLiteral},
154
+ }}>
155
+ {group.words.map((w, i) => {
156
+ const isActive = t >= w.s && t < w.e + 0.05;
157
+ const isPast = t >= w.e + 0.05;
158
+ const wordStartF = w.s * fps;
159
+ const scale = isActive
160
+ ? interpolate(frame, [wordStartF, Math.min(wordStartF + 3, wordStartF + Math.max(0.15, w.e - w.s) * fps)], [0.95, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.cubic) })
161
+ : 1;
162
+ return (
163
+ <span key={i} style={{
164
+ fontSize: ${fontSize},
165
+ fontWeight: 900,
166
+ color: isActive ? (w.a ? "${accentColor}" : "#fff") : isPast ? "rgba(255,255,255,0.5)" : "rgba(255,255,255,0.35)",
167
+ textShadow: isActive ? "0 2px 12px rgba(0,0,0,0.7), 0 0 40px rgba(0,0,0,0.3)" : "0 2px 8px rgba(0,0,0,0.5)",
168
+ transform: \`scale(\${scale})\`,
169
+ letterSpacing: "-0.02em",
170
+ textTransform: "uppercase",
171
+ transition: "color 0.1s ease",
172
+ }}>
173
+ {w.t}
174
+ </span>
175
+ );
176
+ })}
177
+ </div>
178
+ <div style={{
179
+ position: "absolute", bottom: 0, left: 0, right: 0, height: 4,
180
+ background: "rgba(255,255,255,0.15)",
181
+ }}>
182
+ <div style={{
183
+ height: "100%", background: "${accentColor}",
184
+ width: \`\${Math.min(100, (t / TOTAL) * 100)}%\`,
185
+ }} />
186
+ </div>
187
+ </AbsoluteFill>
188
+ );
189
+ }`;
190
+ }
191
+ async function captionsCommand(compositionId, options) {
192
+ if (!compositionId) {
193
+ outputError("Composition ID is required", options);
194
+ process.exit(1);
195
+ }
196
+ const style = options.style ?? "tiktok";
197
+ const fontSize = parseInt(options.fontSize ?? "64", 10);
198
+ const accentColor = options.accentColor ?? "#ff3900";
199
+ const accentWords = new Set(
200
+ (options.accentWords ?? "").split(",").map((w) => w.trim().toLowerCase()).filter(Boolean)
201
+ );
202
+ const showSpinner = !options.json && process.stdout.isTTY;
203
+ const spinner = showSpinner ? ora("Loading composition...").start() : null;
204
+ const composition = await getComposition(compositionId);
205
+ if (!composition) {
206
+ spinner?.stop();
207
+ outputError(`Composition not found: ${compositionId}`, options);
208
+ process.exit(1);
209
+ }
210
+ const scenes = composition.scenes ?? [];
211
+ const audioScenes = scenes.filter((s) => s.type === "audio_scene" || s.type === "video_scene").sort((a, b) => a.start - b.start);
212
+ if (audioScenes.length === 0) {
213
+ spinner?.stop();
214
+ outputError("No audio scenes found in composition", options);
215
+ process.exit(1);
216
+ }
217
+ if (spinner) spinner.text = "Loading transcripts...";
218
+ const transcripts = /* @__PURE__ */ new Map();
219
+ if (options.transcript && options.transcript.length > 0) {
220
+ for (let i = 0; i < Math.min(options.transcript.length, audioScenes.length); i++) {
221
+ const filePath = options.transcript[i];
222
+ if (!filePath || !existsSync(filePath)) {
223
+ spinner?.stop();
224
+ outputError(`Transcript file not found: ${filePath ?? ""}`, options);
225
+ process.exit(1);
226
+ }
227
+ try {
228
+ const data = JSON.parse(
229
+ readFileSync(filePath, "utf-8")
230
+ );
231
+ const scene = audioScenes[i];
232
+ if (scene) transcripts.set(scene.id, data);
233
+ } catch {
234
+ spinner?.stop();
235
+ outputError(`Failed to parse transcript: ${filePath}`, options);
236
+ process.exit(1);
237
+ }
238
+ }
239
+ } else {
240
+ for (const scene of audioScenes) {
241
+ const transcript = findTranscriptForScene(scene.id);
242
+ if (transcript) {
243
+ transcripts.set(scene.id, transcript);
244
+ }
245
+ }
246
+ }
247
+ if (transcripts.size === 0) {
248
+ spinner?.stop();
249
+ outputError(
250
+ "No transcripts found. Either provide --transcript files or run 'program audio transcribe' first and save output to the cache directory.",
251
+ options
252
+ );
253
+ process.exit(1);
254
+ }
255
+ if (spinner) spinner.text = "Building captions...";
256
+ const entries = buildCaptionEntries(audioScenes, transcripts, accentWords);
257
+ if (entries.length === 0) {
258
+ spinner?.stop();
259
+ outputError("No caption entries generated from transcripts", options);
260
+ process.exit(1);
261
+ }
262
+ const totalDuration = Number(composition.duration ?? 0);
263
+ const code = generateCaptionCode(entries, {
264
+ style,
265
+ fontSize,
266
+ accentColor,
267
+ totalDuration
268
+ });
269
+ const captionSceneId = options.scene ?? scenes.find((s) => s.id === "visual-captions")?.id ?? scenes.find((s) => {
270
+ const meta = s.metadata;
271
+ return meta?.title === "Captions" || meta?.title === "Visual Captions";
272
+ })?.id;
273
+ if (captionSceneId) {
274
+ if (spinner) spinner.text = "Updating composition...";
275
+ await updateScene(compositionId, captionSceneId, {
276
+ metadata: {
277
+ files: { "index.tsx": code },
278
+ role: "captions",
279
+ title: "Visual Captions"
280
+ }
281
+ });
282
+ }
283
+ spinner?.stop();
284
+ if (options.json) {
285
+ outputSuccess(
286
+ {
287
+ compositionId,
288
+ entries: entries.length,
289
+ style,
290
+ updatedScene: captionSceneId ?? null,
291
+ transcriptsUsed: Array.from(transcripts.keys())
292
+ },
293
+ options
294
+ );
295
+ } else {
296
+ console.log(`
297
+ Captions built:`);
298
+ console.log(` Style: ${style}`);
299
+ console.log(` Entries: ${entries.length} words`);
300
+ console.log(` Audio parts: ${transcripts.size}`);
301
+ console.log(` Duration: ${totalDuration.toFixed(1)}s`);
302
+ if (accentWords.size > 0) {
303
+ console.log(` Accented: ${Array.from(accentWords).join(", ")}`);
304
+ }
305
+ if (captionSceneId) {
306
+ console.log(` Updated: scene "${captionSceneId}"`);
307
+ } else {
308
+ console.log(`
309
+ No captions scene found in composition.`);
310
+ console.log(
311
+ ` Code generated but not applied. Use --scene to specify target scene.`
312
+ );
313
+ }
314
+ }
315
+ }
316
+
317
+ export {
318
+ buildCaptionEntries,
319
+ generateCaptionCode,
320
+ captionsCommand
321
+ };
322
+ //# sourceMappingURL=chunk-Z5WNLASJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/commands/audio/captions.ts"],"sourcesContent":["import { existsSync, readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport ora from \"ora\";\n\nimport type { OutputOptions } from \"../../lib/output.js\";\nimport { getComposition, updateScene } from \"../../lib/local-convex.js\";\nimport { outputError, outputSuccess } from \"../../lib/output.js\";\n\nconst AUDIO_CACHE_DIR = \"/tmp/program-video-cache/audio\";\n\nexport interface CaptionsOptions extends OutputOptions {\n transcript?: string[];\n accentWords?: string;\n style?: string;\n scene?: string;\n fontSize?: string;\n accentColor?: string;\n}\n\ninterface WhisperWord {\n word: string;\n start: number;\n end: number;\n}\n\ninterface WhisperSegment {\n text: string;\n start: number;\n end: number;\n words?: WhisperWord[];\n}\n\nexport interface WhisperResult {\n text: string;\n segments: WhisperSegment[];\n}\n\nexport interface CaptionSourceScene {\n id: string;\n type: string;\n start: number;\n end: number;\n duration: number;\n order: number;\n}\n\nexport interface CaptionEntry {\n text: string;\n start: number;\n end: number;\n accent?: boolean;\n}\n\nfunction findTranscriptForScene(sceneId: string): WhisperResult | null {\n // Look for transcript JSON in the cache directory\n const patterns = [`${sceneId}-transcript.json`, `${sceneId}.json`];\n for (const pattern of patterns) {\n const filePath = path.join(AUDIO_CACHE_DIR, pattern);\n if (existsSync(filePath)) {\n try {\n return JSON.parse(readFileSync(filePath, \"utf-8\")) as WhisperResult;\n } catch {\n continue;\n }\n }\n }\n return null;\n}\n\nexport function buildCaptionEntries(\n audioScenes: CaptionSourceScene[],\n transcripts: Map<string, WhisperResult>,\n accentWords: Set<string>,\n): CaptionEntry[] {\n const entries: CaptionEntry[] = [];\n\n for (const scene of audioScenes) {\n const transcript = transcripts.get(scene.id);\n if (!transcript) continue;\n\n const timeOffset = scene.start;\n\n for (const segment of transcript.segments) {\n if (segment.words && segment.words.length > 0) {\n // Word-level captions\n for (const word of segment.words) {\n const cleanWord = word.word.trim();\n if (!cleanWord) continue;\n entries.push({\n text: cleanWord,\n start: word.start + timeOffset,\n end: word.end + timeOffset,\n accent: accentWords.has(\n cleanWord.toLowerCase().replace(/[^a-z]/g, \"\"),\n ),\n });\n }\n } else {\n // Segment-level fallback\n entries.push({\n text: segment.text.trim(),\n start: segment.start + timeOffset,\n end: segment.end + timeOffset,\n });\n }\n }\n }\n\n return entries;\n}\n\nexport function generateCaptionCode(\n entries: CaptionEntry[],\n opts: {\n style: string;\n fontSize: number;\n accentColor: string;\n totalDuration: number;\n },\n): string {\n const { style, fontSize, accentColor, totalDuration } = opts;\n const captionFontFamily =\n 'Geist, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif';\n const captionFontFamilyLiteral = JSON.stringify(captionFontFamily);\n\n // Serialize entries as a compact array\n const entriesJson = JSON.stringify(\n entries.map((e) => ({\n t: e.text,\n s: Math.round(e.start * 1000) / 1000,\n e: Math.round(e.end * 1000) / 1000,\n ...(e.accent ? { a: true } : {}),\n })),\n );\n\n if (style === \"subtitle\") {\n return `import { useCurrentFrame, useVideoConfig, AbsoluteFill } from \"remotion\";\n\nconst entries = ${entriesJson};\nconst TOTAL = ${totalDuration};\n\nexport default function Captions() {\n const frame = useCurrentFrame();\n const { fps } = useVideoConfig();\n const t = frame / fps;\n\n const active = entries.filter(e => t >= e.s && t < e.e);\n const text = active.map(e => e.t).join(\" \");\n\n return (\n <AbsoluteFill style={{ justifyContent: \"flex-end\", alignItems: \"center\", padding: \"0 40px 80px\" }}>\n {text && (\n <div style={{\n fontFamily: ${captionFontFamilyLiteral},\n fontSize: ${fontSize},\n fontWeight: 800,\n color: \"#fff\",\n textShadow: \"0 2px 8px rgba(0,0,0,0.8)\",\n textAlign: \"center\",\n lineHeight: 1.3,\n }}>\n {text}\n </div>\n )}\n <div style={{\n position: \"absolute\", bottom: 0, left: 0, right: 0, height: 4,\n background: \"rgba(255,255,255,0.15)\",\n }}>\n <div style={{\n height: \"100%\", background: \"${accentColor}\",\n width: \\`\\${Math.min(100, (t / TOTAL) * 100)}%\\`,\n }} />\n </div>\n </AbsoluteFill>\n );\n}`;\n }\n\n // TikTok style — phrase groups with active word highlight\n return `import { useCurrentFrame, useVideoConfig, AbsoluteFill, interpolate, Easing } from \"remotion\";\n\nconst entries = ${entriesJson};\nconst TOTAL = ${totalDuration};\nconst WORDS_PER_GROUP = 4;\n\n// Pre-compute phrase groups: chunks of N words based on timing gaps\nconst groups = [];\nlet current = [];\nfor (let i = 0; i < entries.length; i++) {\n current.push(entries[i]);\n const gap = i < entries.length - 1 ? entries[i + 1].s - entries[i].e : 999;\n if (current.length >= WORDS_PER_GROUP || gap > 0.4) {\n groups.push({ words: [...current], s: current[0].s, e: current[current.length - 1].e });\n current = [];\n }\n}\nif (current.length > 0) {\n groups.push({ words: [...current], s: current[0].s, e: current[current.length - 1].e });\n}\n\nexport default function Captions() {\n const frame = useCurrentFrame();\n const { fps } = useVideoConfig();\n const t = frame / fps;\n\n const group = groups.find(g => t >= g.s - 0.05 && t < g.e + 0.1);\n if (!group) return null;\n\n const groupStartF = group.s * fps;\n const groupEndF = Math.max(group.s + 0.2, group.e) * fps;\n const fadeIn = Math.min(groupStartF + 3, groupEndF - 0.5);\n const fadeOut = Math.max(fadeIn + 0.5, groupEndF);\n const opacity = interpolate(frame, [groupStartF, fadeIn, fadeOut, fadeOut + 2], [0, 1, 1, 0], { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\" });\n\n return (\n <AbsoluteFill style={{ justifyContent: \"flex-end\", alignItems: \"center\", paddingBottom: \"14%\" }}>\n <div style={{\n display: \"flex\", flexWrap: \"wrap\", justifyContent: \"center\", gap: \"0 10px\",\n maxWidth: \"85%\", opacity,\n fontFamily: ${captionFontFamilyLiteral},\n }}>\n {group.words.map((w, i) => {\n const isActive = t >= w.s && t < w.e + 0.05;\n const isPast = t >= w.e + 0.05;\n const wordStartF = w.s * fps;\n const scale = isActive\n ? interpolate(frame, [wordStartF, Math.min(wordStartF + 3, wordStartF + Math.max(0.15, w.e - w.s) * fps)], [0.95, 1], { extrapolateLeft: \"clamp\", extrapolateRight: \"clamp\", easing: Easing.out(Easing.cubic) })\n : 1;\n return (\n <span key={i} style={{\n fontSize: ${fontSize},\n fontWeight: 900,\n color: isActive ? (w.a ? \"${accentColor}\" : \"#fff\") : isPast ? \"rgba(255,255,255,0.5)\" : \"rgba(255,255,255,0.35)\",\n textShadow: isActive ? \"0 2px 12px rgba(0,0,0,0.7), 0 0 40px rgba(0,0,0,0.3)\" : \"0 2px 8px rgba(0,0,0,0.5)\",\n transform: \\`scale(\\${scale})\\`,\n letterSpacing: \"-0.02em\",\n textTransform: \"uppercase\",\n transition: \"color 0.1s ease\",\n }}>\n {w.t}\n </span>\n );\n })}\n </div>\n <div style={{\n position: \"absolute\", bottom: 0, left: 0, right: 0, height: 4,\n background: \"rgba(255,255,255,0.15)\",\n }}>\n <div style={{\n height: \"100%\", background: \"${accentColor}\",\n width: \\`\\${Math.min(100, (t / TOTAL) * 100)}%\\`,\n }} />\n </div>\n </AbsoluteFill>\n );\n}`;\n}\n\nexport async function captionsCommand(\n compositionId: string | undefined,\n options: CaptionsOptions,\n): Promise<void> {\n if (!compositionId) {\n outputError(\"Composition ID is required\", options);\n process.exit(1);\n }\n\n const style = options.style ?? \"tiktok\";\n const fontSize = parseInt(options.fontSize ?? \"64\", 10);\n const accentColor = options.accentColor ?? \"#ff3900\";\n const accentWords = new Set(\n (options.accentWords ?? \"\")\n .split(\",\")\n .map((w) => w.trim().toLowerCase())\n .filter(Boolean),\n );\n\n const showSpinner = !options.json && process.stdout.isTTY;\n const spinner = showSpinner ? ora(\"Loading composition...\").start() : null;\n\n const composition = await getComposition(compositionId);\n if (!composition) {\n spinner?.stop();\n outputError(`Composition not found: ${compositionId}`, options);\n process.exit(1);\n }\n\n const scenes = (composition.scenes ?? []) as CaptionSourceScene[];\n const audioScenes = scenes\n .filter((s) => s.type === \"audio_scene\" || s.type === \"video_scene\")\n .sort((a, b) => a.start - b.start);\n\n if (audioScenes.length === 0) {\n spinner?.stop();\n outputError(\"No audio scenes found in composition\", options);\n process.exit(1);\n }\n\n // Load transcripts\n if (spinner) spinner.text = \"Loading transcripts...\";\n const transcripts = new Map<string, WhisperResult>();\n\n if (options.transcript && options.transcript.length > 0) {\n // Use provided transcript files — map to audio scenes in order\n for (\n let i = 0;\n i < Math.min(options.transcript.length, audioScenes.length);\n i++\n ) {\n const filePath = options.transcript[i];\n if (!filePath || !existsSync(filePath)) {\n spinner?.stop();\n outputError(`Transcript file not found: ${filePath ?? \"\"}`, options);\n process.exit(1);\n }\n try {\n const data = JSON.parse(\n readFileSync(filePath, \"utf-8\"),\n ) as WhisperResult;\n const scene = audioScenes[i];\n if (scene) transcripts.set(scene.id, data);\n } catch {\n spinner?.stop();\n outputError(`Failed to parse transcript: ${filePath}`, options);\n process.exit(1);\n }\n }\n } else {\n // Auto-discover transcripts from cache directory\n for (const scene of audioScenes) {\n const transcript = findTranscriptForScene(scene.id);\n if (transcript) {\n transcripts.set(scene.id, transcript);\n }\n }\n }\n\n if (transcripts.size === 0) {\n spinner?.stop();\n outputError(\n \"No transcripts found. Either provide --transcript files or run 'program audio transcribe' first and save output to the cache directory.\",\n options,\n );\n process.exit(1);\n }\n\n // Build caption entries\n if (spinner) spinner.text = \"Building captions...\";\n const entries = buildCaptionEntries(audioScenes, transcripts, accentWords);\n\n if (entries.length === 0) {\n spinner?.stop();\n outputError(\"No caption entries generated from transcripts\", options);\n process.exit(1);\n }\n\n const totalDuration = Number(composition.duration ?? 0);\n const code = generateCaptionCode(entries, {\n style,\n fontSize,\n accentColor,\n totalDuration,\n });\n\n // Find or identify the captions scene to update\n const captionSceneId =\n options.scene ??\n scenes.find((s) => s.id === \"visual-captions\")?.id ??\n scenes.find((s) => {\n const meta = (s as unknown as Record<string, unknown>).metadata as\n | Record<string, unknown>\n | undefined;\n return meta?.title === \"Captions\" || meta?.title === \"Visual Captions\";\n })?.id;\n\n if (captionSceneId) {\n if (spinner) spinner.text = \"Updating composition...\";\n await updateScene(compositionId, captionSceneId, {\n metadata: {\n files: { \"index.tsx\": code },\n role: \"captions\",\n title: \"Visual Captions\",\n },\n });\n }\n\n spinner?.stop();\n\n if (options.json) {\n outputSuccess(\n {\n compositionId,\n entries: entries.length,\n style,\n updatedScene: captionSceneId ?? null,\n transcriptsUsed: Array.from(transcripts.keys()),\n },\n options,\n );\n } else {\n console.log(`\\nCaptions built:`);\n console.log(` Style: ${style}`);\n console.log(` Entries: ${entries.length} words`);\n console.log(` Audio parts: ${transcripts.size}`);\n console.log(` Duration: ${totalDuration.toFixed(1)}s`);\n if (accentWords.size > 0) {\n console.log(` Accented: ${Array.from(accentWords).join(\", \")}`);\n }\n if (captionSceneId) {\n console.log(` Updated: scene \"${captionSceneId}\"`);\n } else {\n console.log(`\\n No captions scene found in composition.`);\n console.log(\n ` Code generated but not applied. Use --scene to specify target scene.`,\n );\n }\n }\n}\n"],"mappings":"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","names":[]}