vargai 0.4.0-alpha24 → 0.4.0-alpha26

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.
package/package.json CHANGED
@@ -65,7 +65,7 @@
65
65
  "vargai": "^0.4.0-alpha11",
66
66
  "zod": "^4.2.1"
67
67
  },
68
- "version": "0.4.0-alpha24",
68
+ "version": "0.4.0-alpha26",
69
69
  "exports": {
70
70
  ".": "./src/index.ts",
71
71
  "./ai": "./src/ai-sdk/index.ts",
@@ -321,7 +321,7 @@ see all voices: `bun run lib/elevenlabs.ts voices`
321
321
  ```bash
322
322
  # required api keys
323
323
  export ELEVENLABS_API_KEY="your_key"
324
- export FAL_KEY="your_key" # for wan-25 and image generation
324
+ export FAL_API_KEY="your_key" # for wan-25 and image generation (or set FAL_KEY)
325
325
  ```
326
326
 
327
327
  ## changelog
@@ -483,8 +483,10 @@ export interface FalProvider extends ProviderV3 {
483
483
  }
484
484
 
485
485
  export function createFal(settings: FalProviderSettings = {}): FalProvider {
486
- if (settings.apiKey) {
487
- fal.config({ credentials: settings.apiKey });
486
+ const apiKey =
487
+ settings.apiKey ?? process.env.FAL_API_KEY ?? process.env.FAL_KEY;
488
+ if (apiKey) {
489
+ fal.config({ credentials: apiKey });
488
490
  }
489
491
 
490
492
  return {
@@ -0,0 +1,565 @@
1
+ /** @jsxImportSource react */
2
+
3
+ import { existsSync, mkdirSync } from "node:fs";
4
+ import { basename, dirname, resolve } from "node:path";
5
+ import { generateImage, wrapImageModel } from "ai";
6
+ import { defineCommand } from "citty";
7
+ import { Box, Text } from "ink";
8
+ import { withCache } from "../../ai-sdk/cache";
9
+ import { File } from "../../ai-sdk/file";
10
+ import { fileCache } from "../../ai-sdk/file-cache";
11
+ import { imagePlaceholderFallbackMiddleware } from "../../ai-sdk/middleware";
12
+ import type {
13
+ ClipProps,
14
+ ImageInput,
15
+ ImagePrompt,
16
+ ImageProps,
17
+ RenderProps,
18
+ VargElement,
19
+ VargNode,
20
+ VideoProps,
21
+ } from "../../react/types";
22
+ import { Header, HelpBlock, VargBox, VargText } from "../ui/index.ts";
23
+ import { renderStatic } from "../ui/render.ts";
24
+
25
+ const AUTO_IMPORTS = `/** @jsxImportSource vargai */
26
+ import { Captions, Clip, Image, Music, Overlay, Packshot, Render, Slider, Speech, Split, Subtitle, Swipe, TalkingHead, Title, Video, Grid, SplitLayout } from "vargai/react";
27
+ import { fal, elevenlabs, replicate } from "vargai/ai";
28
+ `;
29
+
30
+ interface FrameInfo {
31
+ clipIndex: number;
32
+ prompt?: ImagePrompt;
33
+ src?: string;
34
+ model?: unknown;
35
+ aspectRatio?: string;
36
+ duration: number;
37
+ startTime: number;
38
+ }
39
+
40
+ async function loadComponent(filePath: string): Promise<VargElement> {
41
+ const resolvedPath = resolve(filePath);
42
+ const source = await Bun.file(resolvedPath).text();
43
+
44
+ const hasVargaiImport =
45
+ source.includes("from 'vargai") ||
46
+ source.includes('from "vargai') ||
47
+ source.includes("@jsxImportSource vargai");
48
+
49
+ const hasRelativeImport =
50
+ source.includes("from './") || source.includes('from "./');
51
+
52
+ const pkgDir = new URL("../../..", import.meta.url).pathname;
53
+ const tmpDir = `${pkgDir}/.cache/varg-frame`;
54
+
55
+ if (!existsSync(tmpDir)) {
56
+ mkdirSync(tmpDir, { recursive: true });
57
+ }
58
+
59
+ if (hasRelativeImport) {
60
+ const mod = await import(resolvedPath);
61
+ return mod.default;
62
+ }
63
+
64
+ if (hasVargaiImport) {
65
+ const tmpFile = `${tmpDir}/${Date.now()}.tsx`;
66
+ await Bun.write(tmpFile, source);
67
+
68
+ try {
69
+ const mod = await import(tmpFile);
70
+ return mod.default;
71
+ } finally {
72
+ (await Bun.file(tmpFile).exists()) && (await Bun.write(tmpFile, ""));
73
+ }
74
+ }
75
+
76
+ const hasAnyImport = source.includes(" from ");
77
+ if (hasAnyImport) {
78
+ const mod = await import(resolvedPath);
79
+ return mod.default;
80
+ }
81
+
82
+ const tmpFile = `${tmpDir}/${Date.now()}.tsx`;
83
+ await Bun.write(tmpFile, AUTO_IMPORTS + source);
84
+
85
+ try {
86
+ const mod = await import(tmpFile);
87
+ return mod.default;
88
+ } finally {
89
+ (await Bun.file(tmpFile).exists()) && (await Bun.write(tmpFile, ""));
90
+ }
91
+ }
92
+
93
+ function toFileUrl(pathOrUrl: string): string {
94
+ if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
95
+ return pathOrUrl;
96
+ }
97
+ if (pathOrUrl.startsWith("file://")) {
98
+ return pathOrUrl;
99
+ }
100
+ const resolved = resolve(pathOrUrl);
101
+ return `file://${resolved}`;
102
+ }
103
+
104
+ async function resolveImageInput(input: ImageInput): Promise<Uint8Array> {
105
+ if (input instanceof Uint8Array) {
106
+ return input;
107
+ }
108
+ if (typeof input === "string") {
109
+ const response = await fetch(toFileUrl(input));
110
+ return new Uint8Array(await response.arrayBuffer());
111
+ }
112
+ if (
113
+ input &&
114
+ typeof input === "object" &&
115
+ "type" in input &&
116
+ input.type === "image"
117
+ ) {
118
+ const imageElement = input as VargElement<"image">;
119
+ const props = imageElement.props as ImageProps;
120
+
121
+ if (props.src) {
122
+ const response = await fetch(toFileUrl(props.src));
123
+ return new Uint8Array(await response.arrayBuffer());
124
+ }
125
+
126
+ return null as unknown as Uint8Array;
127
+ }
128
+ throw new Error("Unknown image input type");
129
+ }
130
+
131
+ function extractFrameFromVideo(element: VargElement<"video">): {
132
+ prompt?: ImagePrompt;
133
+ nestedImage?: VargElement<"image">;
134
+ } {
135
+ const props = element.props as VideoProps;
136
+
137
+ if (props.src) {
138
+ return {};
139
+ }
140
+
141
+ const prompt = props.prompt;
142
+ if (!prompt) {
143
+ return {};
144
+ }
145
+
146
+ if (typeof prompt === "string") {
147
+ return { prompt };
148
+ }
149
+
150
+ if (prompt.images && prompt.images.length > 0) {
151
+ const firstImage = prompt.images[0];
152
+ if (
153
+ firstImage &&
154
+ typeof firstImage === "object" &&
155
+ "type" in firstImage &&
156
+ firstImage.type === "image"
157
+ ) {
158
+ return { nestedImage: firstImage as VargElement<"image"> };
159
+ }
160
+ return { prompt: { text: prompt.text, images: prompt.images } };
161
+ }
162
+
163
+ if (prompt.text) {
164
+ return { prompt: prompt.text };
165
+ }
166
+
167
+ return {};
168
+ }
169
+
170
+ function extractFrames(element: VargElement): FrameInfo[] {
171
+ const frames: FrameInfo[] = [];
172
+ let currentTime = 0;
173
+ let clipIndex = 0;
174
+
175
+ for (const child of element.children) {
176
+ if (!child || typeof child !== "object" || !("type" in child)) continue;
177
+
178
+ const childElement = child as VargElement;
179
+
180
+ if (childElement.type === "clip") {
181
+ const clipProps = childElement.props as ClipProps;
182
+ const duration =
183
+ typeof clipProps.duration === "number" ? clipProps.duration : 3;
184
+
185
+ for (const clipChild of childElement.children) {
186
+ if (
187
+ !clipChild ||
188
+ typeof clipChild !== "object" ||
189
+ !("type" in clipChild)
190
+ )
191
+ continue;
192
+
193
+ const clipChildElement = clipChild as VargElement;
194
+
195
+ if (clipChildElement.type === "image") {
196
+ const props = clipChildElement.props as ImageProps;
197
+ frames.push({
198
+ clipIndex,
199
+ prompt: props.prompt,
200
+ src: props.src,
201
+ model: props.model,
202
+ aspectRatio: props.aspectRatio,
203
+ duration,
204
+ startTime: currentTime,
205
+ });
206
+ break;
207
+ }
208
+
209
+ if (clipChildElement.type === "video") {
210
+ const { prompt, nestedImage } = extractFrameFromVideo(
211
+ clipChildElement as VargElement<"video">,
212
+ );
213
+ const videoProps = clipChildElement.props as VideoProps;
214
+
215
+ if (nestedImage) {
216
+ const imageProps = nestedImage.props as ImageProps;
217
+ frames.push({
218
+ clipIndex,
219
+ prompt: imageProps.prompt,
220
+ src: imageProps.src,
221
+ model: imageProps.model,
222
+ aspectRatio: imageProps.aspectRatio ?? videoProps.aspectRatio,
223
+ duration,
224
+ startTime: currentTime,
225
+ });
226
+ } else if (prompt) {
227
+ frames.push({
228
+ clipIndex,
229
+ prompt,
230
+ model: undefined,
231
+ aspectRatio: videoProps.aspectRatio,
232
+ duration,
233
+ startTime: currentTime,
234
+ });
235
+ }
236
+ break;
237
+ }
238
+ }
239
+
240
+ currentTime += duration;
241
+ clipIndex++;
242
+ }
243
+ }
244
+
245
+ return frames;
246
+ }
247
+
248
+ function findClipAtTime(
249
+ frames: FrameInfo[],
250
+ time: number,
251
+ ): FrameInfo | undefined {
252
+ for (const frame of frames) {
253
+ if (time >= frame.startTime && time < frame.startTime + frame.duration) {
254
+ return frame;
255
+ }
256
+ }
257
+ return frames[frames.length - 1];
258
+ }
259
+
260
+ async function detectDefaultImageModel() {
261
+ const falKey = process.env.FAL_API_KEY ?? process.env.FAL_KEY;
262
+ if (falKey) {
263
+ const { fal } = await import("../../ai-sdk/providers/fal");
264
+ return fal.imageModel("flux-schnell");
265
+ }
266
+ return undefined;
267
+ }
268
+
269
+ async function resolvePromptForGeneration(
270
+ prompt: ImagePrompt,
271
+ ): Promise<string | { text?: string; images: Uint8Array[] }> {
272
+ if (typeof prompt === "string") {
273
+ return prompt;
274
+ }
275
+
276
+ const resolvedImages: Uint8Array[] = [];
277
+ for (const img of prompt.images) {
278
+ const resolved = await resolveImageInput(img);
279
+ if (resolved) {
280
+ resolvedImages.push(resolved);
281
+ }
282
+ }
283
+
284
+ return { text: prompt.text, images: resolvedImages };
285
+ }
286
+
287
+ export const frameCmd = defineCommand({
288
+ meta: {
289
+ name: "frame",
290
+ description: "render still frames from component clips",
291
+ },
292
+ args: {
293
+ file: {
294
+ type: "positional" as const,
295
+ description: "component file (.tsx)",
296
+ required: true,
297
+ },
298
+ output: {
299
+ type: "string" as const,
300
+ alias: "o",
301
+ description: "output path pattern (use %d for clip number)",
302
+ },
303
+ at: {
304
+ type: "string" as const,
305
+ description: "render frame at specific timestamp (e.g., 2.5)",
306
+ },
307
+ clip: {
308
+ type: "string" as const,
309
+ alias: "c",
310
+ description: "render specific clip number only (1-indexed)",
311
+ },
312
+ all: {
313
+ type: "boolean" as const,
314
+ alias: "a",
315
+ description: "render all clips (default if no --at or --clip)",
316
+ default: false,
317
+ },
318
+ cache: {
319
+ type: "string" as const,
320
+ description: "cache directory",
321
+ default: ".cache/ai",
322
+ },
323
+ quiet: {
324
+ type: "boolean" as const,
325
+ alias: "q",
326
+ description: "minimal output",
327
+ default: false,
328
+ },
329
+ preview: {
330
+ type: "boolean" as const,
331
+ description: "use placeholder images (no AI generation)",
332
+ default: false,
333
+ },
334
+ },
335
+ async run({ args }) {
336
+ const file = args.file as string;
337
+
338
+ if (!file) {
339
+ console.error("usage: varg frame <component.tsx> [options]");
340
+ process.exit(1);
341
+ }
342
+
343
+ const component = await loadComponent(file);
344
+
345
+ if (!component || component.type !== "render") {
346
+ console.error("error: default export must be a <Render> element");
347
+ process.exit(1);
348
+ }
349
+
350
+ const renderProps = component.props as RenderProps;
351
+ const frames = extractFrames(component);
352
+
353
+ if (frames.length === 0) {
354
+ console.error("error: no clips with visual elements found");
355
+ process.exit(1);
356
+ }
357
+
358
+ let framesToRender: FrameInfo[] = [];
359
+
360
+ if (args.at) {
361
+ const time = Number.parseFloat(args.at as string);
362
+ const frame = findClipAtTime(frames, time);
363
+ if (frame) {
364
+ framesToRender = [frame];
365
+ } else {
366
+ console.error(`error: no clip found at timestamp ${time}s`);
367
+ process.exit(1);
368
+ }
369
+ } else if (args.clip) {
370
+ const clipNum = Number.parseInt(args.clip as string, 10) - 1;
371
+ const frame = frames.find((f) => f.clipIndex === clipNum);
372
+ if (frame) {
373
+ framesToRender = [frame];
374
+ } else {
375
+ console.error(
376
+ `error: clip ${args.clip} not found (have ${frames.length} clips)`,
377
+ );
378
+ process.exit(1);
379
+ }
380
+ } else {
381
+ framesToRender = frames;
382
+ }
383
+
384
+ const baseName = basename(file).replace(/\.tsx?$/, "");
385
+ const outputDir = dirname(
386
+ (args.output as string) ?? `output/${baseName}-frame-1.png`,
387
+ );
388
+
389
+ if (!existsSync(outputDir)) {
390
+ mkdirSync(outputDir, { recursive: true });
391
+ }
392
+
393
+ const defaultModel = await detectDefaultImageModel();
394
+
395
+ const cache = args.cache
396
+ ? fileCache({ dir: args.cache as string })
397
+ : undefined;
398
+ const cachedGenerateImage = cache
399
+ ? withCache(generateImage, { storage: cache })
400
+ : generateImage;
401
+
402
+ const wrapGenerateImage: typeof generateImage = async (opts) => {
403
+ if (args.preview) {
404
+ if (
405
+ typeof opts.model === "string" ||
406
+ opts.model.specificationVersion !== "v3"
407
+ ) {
408
+ return cachedGenerateImage(opts);
409
+ }
410
+ const wrappedModel = wrapImageModel({
411
+ model: opts.model,
412
+ middleware: imagePlaceholderFallbackMiddleware({
413
+ mode: "preview",
414
+ onFallback: () => {},
415
+ }),
416
+ });
417
+ return generateImage({ ...opts, model: wrappedModel });
418
+ }
419
+ return cachedGenerateImage(opts);
420
+ };
421
+
422
+ const outputPaths: string[] = [];
423
+
424
+ for (const frame of framesToRender) {
425
+ const outputPath =
426
+ (args.output as string)?.replace("%d", String(frame.clipIndex + 1)) ??
427
+ `output/${baseName}-frame-${frame.clipIndex + 1}.png`;
428
+
429
+ if (!args.quiet) {
430
+ console.log(`rendering clip ${frame.clipIndex + 1}...`);
431
+ }
432
+
433
+ if (frame.src) {
434
+ const response = await fetch(toFileUrl(frame.src));
435
+ const data = new Uint8Array(await response.arrayBuffer());
436
+ await Bun.write(outputPath, data);
437
+ outputPaths.push(outputPath);
438
+ continue;
439
+ }
440
+
441
+ if (!frame.prompt) {
442
+ if (!args.quiet) {
443
+ console.log(
444
+ ` skipping clip ${frame.clipIndex + 1} (no prompt or src)`,
445
+ );
446
+ }
447
+ continue;
448
+ }
449
+
450
+ const model = frame.model ?? defaultModel;
451
+ if (!model) {
452
+ console.error(
453
+ "error: no image model available. set FAL_API_KEY or specify model in component",
454
+ );
455
+ process.exit(1);
456
+ }
457
+
458
+ const resolvedPrompt = await resolvePromptForGeneration(frame.prompt);
459
+
460
+ const { images } = await wrapGenerateImage({
461
+ model: model as Parameters<typeof generateImage>[0]["model"],
462
+ prompt: resolvedPrompt,
463
+ aspectRatio: frame.aspectRatio as `${number}:${number}` | undefined,
464
+ n: 1,
465
+ });
466
+
467
+ const firstImage = images[0];
468
+ if (!firstImage?.uint8Array) {
469
+ console.error(
470
+ `error: image generation returned no data for clip ${frame.clipIndex + 1}`,
471
+ );
472
+ continue;
473
+ }
474
+
475
+ await Bun.write(outputPath, firstImage.uint8Array);
476
+ outputPaths.push(outputPath);
477
+
478
+ if (!args.quiet) {
479
+ console.log(` → ${outputPath}`);
480
+ }
481
+ }
482
+
483
+ if (!args.quiet) {
484
+ console.log(`\ndone! ${outputPaths.length} frame(s) rendered`);
485
+ }
486
+ },
487
+ });
488
+
489
+ function FrameHelpView() {
490
+ const examples = [
491
+ {
492
+ command: "varg frame video.tsx",
493
+ description: "render all clips as frames",
494
+ },
495
+ {
496
+ command: "varg frame video.tsx --clip 2",
497
+ description: "render only clip 2",
498
+ },
499
+ {
500
+ command: "varg frame video.tsx --at 5.5",
501
+ description: "render frame at 5.5 seconds",
502
+ },
503
+ {
504
+ command: "varg frame video.tsx -o frames/shot-%d.png",
505
+ description: "custom output pattern",
506
+ },
507
+ ];
508
+
509
+ return (
510
+ <VargBox title="varg frame">
511
+ <Box marginBottom={1}>
512
+ <Text>
513
+ render still frames from component clips. extracts the first visual
514
+ element from each clip and generates as png.
515
+ </Text>
516
+ </Box>
517
+
518
+ <Header>USAGE</Header>
519
+ <Box paddingLeft={2} marginBottom={1}>
520
+ <VargText variant="accent">
521
+ varg frame {"<file.tsx>"} [options]
522
+ </VargText>
523
+ </Box>
524
+
525
+ <Header>OPTIONS</Header>
526
+ <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
527
+ <Text>
528
+ <VargText variant="accent">-o, --output </VargText>output path pattern
529
+ (use %d for clip number)
530
+ </Text>
531
+ <Text>
532
+ <VargText variant="accent">--at </VargText>render frame at specific
533
+ timestamp (seconds)
534
+ </Text>
535
+ <Text>
536
+ <VargText variant="accent">-c, --clip </VargText>render specific clip
537
+ number only (1-indexed)
538
+ </Text>
539
+ <Text>
540
+ <VargText variant="accent">-a, --all </VargText>render all clips
541
+ (default)
542
+ </Text>
543
+ <Text>
544
+ <VargText variant="accent">--cache </VargText>cache directory
545
+ (default: .cache/ai)
546
+ </Text>
547
+ <Text>
548
+ <VargText variant="accent">--preview </VargText>use placeholder images
549
+ </Text>
550
+ <Text>
551
+ <VargText variant="accent">-q, --quiet </VargText>minimal output
552
+ </Text>
553
+ </Box>
554
+
555
+ <Header>EXAMPLES</Header>
556
+ <Box marginTop={1}>
557
+ <HelpBlock examples={examples} />
558
+ </Box>
559
+ </VargBox>
560
+ );
561
+ }
562
+
563
+ export function showFrameHelp() {
564
+ renderStatic(<FrameHelpView />);
565
+ }
@@ -1,4 +1,5 @@
1
1
  export { findCmd, showFindHelp } from "./find.tsx";
2
+ export { frameCmd, showFrameHelp } from "./frame.tsx";
2
3
  export { helloCmd } from "./hello.ts";
3
4
  export { helpCmd, showHelp } from "./help.tsx";
4
5
  export { initCmd, showInitHelp } from "./init.tsx";
@@ -10,5 +11,6 @@ export {
10
11
  showRenderHelp,
11
12
  } from "./render.tsx";
12
13
  export { runCmd, showRunHelp, showTargetHelp } from "./run.tsx";
14
+ export { showStoryboardHelp, storyboardCmd } from "./storyboard.tsx";
13
15
  export { studioCmd } from "./studio.ts";
14
16
  export { showWhichHelp, whichCmd } from "./which.tsx";
@@ -17,7 +17,8 @@ import { fal, elevenlabs, replicate } from "vargai/ai";
17
17
  async function detectDefaultModels(): Promise<DefaultModels | undefined> {
18
18
  const defaults: DefaultModels = {};
19
19
 
20
- if (process.env.FAL_KEY) {
20
+ const falKey = process.env.FAL_API_KEY ?? process.env.FAL_KEY;
21
+ if (falKey) {
21
22
  const { fal } = await import("../../ai-sdk/providers/fal");
22
23
  defaults.image = fal.imageModel("flux-schnell");
23
24
  defaults.video = fal.videoModel("wan-2.5");