vargai 0.4.0-alpha4 → 0.4.0-alpha40

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.
Files changed (114) hide show
  1. package/.env.example +6 -0
  2. package/README.md +483 -61
  3. package/assets/fonts/TikTokSans-Bold.ttf +0 -0
  4. package/examples/grok-imagine-test.tsx +155 -0
  5. package/launch-videos/06-kawaii-fruits.tsx +93 -0
  6. package/launch-videos/07-ugc-weight-loss.tsx +132 -0
  7. package/launch-videos/08-talking-head-varg.tsx +107 -0
  8. package/launch-videos/09-girl.tsx +160 -0
  9. package/launch-videos/README.md +42 -0
  10. package/package.json +10 -4
  11. package/pipeline/cookbooks/round-video-character.md +1 -1
  12. package/skills/varg-video-generation/SKILL.md +224 -0
  13. package/skills/varg-video-generation/references/templates.md +380 -0
  14. package/skills/varg-video-generation/scripts/setup.ts +265 -0
  15. package/src/ai-sdk/cache.ts +1 -3
  16. package/src/ai-sdk/examples/google-image.ts +62 -0
  17. package/src/ai-sdk/index.ts +10 -0
  18. package/src/ai-sdk/middleware/wrap-image-model.ts +4 -21
  19. package/src/ai-sdk/middleware/wrap-music-model.ts +4 -16
  20. package/src/ai-sdk/middleware/wrap-video-model.ts +5 -17
  21. package/src/ai-sdk/providers/CONTRIBUTING.md +457 -0
  22. package/src/ai-sdk/providers/editly/backends/index.ts +8 -0
  23. package/src/ai-sdk/providers/editly/backends/local.ts +94 -0
  24. package/src/ai-sdk/providers/editly/backends/types.ts +74 -0
  25. package/src/ai-sdk/providers/editly/editly.test.ts +49 -1
  26. package/src/ai-sdk/providers/editly/index.ts +164 -80
  27. package/src/ai-sdk/providers/editly/layers.ts +58 -6
  28. package/src/ai-sdk/providers/editly/rendi/editly-with-rendi-backend.test.ts +335 -0
  29. package/src/ai-sdk/providers/editly/rendi/index.ts +289 -0
  30. package/src/ai-sdk/providers/editly/rendi/rendi.test.ts +35 -0
  31. package/src/ai-sdk/providers/editly/types.ts +30 -0
  32. package/src/ai-sdk/providers/elevenlabs.ts +10 -2
  33. package/src/ai-sdk/providers/fal.test.ts +214 -0
  34. package/src/ai-sdk/providers/fal.ts +435 -40
  35. package/src/ai-sdk/providers/google.ts +423 -0
  36. package/src/ai-sdk/providers/together.ts +191 -0
  37. package/src/cli/commands/find.tsx +1 -0
  38. package/src/cli/commands/frame.tsx +616 -0
  39. package/src/cli/commands/hello.ts +85 -0
  40. package/src/cli/commands/help.tsx +18 -30
  41. package/src/cli/commands/index.ts +11 -2
  42. package/src/cli/commands/init.tsx +570 -0
  43. package/src/cli/commands/list.tsx +1 -0
  44. package/src/cli/commands/render.tsx +322 -76
  45. package/src/cli/commands/run.tsx +1 -0
  46. package/src/cli/commands/storyboard.tsx +1714 -0
  47. package/src/cli/commands/which.tsx +1 -0
  48. package/src/cli/index.ts +23 -4
  49. package/src/cli/ui/components/Badge.tsx +1 -0
  50. package/src/cli/ui/components/DataTable.tsx +1 -0
  51. package/src/cli/ui/components/Header.tsx +1 -0
  52. package/src/cli/ui/components/HelpBlock.tsx +1 -0
  53. package/src/cli/ui/components/KeyValue.tsx +1 -0
  54. package/src/cli/ui/components/OptionRow.tsx +1 -0
  55. package/src/cli/ui/components/Separator.tsx +1 -0
  56. package/src/cli/ui/components/StatusBox.tsx +1 -0
  57. package/src/cli/ui/components/VargBox.tsx +1 -0
  58. package/src/cli/ui/components/VargProgress.tsx +1 -0
  59. package/src/cli/ui/components/VargSpinner.tsx +1 -0
  60. package/src/cli/ui/components/VargText.tsx +1 -0
  61. package/src/definitions/actions/grok-edit.ts +133 -0
  62. package/src/definitions/actions/index.ts +16 -0
  63. package/src/definitions/actions/qwen-angles.ts +218 -0
  64. package/src/index.ts +1 -0
  65. package/src/providers/fal.ts +196 -0
  66. package/src/react/assets.ts +9 -0
  67. package/src/react/elements.ts +0 -5
  68. package/src/react/examples/branching.tsx +6 -4
  69. package/src/react/examples/character-video.tsx +13 -10
  70. package/src/react/examples/local-files-test.tsx +19 -0
  71. package/src/react/examples/ltx2-test.tsx +25 -0
  72. package/src/react/examples/madi.tsx +13 -10
  73. package/src/react/examples/mcmeows.tsx +40 -0
  74. package/src/react/examples/music-defaults.tsx +24 -0
  75. package/src/react/examples/quickstart-test.tsx +101 -0
  76. package/src/react/examples/qwen-angles-test.tsx +72 -0
  77. package/src/react/index.ts +3 -3
  78. package/src/react/layouts/grid.tsx +1 -1
  79. package/src/react/layouts/index.ts +2 -1
  80. package/src/react/layouts/slot.tsx +85 -0
  81. package/src/react/layouts/split.tsx +18 -0
  82. package/src/react/react.test.ts +60 -11
  83. package/src/react/renderers/burn-captions.ts +95 -0
  84. package/src/react/renderers/cache.test.ts +182 -0
  85. package/src/react/renderers/captions.ts +25 -6
  86. package/src/react/renderers/clip.ts +56 -25
  87. package/src/react/renderers/context.ts +5 -2
  88. package/src/react/renderers/image.ts +5 -2
  89. package/src/react/renderers/index.ts +0 -1
  90. package/src/react/renderers/music.ts +8 -3
  91. package/src/react/renderers/packshot/blinking-button.ts +413 -0
  92. package/src/react/renderers/packshot.ts +170 -8
  93. package/src/react/renderers/progress.ts +4 -3
  94. package/src/react/renderers/render.ts +127 -71
  95. package/src/react/renderers/speech.ts +2 -2
  96. package/src/react/renderers/split.ts +34 -13
  97. package/src/react/renderers/utils.test.ts +80 -0
  98. package/src/react/renderers/utils.ts +37 -1
  99. package/src/react/renderers/video.ts +47 -9
  100. package/src/react/types.ts +70 -17
  101. package/src/studio/stages.ts +40 -39
  102. package/src/studio/step-renderer.ts +14 -24
  103. package/src/studio/ui/index.html +2 -2
  104. package/src/tests/all.test.ts +4 -4
  105. package/src/tests/index.ts +1 -1
  106. package/test-slot-grid.tsx +19 -0
  107. package/test-slot-userland.tsx +30 -0
  108. package/test-sync-v2.ts +30 -0
  109. package/test-sync-v2.tsx +29 -0
  110. package/tsconfig.json +1 -1
  111. package/video.tsx +7 -0
  112. package/src/ai-sdk/providers/editly/ffmpeg.ts +0 -60
  113. package/src/react/renderers/animate.ts +0 -59
  114. /package/src/cli/commands/{studio.tsx → studio.ts} +0 -0
@@ -0,0 +1,1714 @@
1
+ /** @jsxImportSource react */
2
+
3
+ import { existsSync, mkdirSync } from "node:fs";
4
+ import { basename, dirname, resolve } from "node:path";
5
+ import { defineCommand } from "citty";
6
+ import { Box, Text } from "ink";
7
+ import type {
8
+ CaptionsProps,
9
+ ClipProps,
10
+ ImageProps,
11
+ MusicProps,
12
+ PackshotProps,
13
+ SliderProps,
14
+ SpeechProps,
15
+ SplitProps,
16
+ SwipeProps,
17
+ TalkingHeadProps,
18
+ TitleProps,
19
+ VargElement,
20
+ VargNode,
21
+ VideoProps,
22
+ } from "../../react/types";
23
+ import { Header, HelpBlock, VargBox, VargText } from "../ui/index.ts";
24
+ import { renderStatic } from "../ui/render.ts";
25
+
26
+ const AUTO_IMPORTS = `/** @jsxImportSource vargai */
27
+ import { Captions, Clip, Image, Music, Overlay, Packshot, Render, Slider, Speech, Split, Subtitle, Swipe, TalkingHead, Title, Video, Grid, SplitLayout } from "vargai/react";
28
+ import { fal, elevenlabs, replicate } from "vargai/ai";
29
+ `;
30
+
31
+ interface StoryboardClip {
32
+ index: number;
33
+ duration: number | "auto";
34
+ transition?: string;
35
+ elements: StoryboardElement[];
36
+ }
37
+
38
+ interface StoryboardElement {
39
+ type: string;
40
+ prompt?: string;
41
+ src?: string;
42
+ text?: string;
43
+ voice?: string;
44
+ model?: string;
45
+ details: Record<string, unknown>;
46
+ imageDataUrl?: string;
47
+ _element?: VargElement;
48
+ }
49
+
50
+ interface Storyboard {
51
+ width: number;
52
+ height: number;
53
+ fps: number;
54
+ clips: StoryboardClip[];
55
+ globalElements: StoryboardElement[];
56
+ }
57
+
58
+ async function loadComponent(filePath: string): Promise<VargElement> {
59
+ const resolvedPath = resolve(filePath);
60
+ const source = await Bun.file(resolvedPath).text();
61
+
62
+ const hasVargaiImport =
63
+ source.includes("from 'vargai") ||
64
+ source.includes('from "vargai') ||
65
+ source.includes("@jsxImportSource vargai");
66
+
67
+ const hasRelativeImport =
68
+ source.includes("from './") || source.includes('from "./');
69
+
70
+ const pkgDir = new URL("../../..", import.meta.url).pathname;
71
+ const tmpDir = `${pkgDir}/.cache/varg-storyboard`;
72
+
73
+ if (!existsSync(tmpDir)) {
74
+ mkdirSync(tmpDir, { recursive: true });
75
+ }
76
+
77
+ if (hasRelativeImport) {
78
+ const mod = await import(resolvedPath);
79
+ return mod.default;
80
+ }
81
+
82
+ if (hasVargaiImport) {
83
+ const tmpFile = `${tmpDir}/${Date.now()}.tsx`;
84
+ await Bun.write(tmpFile, source);
85
+
86
+ try {
87
+ const mod = await import(tmpFile);
88
+ return mod.default;
89
+ } finally {
90
+ (await Bun.file(tmpFile).exists()) && (await Bun.write(tmpFile, ""));
91
+ }
92
+ }
93
+
94
+ const hasAnyImport = source.includes(" from ");
95
+ if (hasAnyImport) {
96
+ const mod = await import(resolvedPath);
97
+ return mod.default;
98
+ }
99
+
100
+ const tmpFile = `${tmpDir}/${Date.now()}.tsx`;
101
+ await Bun.write(tmpFile, AUTO_IMPORTS + source);
102
+
103
+ try {
104
+ const mod = await import(tmpFile);
105
+ return mod.default;
106
+ } finally {
107
+ (await Bun.file(tmpFile).exists()) && (await Bun.write(tmpFile, ""));
108
+ }
109
+ }
110
+
111
+ function getPromptText(prompt: unknown): string | undefined {
112
+ if (typeof prompt === "string") return prompt;
113
+ if (prompt && typeof prompt === "object" && "text" in prompt) {
114
+ return (prompt as { text?: string }).text;
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ function getModelName(model: unknown): string | undefined {
120
+ if (!model) return undefined;
121
+ if (typeof model === "string") return model;
122
+ if (typeof model === "object" && "modelId" in model) {
123
+ return (model as { modelId: string }).modelId;
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ function extractNestedFromPrompt(prompt: unknown): StoryboardElement[] {
129
+ if (!prompt || typeof prompt !== "object") return [];
130
+
131
+ const nested: StoryboardElement[] = [];
132
+ const p = prompt as Record<string, unknown>;
133
+
134
+ if (p.images && Array.isArray(p.images)) {
135
+ for (const img of p.images) {
136
+ if (img && typeof img === "object" && "type" in img) {
137
+ nested.push(extractElementInfo(img as VargElement));
138
+ } else if (typeof img === "string") {
139
+ const isUrl = img.startsWith("http://") || img.startsWith("https://");
140
+ const isLocalFile =
141
+ img.startsWith("/") || img.startsWith("./") || img.includes(".");
142
+ if (isUrl || isLocalFile) {
143
+ nested.push({
144
+ type: "input",
145
+ src: img,
146
+ details: {
147
+ inputType: isUrl ? "url" : "file",
148
+ },
149
+ });
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ if (p.video && typeof p.video === "object" && "type" in p.video) {
156
+ nested.push(extractElementInfo(p.video as VargElement));
157
+ }
158
+
159
+ if (p.audio && typeof p.audio === "object" && "type" in p.audio) {
160
+ nested.push(extractElementInfo(p.audio as VargElement));
161
+ }
162
+
163
+ return nested;
164
+ }
165
+
166
+ function extractElementInfo(element: VargElement): StoryboardElement {
167
+ const base: StoryboardElement = {
168
+ type: element.type,
169
+ details: {},
170
+ _element: element,
171
+ };
172
+
173
+ switch (element.type) {
174
+ case "image": {
175
+ const props = element.props as ImageProps;
176
+ base.prompt = getPromptText(props.prompt);
177
+ base.src = props.src;
178
+ base.model = getModelName(props.model);
179
+ const nestedFromPrompt = extractNestedFromPrompt(props.prompt);
180
+ base.details = {
181
+ aspectRatio: props.aspectRatio,
182
+ zoom: props.zoom,
183
+ resize: props.resize,
184
+ removeBackground: props.removeBackground,
185
+ children: nestedFromPrompt.length > 0 ? nestedFromPrompt : undefined,
186
+ };
187
+ break;
188
+ }
189
+
190
+ case "video": {
191
+ const props = element.props as VideoProps;
192
+ base.prompt = getPromptText(props.prompt);
193
+ base.src = props.src;
194
+ base.model = getModelName(props.model);
195
+ const nestedFromPrompt = extractNestedFromPrompt(props.prompt);
196
+ base.details = {
197
+ aspectRatio: props.aspectRatio,
198
+ resize: props.resize,
199
+ cutFrom: props.cutFrom,
200
+ cutTo: props.cutTo,
201
+ volume: props.volume,
202
+ children: nestedFromPrompt.length > 0 ? nestedFromPrompt : undefined,
203
+ };
204
+ break;
205
+ }
206
+
207
+ case "speech": {
208
+ const props = element.props as SpeechProps;
209
+ base.text = getTextContent(element.children);
210
+ base.voice = props.voice;
211
+ base.model = getModelName(props.model);
212
+ base.details = {
213
+ volume: props.volume,
214
+ };
215
+ break;
216
+ }
217
+
218
+ case "music": {
219
+ const props = element.props as MusicProps;
220
+ base.prompt = props.prompt;
221
+ base.src = props.src;
222
+ base.model = getModelName(props.model);
223
+ base.details = {
224
+ volume: props.volume,
225
+ loop: props.loop,
226
+ ducking: props.ducking,
227
+ cutFrom: props.cutFrom,
228
+ cutTo: props.cutTo,
229
+ };
230
+ break;
231
+ }
232
+
233
+ case "title": {
234
+ const props = element.props as TitleProps;
235
+ base.text = getTextContent(element.children);
236
+ base.details = {
237
+ position: props.position,
238
+ color: props.color,
239
+ start: props.start,
240
+ end: props.end,
241
+ };
242
+ break;
243
+ }
244
+
245
+ case "captions": {
246
+ const props = element.props as CaptionsProps;
247
+ base.details = {
248
+ style: props.style,
249
+ color: props.color,
250
+ activeColor: props.activeColor,
251
+ fontSize: props.fontSize,
252
+ };
253
+ break;
254
+ }
255
+
256
+ case "talking-head": {
257
+ const props = element.props as TalkingHeadProps;
258
+ base.text = getTextContent(element.children);
259
+ base.voice = props.voice;
260
+ base.model = getModelName(props.model);
261
+ base.details = {
262
+ character: props.character,
263
+ src: props.src,
264
+ position: props.position,
265
+ size: props.size,
266
+ };
267
+ break;
268
+ }
269
+
270
+ case "packshot": {
271
+ const props = element.props as PackshotProps;
272
+ base.details = {
273
+ logo: props.logo,
274
+ logoPosition: props.logoPosition,
275
+ cta: props.cta,
276
+ ctaPosition: props.ctaPosition,
277
+ ctaColor: props.ctaColor,
278
+ blinkCta: props.blinkCta,
279
+ duration: props.duration,
280
+ };
281
+ break;
282
+ }
283
+
284
+ case "split": {
285
+ const props = element.props as SplitProps;
286
+ base.details = {
287
+ direction: props.direction,
288
+ children: extractChildElements(element.children),
289
+ };
290
+ break;
291
+ }
292
+
293
+ case "slider": {
294
+ const props = element.props as SliderProps;
295
+ base.details = {
296
+ direction: props.direction,
297
+ children: extractChildElements(element.children),
298
+ };
299
+ break;
300
+ }
301
+
302
+ case "swipe": {
303
+ const props = element.props as SwipeProps;
304
+ base.details = {
305
+ direction: props.direction,
306
+ interval: props.interval,
307
+ children: extractChildElements(element.children),
308
+ };
309
+ break;
310
+ }
311
+ }
312
+
313
+ // clean up undefined values from details
314
+ base.details = Object.fromEntries(
315
+ Object.entries(base.details).filter(([, v]) => v !== undefined),
316
+ );
317
+
318
+ return base;
319
+ }
320
+
321
+ function getTextContent(children: VargNode[]): string | undefined {
322
+ const texts: string[] = [];
323
+ for (const child of children) {
324
+ if (typeof child === "string") {
325
+ texts.push(child);
326
+ } else if (typeof child === "number") {
327
+ texts.push(String(child));
328
+ }
329
+ }
330
+ return texts.length > 0 ? texts.join("") : undefined;
331
+ }
332
+
333
+ function extractChildElements(children: VargNode[]): StoryboardElement[] {
334
+ const elements: StoryboardElement[] = [];
335
+ for (const child of children) {
336
+ if (!child || typeof child !== "object" || !("type" in child)) continue;
337
+ elements.push(extractElementInfo(child as VargElement));
338
+ }
339
+ return elements;
340
+ }
341
+
342
+ function parseStoryboard(element: VargElement): Storyboard {
343
+ const props = element.props as {
344
+ width?: number;
345
+ height?: number;
346
+ fps?: number;
347
+ };
348
+
349
+ const storyboard: Storyboard = {
350
+ width: props.width ?? 1920,
351
+ height: props.height ?? 1080,
352
+ fps: props.fps ?? 30,
353
+ clips: [],
354
+ globalElements: [],
355
+ };
356
+
357
+ let clipIndex = 0;
358
+ for (const child of element.children) {
359
+ if (!child || typeof child !== "object" || !("type" in child)) continue;
360
+
361
+ const childElement = child as VargElement;
362
+
363
+ if (childElement.type === "clip") {
364
+ const clipProps = childElement.props as ClipProps;
365
+ const clip: StoryboardClip = {
366
+ index: clipIndex++,
367
+ duration: clipProps.duration ?? "auto",
368
+ transition: clipProps.transition?.name,
369
+ elements: extractChildElements(childElement.children),
370
+ };
371
+ storyboard.clips.push(clip);
372
+ } else {
373
+ // global elements like music, captions at render level
374
+ storyboard.globalElements.push(extractElementInfo(childElement));
375
+ }
376
+ }
377
+
378
+ return storyboard;
379
+ }
380
+
381
+ const TYPE_COLORS: Record<string, string> = {
382
+ image: "#34d399",
383
+ video: "#60a5fa",
384
+ speech: "#c084fc",
385
+ music: "#fbbf24",
386
+ title: "#f472b6",
387
+ subtitle: "#94a3b8",
388
+ captions: "#a78bfa",
389
+ "talking-head": "#22d3ee",
390
+ packshot: "#a855f7",
391
+ split: "#818cf8",
392
+ slider: "#2dd4bf",
393
+ swipe: "#fb923c",
394
+ input: "#9ca3af",
395
+ };
396
+
397
+ function escapeHtml(str: string): string {
398
+ return str.replace(/</g, "&lt;").replace(/>/g, "&gt;");
399
+ }
400
+
401
+ function escapeAttr(str: string): string {
402
+ return str
403
+ .replace(/&/g, "&amp;")
404
+ .replace(/"/g, "&quot;")
405
+ .replace(/'/g, "&#39;")
406
+ .replace(/</g, "&lt;")
407
+ .replace(/>/g, "&gt;");
408
+ }
409
+
410
+ function isLocalFilePath(src: string): boolean {
411
+ if (src.startsWith("http://") || src.startsWith("https://")) return false;
412
+ if (src.startsWith("data:")) return false;
413
+ return true;
414
+ }
415
+
416
+ async function localFileToDataUrl(src: string): Promise<string | undefined> {
417
+ try {
418
+ const resolved = resolve(process.cwd(), src);
419
+ const file = Bun.file(resolved);
420
+ if (!(await file.exists())) return undefined;
421
+ const buffer = await file.arrayBuffer();
422
+ const ext = src.split(".").pop()?.toLowerCase();
423
+ const mimeType =
424
+ ext === "png"
425
+ ? "image/png"
426
+ : ext === "jpg" || ext === "jpeg"
427
+ ? "image/jpeg"
428
+ : ext === "gif"
429
+ ? "image/gif"
430
+ : ext === "webp"
431
+ ? "image/webp"
432
+ : "image/png";
433
+ const base64 = Buffer.from(buffer).toString("base64");
434
+ return `data:${mimeType};base64,${base64}`;
435
+ } catch {
436
+ return undefined;
437
+ }
438
+ }
439
+
440
+ function generateHtml(storyboard: Storyboard, sourceFile: string): string {
441
+ const escapedSourceFile = escapeHtml(sourceFile);
442
+
443
+ const renderTreeNode = (
444
+ el: StoryboardElement,
445
+ depth: number,
446
+ isLast: boolean,
447
+ parentPrefix: string,
448
+ ): string => {
449
+ const color = TYPE_COLORS[el.type] || "#666";
450
+ const connector = depth === 0 ? "" : isLast ? "└─ " : "├─ ";
451
+ const childPrefix =
452
+ depth === 0 ? "" : parentPrefix + (isLast ? " " : "│ ");
453
+
454
+ const children =
455
+ (el.details.children as StoryboardElement[] | undefined) || [];
456
+ const childrenHtml = children
457
+ .map((child, i) =>
458
+ renderTreeNode(
459
+ child,
460
+ depth + 1,
461
+ i === children.length - 1,
462
+ childPrefix,
463
+ ),
464
+ )
465
+ .join("");
466
+
467
+ const hasSrcWithPreview =
468
+ el.src && (el.type === "input" || (el.type === "image" && !el.prompt));
469
+ const previewSrc =
470
+ el.imageDataUrl ||
471
+ (el.src && !isLocalFilePath(el.src) ? el.src : undefined);
472
+
473
+ if (hasSrcWithPreview) {
474
+ const shortPath =
475
+ el.src!.length > 50 ? `${el.src!.slice(0, 50)}...` : el.src!;
476
+ const isUrl =
477
+ el.src!.startsWith("http://") || el.src!.startsWith("https://");
478
+ const escapedSrc = escapeAttr(el.src!);
479
+ const previewImgSrc = previewSrc ? escapeAttr(previewSrc) : undefined;
480
+ return `
481
+ <div class="tree-node" style="--depth: ${depth}">
482
+ <span class="tree-prefix">${parentPrefix}${connector}</span>
483
+ <span class="type-tag" style="background: ${color}">${el.type}</span>
484
+ <span class="input-preview-wrapper">
485
+ ${
486
+ isUrl
487
+ ? `<a href="${escapedSrc}" target="_blank" rel="noopener noreferrer" class="tree-prompt input-url">${escapeHtml(shortPath)}</a>`
488
+ : `<span class="tree-prompt input-url">${escapeHtml(shortPath)}</span>`
489
+ }
490
+ ${previewImgSrc ? `<span class="input-preview-tooltip"><img src="${previewImgSrc}" alt="preview" /></span>` : ""}
491
+ </span>
492
+ </div>${childrenHtml}`;
493
+ }
494
+
495
+ const promptOrText = el.prompt || el.text || el.src || "";
496
+ const shortPrompt = promptOrText ? escapeHtml(promptOrText) : "";
497
+
498
+ return `
499
+ <div class="tree-node" style="--depth: ${depth}">
500
+ <span class="tree-prefix">${parentPrefix}${connector}</span>
501
+ <span class="type-tag" style="background: ${color}">${el.type}</span>
502
+ ${el.model ? `<span class="model-tag">${el.model}</span>` : ""}
503
+ ${shortPrompt ? `<span class="tree-prompt">${shortPrompt}</span>` : ""}
504
+ </div>${childrenHtml}`;
505
+ };
506
+
507
+ const renderCardBack = (elements: StoryboardElement[]): string => {
508
+ return elements
509
+ .map((el) => {
510
+ const treeHtml = renderTreeNode(el, 0, true, "");
511
+ return `<div class="tree-view">${treeHtml}</div>`;
512
+ })
513
+ .join("");
514
+ };
515
+
516
+ const aspectRatio = `${storyboard.width} / ${storyboard.height}`;
517
+
518
+ const clipsHtml = storyboard.clips
519
+ .map((clip) => {
520
+ const durationText =
521
+ clip.duration === "auto" ? "auto" : `${clip.duration}s`;
522
+
523
+ const mainEl = clip.elements[0];
524
+ const previewImage = mainEl
525
+ ? mainEl.type === "image"
526
+ ? mainEl.imageDataUrl
527
+ : getFirstNestedImage(mainEl)
528
+ : undefined;
529
+
530
+ return `
531
+ <div class="card-wrapper">
532
+ <div class="card-meta">
533
+ <span class="clip-num">${clip.index + 1}</span>
534
+ <span class="duration">${durationText}</span>
535
+ ${clip.transition ? `<span class="transition">→ ${clip.transition}</span>` : ""}
536
+ </div>
537
+ <div class="flip-card" style="aspect-ratio: ${aspectRatio}">
538
+ <div class="flip-card-inner">
539
+ <div class="flip-card-front">
540
+ ${previewImage ? `<img src="${previewImage}" alt="frame" />` : '<div class="card-placeholder"></div>'}
541
+ </div>
542
+ <div class="flip-card-back">
543
+ ${renderCardBack(clip.elements)}
544
+ </div>
545
+ </div>
546
+ </div>
547
+ </div>`;
548
+ })
549
+ .join("\n");
550
+
551
+ const renderNestedTree = (
552
+ children: StoryboardElement[],
553
+ depth = 1,
554
+ ): string => {
555
+ return children
556
+ .map((child, i) => {
557
+ const isLast = i === children.length - 1;
558
+ const connector = isLast ? "└─" : "├─";
559
+ const color = TYPE_COLORS[child.type] || "#666";
560
+ const grandChildren =
561
+ (child.details.children as StoryboardElement[]) || [];
562
+
563
+ const hasSrcWithPreview =
564
+ child.src &&
565
+ (child.type === "input" || (child.type === "image" && !child.prompt));
566
+ const previewSrc =
567
+ child.imageDataUrl ||
568
+ (child.src && !isLocalFilePath(child.src) ? child.src : undefined);
569
+
570
+ if (hasSrcWithPreview) {
571
+ const shortPath =
572
+ child.src!.length > 60
573
+ ? `${child.src!.slice(0, 60)}...`
574
+ : child.src!;
575
+ const isUrl =
576
+ child.src!.startsWith("http://") ||
577
+ child.src!.startsWith("https://");
578
+ const escapedSrc = escapeAttr(child.src!);
579
+ const previewImgSrc = previewSrc ? escapeAttr(previewSrc) : undefined;
580
+ return `
581
+ <div class="timeline-nested">
582
+ <span class="nested-connector">${connector}</span>
583
+ <span class="nested-type" style="background: ${color}">${child.type}</span>
584
+ <span class="input-preview-wrapper">
585
+ ${
586
+ isUrl
587
+ ? `<a href="${escapedSrc}" target="_blank" rel="noopener noreferrer" class="nested-prompt input-url">${escapeHtml(shortPath)}</a>`
588
+ : `<span class="nested-prompt input-url">${escapeHtml(shortPath)}</span>`
589
+ }
590
+ ${previewImgSrc ? `<span class="input-preview-tooltip"><img src="${previewImgSrc}" alt="preview" /></span>` : ""}
591
+ </span>
592
+ </div>
593
+ ${grandChildren.length > 0 ? renderNestedTree(grandChildren, depth + 1) : ""}`;
594
+ }
595
+
596
+ const childPrompt = child.prompt || child.text || child.src || "";
597
+
598
+ return `
599
+ <div class="timeline-nested">
600
+ <span class="nested-connector">${connector}</span>
601
+ <span class="nested-type" style="background: ${color}">${child.type}</span>
602
+ ${child.model ? `<span class="nested-model">${child.model}</span>` : ""}
603
+ ${childPrompt ? `<p class="nested-prompt">${escapeHtml(childPrompt)}</p>` : ""}
604
+ </div>
605
+ ${grandChildren.length > 0 ? renderNestedTree(grandChildren, depth + 1) : ""}`;
606
+ })
607
+ .join("");
608
+ };
609
+
610
+ const timelineHtml = storyboard.clips
611
+ .map((clip) => {
612
+ const mainEl = clip.elements[0];
613
+ if (!mainEl) return "";
614
+
615
+ const durationText =
616
+ clip.duration === "auto" ? "auto" : `${clip.duration}s`;
617
+
618
+ const previewImage =
619
+ mainEl.type === "image"
620
+ ? mainEl.imageDataUrl
621
+ : getFirstNestedImage(mainEl);
622
+
623
+ const videoPrompt = mainEl.prompt || "";
624
+ const speechEl = clip.elements.find((e) => e.type === "speech");
625
+ const speechText = speechEl?.text || "";
626
+ const nestedChildren =
627
+ (mainEl.details.children as StoryboardElement[]) || [];
628
+
629
+ const color = TYPE_COLORS[mainEl.type] || "#666";
630
+
631
+ return `
632
+ <div class="timeline-row">
633
+ <div class="timeline-image" style="aspect-ratio: ${aspectRatio}">
634
+ ${previewImage ? `<img src="${previewImage}" alt="frame" />` : '<div class="timeline-placeholder"></div>'}
635
+ </div>
636
+ <div class="timeline-info">
637
+ <div class="timeline-header">
638
+ <span class="clip-num">${clip.index + 1}</span>
639
+ <span class="duration">${durationText}</span>
640
+ ${clip.transition ? `<span class="transition">→ ${clip.transition}</span>` : ""}
641
+ </div>
642
+ <div class="timeline-section">
643
+ <div class="timeline-type-row">
644
+ <span class="timeline-type" style="background: ${color}">${mainEl.type}</span>
645
+ ${mainEl.model ? `<span class="timeline-model">${mainEl.model}</span>` : ""}
646
+ </div>
647
+ ${videoPrompt ? `<p class="timeline-text">${escapeHtml(videoPrompt)}</p>` : ""}
648
+ </div>
649
+ ${
650
+ nestedChildren.length > 0
651
+ ? `
652
+ <div class="timeline-children">
653
+ ${renderNestedTree(nestedChildren)}
654
+ </div>`
655
+ : ""
656
+ }
657
+ ${
658
+ speechText
659
+ ? `
660
+ <div class="timeline-section">
661
+ <span class="timeline-label">vo:</span>
662
+ <p class="timeline-text">${escapeHtml(speechText)}</p>
663
+ </div>`
664
+ : ""
665
+ }
666
+ </div>
667
+ </div>`;
668
+ })
669
+ .join("\n");
670
+
671
+ const globalHtml =
672
+ storyboard.globalElements.length > 0
673
+ ? `
674
+ <div class="global-bar">
675
+ <span class="global-label">Global:</span>
676
+ ${storyboard.globalElements
677
+ .map((el) => {
678
+ const color = TYPE_COLORS[el.type] || "#666";
679
+ const label = el.prompt || el.text || el.type;
680
+ const shortLabel =
681
+ label.length > 40 ? `${label.slice(0, 40)}...` : label;
682
+ return `<span class="global-tag" style="border-color: ${color}"><span class="global-type" style="background: ${color}">${el.type}</span>${escapeHtml(shortLabel)}</span>`;
683
+ })
684
+ .join("")}
685
+ </div>`
686
+ : "";
687
+
688
+ return `<!DOCTYPE html>
689
+ <html lang="en">
690
+ <head>
691
+ <meta charset="UTF-8">
692
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
693
+ <title>Storyboard - ${escapedSourceFile}</title>
694
+ <style>
695
+ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
696
+
697
+ * { box-sizing: border-box; margin: 0; padding: 0; }
698
+
699
+ :root {
700
+ /* dark theme (default) */
701
+ --bg-primary: #0d0d0f;
702
+ --bg-card: #18181c;
703
+ --bg-card-header: #1e1e24;
704
+ --bg-elevated: #252530;
705
+ --border-subtle: rgba(255, 255, 255, 0.06);
706
+ --border-soft: rgba(255, 255, 255, 0.1);
707
+ --text-primary: #f4f4f5;
708
+ --text-secondary: #a1a1aa;
709
+ --text-muted: #71717a;
710
+ --accent-mint: #6ee7b7;
711
+ --accent-peach: #fda4af;
712
+ --accent-lavender: #c4b5fd;
713
+ --accent-sky: #7dd3fc;
714
+ --accent-amber: #fcd34d;
715
+ --shadow-soft: 0 4px 24px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3);
716
+ --shadow-glow: 0 0 0 1px rgba(255, 255, 255, 0.04);
717
+ --radius-squishy: 16px;
718
+ --radius-pill: 24px;
719
+ --radius-tag: 10px;
720
+ --toggle-icon: "☀";
721
+ }
722
+
723
+ [data-theme="light"] {
724
+ --bg-primary: #f8f8fa;
725
+ --bg-card: #ffffff;
726
+ --bg-card-header: #f3f4f6;
727
+ --bg-elevated: #e8e9ed;
728
+ --border-subtle: rgba(0, 0, 0, 0.06);
729
+ --border-soft: rgba(0, 0, 0, 0.1);
730
+ --text-primary: #18181b;
731
+ --text-secondary: #52525b;
732
+ --text-muted: #71717a;
733
+ --accent-mint: #059669;
734
+ --accent-peach: #e11d48;
735
+ --accent-lavender: #7c3aed;
736
+ --accent-sky: #0284c7;
737
+ --accent-amber: #d97706;
738
+ --shadow-soft: 0 4px 24px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
739
+ --shadow-glow: 0 0 0 1px rgba(0, 0, 0, 0.03);
740
+ --toggle-icon: "☾";
741
+ }
742
+
743
+ @media (prefers-color-scheme: light) {
744
+ :root:not([data-theme="dark"]) {
745
+ --bg-primary: #f8f8fa;
746
+ --bg-card: #ffffff;
747
+ --bg-card-header: #f3f4f6;
748
+ --bg-elevated: #e8e9ed;
749
+ --border-subtle: rgba(0, 0, 0, 0.06);
750
+ --border-soft: rgba(0, 0, 0, 0.1);
751
+ --text-primary: #18181b;
752
+ --text-secondary: #52525b;
753
+ --text-muted: #71717a;
754
+ --accent-mint: #059669;
755
+ --accent-peach: #e11d48;
756
+ --accent-lavender: #7c3aed;
757
+ --accent-sky: #0284c7;
758
+ --accent-amber: #d97706;
759
+ --shadow-soft: 0 4px 24px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
760
+ --shadow-glow: 0 0 0 1px rgba(0, 0, 0, 0.03);
761
+ --toggle-icon: "☾";
762
+ }
763
+ }
764
+
765
+ body, .card, .card-header, .card-element, .global-bar, .global-tag, .summary span, .meta span, .model-tag {
766
+ transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
767
+ }
768
+
769
+ body {
770
+ font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
771
+ background: var(--bg-primary);
772
+ background-image:
773
+ radial-gradient(ellipse 80% 60% at 50% 0%, rgba(110, 231, 183, 0.04), transparent),
774
+ radial-gradient(ellipse 60% 50% at 80% 100%, rgba(196, 181, 253, 0.03), transparent);
775
+ color: var(--text-primary);
776
+ line-height: 1.6;
777
+ padding: 2rem;
778
+ min-height: 100vh;
779
+ }
780
+
781
+ .theme-toggle {
782
+ background: var(--bg-elevated);
783
+ border: 1px solid var(--border-subtle);
784
+ border-radius: 10px;
785
+ width: 36px;
786
+ height: 36px;
787
+ cursor: pointer;
788
+ display: flex;
789
+ align-items: center;
790
+ justify-content: center;
791
+ font-size: 1rem;
792
+ transition: background 0.2s ease, border-color 0.2s ease, transform 0.15s ease;
793
+ }
794
+
795
+ .theme-toggle:hover {
796
+ background: var(--bg-card-header);
797
+ border-color: var(--border-soft);
798
+ transform: scale(1.05);
799
+ }
800
+
801
+ .theme-toggle:active {
802
+ transform: scale(0.95);
803
+ }
804
+
805
+ .theme-icon::before {
806
+ content: var(--toggle-icon);
807
+ }
808
+
809
+ .header {
810
+ display: flex;
811
+ justify-content: space-between;
812
+ align-items: center;
813
+ margin-bottom: 2rem;
814
+ padding-bottom: 1.25rem;
815
+ border-bottom: 1px solid var(--border-subtle);
816
+ }
817
+
818
+ .header h1 {
819
+ font-size: 1.5rem;
820
+ font-weight: 700;
821
+ color: var(--text-primary);
822
+ letter-spacing: -0.02em;
823
+ }
824
+
825
+ .meta {
826
+ font-size: 0.8rem;
827
+ color: var(--text-muted);
828
+ display: flex;
829
+ align-items: center;
830
+ gap: 0.5rem;
831
+ }
832
+
833
+ .meta span {
834
+ padding: 0.35rem 0.75rem;
835
+ background: var(--bg-elevated);
836
+ border-radius: var(--radius-tag);
837
+ border: 1px solid var(--border-subtle);
838
+ }
839
+
840
+ .grid {
841
+ display: grid;
842
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
843
+ gap: 1.5rem;
844
+ }
845
+
846
+ .card-wrapper {
847
+ display: flex;
848
+ flex-direction: column;
849
+ gap: 0.5rem;
850
+ }
851
+
852
+ .card-meta {
853
+ display: flex;
854
+ align-items: center;
855
+ gap: 0.5rem;
856
+ padding: 0 0.25rem;
857
+ }
858
+
859
+ .clip-num {
860
+ background: linear-gradient(135deg, var(--accent-mint), #34d399);
861
+ color: #0d0d0f;
862
+ font-weight: 700;
863
+ font-size: 0.7rem;
864
+ padding: 0.3rem 0.6rem;
865
+ border-radius: var(--radius-tag);
866
+ box-shadow: 0 2px 8px rgba(110, 231, 183, 0.25);
867
+ }
868
+
869
+ .duration {
870
+ font-size: 0.75rem;
871
+ font-weight: 500;
872
+ color: var(--accent-mint);
873
+ }
874
+
875
+ .transition {
876
+ font-size: 0.7rem;
877
+ font-weight: 500;
878
+ color: var(--accent-amber);
879
+ margin-left: auto;
880
+ padding: 0.2rem 0.5rem;
881
+ background: rgba(252, 211, 77, 0.1);
882
+ border-radius: 8px;
883
+ }
884
+
885
+ .flip-card {
886
+ perspective: 1000px;
887
+ cursor: pointer;
888
+ }
889
+
890
+ .flip-card-inner {
891
+ position: relative;
892
+ width: 100%;
893
+ height: 100%;
894
+ transition: transform 0.6s ease;
895
+ transform-style: preserve-3d;
896
+ }
897
+
898
+ .flip-card:hover .flip-card-inner {
899
+ transform: rotateY(180deg);
900
+ }
901
+
902
+ .flip-card-front, .flip-card-back {
903
+ position: absolute;
904
+ width: 100%;
905
+ height: 100%;
906
+ backface-visibility: hidden;
907
+ border-radius: var(--radius-squishy);
908
+ overflow: hidden;
909
+ box-shadow: var(--shadow-soft);
910
+ }
911
+
912
+ .flip-card-front {
913
+ background: var(--bg-card);
914
+ }
915
+
916
+ .flip-card-front img {
917
+ width: 100%;
918
+ height: 100%;
919
+ object-fit: cover;
920
+ }
921
+
922
+ .card-placeholder {
923
+ width: 100%;
924
+ height: 100%;
925
+ background: var(--bg-elevated);
926
+ display: flex;
927
+ align-items: center;
928
+ justify-content: center;
929
+ color: var(--text-muted);
930
+ font-size: 2rem;
931
+ }
932
+
933
+ .card-placeholder::after {
934
+ content: "▶";
935
+ }
936
+
937
+ .flip-card-back {
938
+ background: var(--bg-card);
939
+ transform: rotateY(180deg);
940
+ padding: 1rem;
941
+ overflow-y: auto;
942
+ }
943
+
944
+ .flip-card-back .tree-view {
945
+ font-size: 0.7rem;
946
+ }
947
+
948
+ .flip-card-back .tree-prompt {
949
+ font-size: 0.75rem;
950
+ }
951
+
952
+ .element-info {
953
+ display: flex;
954
+ flex-direction: column;
955
+ gap: 0.25rem;
956
+ padding: 0.65rem;
957
+ }
958
+
959
+ .card-element:not(:has(.preview-image)) .element-info {
960
+ padding: 0;
961
+ }
962
+
963
+ .tree-view {
964
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
965
+ font-size: 0.75rem;
966
+ line-height: 1.6;
967
+ }
968
+
969
+ .tree-node {
970
+ display: flex;
971
+ align-items: baseline;
972
+ gap: 0.4rem;
973
+ flex-wrap: wrap;
974
+ }
975
+
976
+ .tree-prefix {
977
+ color: var(--text-muted);
978
+ white-space: pre;
979
+ user-select: none;
980
+ }
981
+
982
+ .tree-prompt {
983
+ color: var(--text-secondary);
984
+ font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
985
+ font-size: 0.8rem;
986
+ margin-left: 0.25rem;
987
+ }
988
+
989
+ .type-tag {
990
+ color: #fff;
991
+ font-size: 0.65rem;
992
+ font-weight: 600;
993
+ text-transform: uppercase;
994
+ letter-spacing: 0.03em;
995
+ padding: 0.3rem 0.55rem;
996
+ border-radius: 8px;
997
+ flex-shrink: 0;
998
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
999
+ }
1000
+
1001
+ .model-tag {
1002
+ font-size: 0.65rem;
1003
+ font-weight: 500;
1004
+ color: var(--text-muted);
1005
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
1006
+ background: var(--bg-card);
1007
+ padding: 0.25rem 0.5rem;
1008
+ border-radius: 8px;
1009
+ border: 1px solid var(--border-subtle);
1010
+ }
1011
+
1012
+ .nested {
1013
+ display: flex;
1014
+ gap: 0.35rem;
1015
+ flex-wrap: wrap;
1016
+ }
1017
+
1018
+ .nested-tag {
1019
+ font-size: 0.55rem;
1020
+ font-weight: 600;
1021
+ color: #fff;
1022
+ padding: 0.2rem 0.4rem;
1023
+ border-radius: 6px;
1024
+ opacity: 0.85;
1025
+ }
1026
+
1027
+ .prompt {
1028
+ font-size: 0.82rem;
1029
+ color: var(--text-secondary);
1030
+ line-height: 1.55;
1031
+ width: 100%;
1032
+ margin-top: 0.25rem;
1033
+ }
1034
+
1035
+ .global-bar {
1036
+ margin-top: 2rem;
1037
+ padding: 1rem 1.25rem;
1038
+ background: var(--bg-card);
1039
+ border-radius: var(--radius-squishy);
1040
+ display: flex;
1041
+ flex-wrap: wrap;
1042
+ align-items: center;
1043
+ gap: 0.65rem;
1044
+ border: 1px solid var(--border-subtle);
1045
+ box-shadow: var(--shadow-soft), var(--shadow-glow);
1046
+ }
1047
+
1048
+ .global-label {
1049
+ font-size: 0.75rem;
1050
+ color: var(--text-muted);
1051
+ font-weight: 600;
1052
+ text-transform: uppercase;
1053
+ letter-spacing: 0.05em;
1054
+ }
1055
+
1056
+ .global-tag {
1057
+ font-size: 0.75rem;
1058
+ color: var(--text-secondary);
1059
+ padding: 0.35rem 0.65rem;
1060
+ padding-left: 0;
1061
+ border-radius: var(--radius-tag);
1062
+ border: 1px solid;
1063
+ display: inline-flex;
1064
+ align-items: center;
1065
+ gap: 0.5rem;
1066
+ overflow: hidden;
1067
+ background: var(--bg-elevated);
1068
+ }
1069
+
1070
+ .global-type {
1071
+ font-size: 0.6rem;
1072
+ color: #fff;
1073
+ padding: 0.35rem 0.55rem;
1074
+ font-weight: 600;
1075
+ text-transform: uppercase;
1076
+ letter-spacing: 0.03em;
1077
+ }
1078
+
1079
+ .summary {
1080
+ margin-top: 1.5rem;
1081
+ display: flex;
1082
+ flex-wrap: wrap;
1083
+ gap: 1rem;
1084
+ font-size: 0.8rem;
1085
+ color: var(--text-muted);
1086
+ }
1087
+
1088
+ .summary span {
1089
+ padding: 0.5rem 0.85rem;
1090
+ background: var(--bg-card);
1091
+ border-radius: var(--radius-tag);
1092
+ border: 1px solid var(--border-subtle);
1093
+ transition: border-color 0.2s ease;
1094
+ }
1095
+
1096
+ .summary span:hover {
1097
+ border-color: var(--border-soft);
1098
+ }
1099
+
1100
+ .summary strong {
1101
+ color: var(--text-primary);
1102
+ font-weight: 600;
1103
+ }
1104
+
1105
+ .render-btn {
1106
+ background: linear-gradient(135deg, var(--accent-mint), #34d399);
1107
+ border: none;
1108
+ border-radius: 10px;
1109
+ padding: 0.5rem 1rem;
1110
+ cursor: pointer;
1111
+ display: flex;
1112
+ align-items: center;
1113
+ gap: 0.4rem;
1114
+ font-size: 0.8rem;
1115
+ font-weight: 600;
1116
+ color: #0d0d0f;
1117
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
1118
+ box-shadow: 0 2px 8px rgba(110, 231, 183, 0.3);
1119
+ }
1120
+
1121
+ .render-btn:hover {
1122
+ transform: scale(1.05);
1123
+ box-shadow: 0 4px 12px rgba(110, 231, 183, 0.4);
1124
+ }
1125
+
1126
+ .render-btn:active {
1127
+ transform: scale(0.95);
1128
+ }
1129
+
1130
+ .render-btn.copied {
1131
+ background: linear-gradient(135deg, var(--accent-lavender), #a78bfa);
1132
+ box-shadow: 0 2px 8px rgba(167, 139, 250, 0.3);
1133
+ }
1134
+
1135
+ .render-icon {
1136
+ font-size: 0.7rem;
1137
+ }
1138
+
1139
+ .view-toggle {
1140
+ display: flex;
1141
+ background: var(--bg-elevated);
1142
+ border-radius: 8px;
1143
+ border: 1px solid var(--border-subtle);
1144
+ overflow: hidden;
1145
+ }
1146
+
1147
+ .view-toggle button {
1148
+ background: none;
1149
+ border: none;
1150
+ padding: 0.4rem 0.6rem;
1151
+ cursor: pointer;
1152
+ color: var(--text-muted);
1153
+ font-size: 0.9rem;
1154
+ transition: background 0.15s ease, color 0.15s ease;
1155
+ }
1156
+
1157
+ .view-toggle button:hover {
1158
+ color: var(--text-secondary);
1159
+ }
1160
+
1161
+ .view-toggle button.active {
1162
+ background: var(--bg-card);
1163
+ color: var(--text-primary);
1164
+ }
1165
+
1166
+ .timeline {
1167
+ display: none;
1168
+ flex-direction: column;
1169
+ gap: 0;
1170
+ }
1171
+
1172
+ .timeline.active {
1173
+ display: flex;
1174
+ }
1175
+
1176
+ .grid.active {
1177
+ display: grid;
1178
+ }
1179
+
1180
+ .grid:not(.active) {
1181
+ display: none;
1182
+ }
1183
+
1184
+ .timeline-row {
1185
+ display: grid;
1186
+ grid-template-columns: minmax(300px, 1fr) 1fr;
1187
+ gap: 2rem;
1188
+ padding: 2rem 0;
1189
+ border-bottom: 1px solid var(--border-subtle);
1190
+ }
1191
+
1192
+ .timeline-row:last-child {
1193
+ border-bottom: none;
1194
+ }
1195
+
1196
+ .timeline-image {
1197
+ border-radius: var(--radius-squishy);
1198
+ overflow: hidden;
1199
+ background: var(--bg-card);
1200
+ max-width: 500px;
1201
+ }
1202
+
1203
+ .timeline-image img {
1204
+ width: 100%;
1205
+ height: 100%;
1206
+ object-fit: cover;
1207
+ }
1208
+
1209
+ .timeline-placeholder {
1210
+ width: 100%;
1211
+ height: 100%;
1212
+ background: var(--bg-elevated);
1213
+ }
1214
+
1215
+ .timeline-info {
1216
+ display: flex;
1217
+ flex-direction: column;
1218
+ justify-content: center;
1219
+ gap: 1rem;
1220
+ }
1221
+
1222
+ .timeline-title {
1223
+ font-size: 0.85rem;
1224
+ font-weight: 600;
1225
+ color: var(--accent-lavender);
1226
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
1227
+ }
1228
+
1229
+ .timeline-header {
1230
+ display: flex;
1231
+ align-items: center;
1232
+ gap: 0.5rem;
1233
+ }
1234
+
1235
+ .timeline-section {
1236
+ display: flex;
1237
+ flex-direction: column;
1238
+ gap: 0.4rem;
1239
+ }
1240
+
1241
+ .timeline-type-row {
1242
+ display: flex;
1243
+ align-items: center;
1244
+ gap: 0.5rem;
1245
+ }
1246
+
1247
+ .timeline-type {
1248
+ color: #fff;
1249
+ font-size: 0.65rem;
1250
+ font-weight: 600;
1251
+ text-transform: uppercase;
1252
+ padding: 0.25rem 0.5rem;
1253
+ border-radius: 6px;
1254
+ }
1255
+
1256
+ .timeline-model {
1257
+ font-size: 0.7rem;
1258
+ color: var(--text-muted);
1259
+ font-family: monospace;
1260
+ }
1261
+
1262
+ .timeline-label {
1263
+ font-size: 0.85rem;
1264
+ font-weight: 600;
1265
+ color: var(--text-primary);
1266
+ text-decoration: underline;
1267
+ text-underline-offset: 3px;
1268
+ }
1269
+
1270
+ .timeline-text {
1271
+ font-size: 0.9rem;
1272
+ color: var(--text-secondary);
1273
+ line-height: 1.5;
1274
+ }
1275
+
1276
+ .timeline-children {
1277
+ padding-left: 0.5rem;
1278
+ border-left: 2px solid var(--border-subtle);
1279
+ display: flex;
1280
+ flex-direction: column;
1281
+ gap: 0.5rem;
1282
+ }
1283
+
1284
+ .timeline-nested {
1285
+ display: flex;
1286
+ flex-wrap: wrap;
1287
+ align-items: baseline;
1288
+ gap: 0.4rem;
1289
+ }
1290
+
1291
+ .nested-connector {
1292
+ color: var(--text-muted);
1293
+ font-family: monospace;
1294
+ font-size: 0.8rem;
1295
+ }
1296
+
1297
+ .nested-type {
1298
+ color: #fff;
1299
+ font-size: 0.6rem;
1300
+ font-weight: 600;
1301
+ text-transform: uppercase;
1302
+ padding: 0.2rem 0.4rem;
1303
+ border-radius: 4px;
1304
+ }
1305
+
1306
+ .nested-model {
1307
+ font-size: 0.65rem;
1308
+ color: var(--text-muted);
1309
+ font-family: monospace;
1310
+ }
1311
+
1312
+ .nested-prompt {
1313
+ font-size: 0.85rem;
1314
+ color: var(--text-secondary);
1315
+ line-height: 1.4;
1316
+ width: 100%;
1317
+ margin-top: 0.25rem;
1318
+ }
1319
+
1320
+ .input-preview-wrapper {
1321
+ position: relative;
1322
+ display: inline-block;
1323
+ }
1324
+
1325
+ .input-url {
1326
+ color: var(--accent-sky);
1327
+ text-decoration: none;
1328
+ word-break: break-all;
1329
+ }
1330
+
1331
+ .input-url:hover {
1332
+ text-decoration: underline;
1333
+ }
1334
+
1335
+ .input-preview-tooltip {
1336
+ display: none;
1337
+ position: absolute;
1338
+ left: 0;
1339
+ top: 100%;
1340
+ margin-top: 8px;
1341
+ z-index: 1000;
1342
+ background: var(--bg-card);
1343
+ border: 1px solid var(--border-soft);
1344
+ border-radius: var(--radius-squishy);
1345
+ box-shadow: var(--shadow-soft);
1346
+ padding: 8px;
1347
+ max-width: 300px;
1348
+ }
1349
+
1350
+ .input-preview-tooltip img {
1351
+ max-width: 100%;
1352
+ max-height: 200px;
1353
+ border-radius: 8px;
1354
+ display: block;
1355
+ }
1356
+
1357
+ .input-preview-wrapper:hover .input-preview-tooltip {
1358
+ display: block;
1359
+ }
1360
+ </style>
1361
+ </head>
1362
+ <body>
1363
+ <div class="header">
1364
+ <h1>Storyboard</h1>
1365
+ <div class="meta">
1366
+ <span>${escapedSourceFile}</span>
1367
+ <span>${storyboard.width}×${storyboard.height}</span>
1368
+ <span>${storyboard.fps}fps</span>
1369
+ <div class="view-toggle">
1370
+ <button class="active" onclick="setView('grid')" aria-label="Grid view">▦</button>
1371
+ <button onclick="setView('timeline')" aria-label="Timeline view">☰</button>
1372
+ </div>
1373
+ <button class="render-btn" onclick="copyRenderCommand()" aria-label="Copy render command">
1374
+ <span class="render-icon">▶</span>
1375
+ <span class="render-text">Render</span>
1376
+ </button>
1377
+ <button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme">
1378
+ <span class="theme-icon"></span>
1379
+ </button>
1380
+ </div>
1381
+ </div>
1382
+
1383
+ <div class="grid active">
1384
+ ${clipsHtml}
1385
+ </div>
1386
+
1387
+ <div class="timeline">
1388
+ ${timelineHtml}
1389
+ </div>
1390
+
1391
+ ${globalHtml}
1392
+
1393
+ <div class="summary">
1394
+ <span><strong>${storyboard.clips.length}</strong> clips</span>
1395
+ <span><strong>${countElements(storyboard, "video")}</strong> videos</span>
1396
+ <span><strong>${countElements(storyboard, "image")}</strong> images</span>
1397
+ <span><strong>${countElements(storyboard, "speech")}</strong> speech</span>
1398
+ <span><strong>${countElements(storyboard, "music")}</strong> music</span>
1399
+ </div>
1400
+
1401
+ <script>
1402
+ (function() {
1403
+ const root = document.documentElement;
1404
+ const stored = localStorage.getItem('storyboard-theme');
1405
+ if (stored) {
1406
+ root.setAttribute('data-theme', stored);
1407
+ }
1408
+ })();
1409
+
1410
+ function toggleTheme() {
1411
+ const root = document.documentElement;
1412
+ const current = root.getAttribute('data-theme');
1413
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
1414
+
1415
+ let next;
1416
+ if (current === 'light') {
1417
+ next = 'dark';
1418
+ } else if (current === 'dark') {
1419
+ next = 'light';
1420
+ } else {
1421
+ next = prefersDark ? 'light' : 'dark';
1422
+ }
1423
+
1424
+ root.setAttribute('data-theme', next);
1425
+ localStorage.setItem('storyboard-theme', next);
1426
+ }
1427
+
1428
+ function copyRenderCommand() {
1429
+ const cmd = "bunx vargai render ${sourceFile}";
1430
+ navigator.clipboard.writeText(cmd).then(() => {
1431
+ const btn = document.querySelector('.render-btn');
1432
+ const text = btn.querySelector('.render-text');
1433
+ btn.classList.add('copied');
1434
+ text.textContent = 'Copied!';
1435
+ setTimeout(() => {
1436
+ btn.classList.remove('copied');
1437
+ text.textContent = 'Render';
1438
+ }, 2000);
1439
+ });
1440
+ }
1441
+
1442
+ function setView(view) {
1443
+ const grid = document.querySelector('.grid');
1444
+ const timeline = document.querySelector('.timeline');
1445
+ const buttons = document.querySelectorAll('.view-toggle button');
1446
+
1447
+ if (view === 'grid') {
1448
+ grid.classList.add('active');
1449
+ timeline.classList.remove('active');
1450
+ buttons[0].classList.add('active');
1451
+ buttons[1].classList.remove('active');
1452
+ } else {
1453
+ grid.classList.remove('active');
1454
+ timeline.classList.add('active');
1455
+ buttons[0].classList.remove('active');
1456
+ buttons[1].classList.add('active');
1457
+ }
1458
+
1459
+ localStorage.setItem('storyboard-view', view);
1460
+ }
1461
+
1462
+ (function() {
1463
+ const stored = localStorage.getItem('storyboard-view');
1464
+ if (stored) setView(stored);
1465
+ })();
1466
+ </script>
1467
+ </body>
1468
+ </html>`;
1469
+ }
1470
+
1471
+ function countElements(storyboard: Storyboard, type: string): number {
1472
+ let count = 0;
1473
+
1474
+ const countInElements = (elements: StoryboardElement[]) => {
1475
+ for (const el of elements) {
1476
+ if (el.type === type) count++;
1477
+ if (el.details.children && Array.isArray(el.details.children)) {
1478
+ countInElements(el.details.children as StoryboardElement[]);
1479
+ }
1480
+ }
1481
+ };
1482
+
1483
+ for (const clip of storyboard.clips) {
1484
+ countInElements(clip.elements);
1485
+ }
1486
+ countInElements(storyboard.globalElements);
1487
+
1488
+ return count;
1489
+ }
1490
+
1491
+ async function populateCachedImages(
1492
+ storyboard: Storyboard,
1493
+ cacheDir: string,
1494
+ ): Promise<number> {
1495
+ const { computeCacheKey } = await import("../../react/renderers/utils");
1496
+ const { fileCache } = await import("../../ai-sdk/file-cache");
1497
+ const cache = fileCache({ dir: cacheDir });
1498
+
1499
+ let foundCount = 0;
1500
+
1501
+ async function lookupImage(el: StoryboardElement): Promise<void> {
1502
+ // Handle local file sources (for both "image" and "input" types)
1503
+ if (el.src && isLocalFilePath(el.src) && !el.imageDataUrl) {
1504
+ const dataUrl = await localFileToDataUrl(el.src);
1505
+ if (dataUrl) {
1506
+ el.imageDataUrl = dataUrl;
1507
+ foundCount++;
1508
+ }
1509
+ }
1510
+
1511
+ // Handle cached generated images
1512
+ if (el.type === "image" && el._element && !el.imageDataUrl) {
1513
+ const cacheKeyParts = computeCacheKey(el._element);
1514
+ const cacheKey = `generateImage:${cacheKeyParts.map((d) => String(d ?? "")).join(":")}`;
1515
+ const cached = (await cache.get(cacheKey)) as
1516
+ | { images?: Array<{ uint8Array?: Uint8Array }> }
1517
+ | undefined;
1518
+
1519
+ if (cached?.images?.[0]?.uint8Array) {
1520
+ const base64 = Buffer.from(cached.images[0].uint8Array).toString(
1521
+ "base64",
1522
+ );
1523
+ el.imageDataUrl = `data:image/png;base64,${base64}`;
1524
+ foundCount++;
1525
+ }
1526
+ }
1527
+
1528
+ if (el.details.children && Array.isArray(el.details.children)) {
1529
+ for (const child of el.details.children as StoryboardElement[]) {
1530
+ await lookupImage(child);
1531
+ }
1532
+ }
1533
+ }
1534
+
1535
+ async function processElements(elements: StoryboardElement[]): Promise<void> {
1536
+ for (const el of elements) {
1537
+ await lookupImage(el);
1538
+
1539
+ if (el.details.children && Array.isArray(el.details.children)) {
1540
+ await processElements(el.details.children as StoryboardElement[]);
1541
+ }
1542
+ }
1543
+ }
1544
+
1545
+ for (const clip of storyboard.clips) {
1546
+ await processElements(clip.elements);
1547
+ }
1548
+ await processElements(storyboard.globalElements);
1549
+
1550
+ return foundCount;
1551
+ }
1552
+
1553
+ function getFirstNestedImage(el: StoryboardElement): string | undefined {
1554
+ if (el.imageDataUrl) return el.imageDataUrl;
1555
+
1556
+ if (el.details.children && Array.isArray(el.details.children)) {
1557
+ for (const child of el.details.children as StoryboardElement[]) {
1558
+ const found = getFirstNestedImage(child);
1559
+ if (found) return found;
1560
+ }
1561
+ }
1562
+
1563
+ return undefined;
1564
+ }
1565
+
1566
+ export const storyboardCmd = defineCommand({
1567
+ meta: {
1568
+ name: "storyboard",
1569
+ description: "generate html storyboard from component",
1570
+ },
1571
+ args: {
1572
+ file: {
1573
+ type: "positional" as const,
1574
+ description: "component file (.tsx)",
1575
+ required: true,
1576
+ },
1577
+ output: {
1578
+ type: "string" as const,
1579
+ alias: "o",
1580
+ description: "output html path",
1581
+ },
1582
+ cache: {
1583
+ type: "string" as const,
1584
+ alias: "c",
1585
+ description: "cache directory for image lookup",
1586
+ default: ".cache/ai",
1587
+ },
1588
+ quiet: {
1589
+ type: "boolean" as const,
1590
+ alias: "q",
1591
+ description: "minimal output",
1592
+ default: false,
1593
+ },
1594
+ open: {
1595
+ type: "boolean" as const,
1596
+ description: "open in browser after generation",
1597
+ default: false,
1598
+ },
1599
+ },
1600
+ async run({ args }) {
1601
+ const file = args.file as string;
1602
+
1603
+ if (!file) {
1604
+ console.error("usage: varg storyboard <component.tsx> [-o output.html]");
1605
+ process.exit(1);
1606
+ }
1607
+
1608
+ const component = await loadComponent(file);
1609
+
1610
+ if (!component || component.type !== "render") {
1611
+ console.error("error: default export must be a <Render> element");
1612
+ process.exit(1);
1613
+ }
1614
+
1615
+ const baseName = basename(file).replace(/\.tsx?$/, "");
1616
+ const outputPath =
1617
+ (args.output as string) ?? `output/${baseName}-storyboard.html`;
1618
+
1619
+ const outputDir = dirname(outputPath);
1620
+ if (!existsSync(outputDir)) {
1621
+ mkdirSync(outputDir, { recursive: true });
1622
+ }
1623
+
1624
+ if (!args.quiet) {
1625
+ console.log(`parsing ${file}...`);
1626
+ }
1627
+
1628
+ const storyboard = parseStoryboard(component);
1629
+
1630
+ const cacheDir = resolve(args.cache as string);
1631
+ let cachedCount = 0;
1632
+ if (existsSync(cacheDir)) {
1633
+ cachedCount = await populateCachedImages(storyboard, cacheDir);
1634
+ }
1635
+
1636
+ const html = generateHtml(storyboard, file);
1637
+
1638
+ await Bun.write(outputPath, html);
1639
+
1640
+ if (!args.quiet) {
1641
+ console.log(`storyboard generated: ${outputPath}`);
1642
+ console.log(
1643
+ ` ${storyboard.clips.length} clips, ${storyboard.width}x${storyboard.height}`,
1644
+ );
1645
+ if (cachedCount > 0) {
1646
+ console.log(` ${cachedCount} cached images found`);
1647
+ }
1648
+ }
1649
+
1650
+ if (args.open) {
1651
+ const { $ } = await import("bun");
1652
+ await $`open ${outputPath}`.quiet();
1653
+ }
1654
+ },
1655
+ });
1656
+
1657
+ function StoryboardHelpView() {
1658
+ const examples = [
1659
+ {
1660
+ command: "varg storyboard video.tsx",
1661
+ description: "generate storyboard to output/video-storyboard.html",
1662
+ },
1663
+ {
1664
+ command: "varg storyboard video.tsx -o storyboard.html",
1665
+ description: "custom output path",
1666
+ },
1667
+ {
1668
+ command: "varg storyboard video.tsx --open",
1669
+ description: "generate and open in browser",
1670
+ },
1671
+ ];
1672
+
1673
+ return (
1674
+ <VargBox title="varg storyboard">
1675
+ <Box marginBottom={1}>
1676
+ <Text>
1677
+ generate an html storyboard from a varg component. shows all clips,
1678
+ prompts, and settings in a visual layout.
1679
+ </Text>
1680
+ </Box>
1681
+
1682
+ <Header>USAGE</Header>
1683
+ <Box paddingLeft={2} marginBottom={1}>
1684
+ <VargText variant="accent">
1685
+ varg storyboard {"<file.tsx>"} [options]
1686
+ </VargText>
1687
+ </Box>
1688
+
1689
+ <Header>OPTIONS</Header>
1690
+ <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
1691
+ <Text>
1692
+ <VargText variant="accent">-o, --output </VargText>output path
1693
+ (default: output/{"<name>"}-storyboard.html)
1694
+ </Text>
1695
+ <Text>
1696
+ <VargText variant="accent">--open </VargText>open in browser after
1697
+ generation
1698
+ </Text>
1699
+ <Text>
1700
+ <VargText variant="accent">-q, --quiet </VargText>minimal output
1701
+ </Text>
1702
+ </Box>
1703
+
1704
+ <Header>EXAMPLES</Header>
1705
+ <Box marginTop={1}>
1706
+ <HelpBlock examples={examples} />
1707
+ </Box>
1708
+ </VargBox>
1709
+ );
1710
+ }
1711
+
1712
+ export function showStoryboardHelp() {
1713
+ renderStatic(<StoryboardHelpView />);
1714
+ }