vargai 0.4.0-alpha25 → 0.4.0-alpha27

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-alpha25",
68
+ "version": "0.4.0-alpha27",
69
69
  "exports": {
70
70
  ".": "./src/index.ts",
71
71
  "./ai": "./src/ai-sdk/index.ts",
@@ -0,0 +1,616 @@
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 { fileCache } from "../../ai-sdk/file-cache";
10
+ import { imagePlaceholderFallbackMiddleware } from "../../ai-sdk/middleware";
11
+ import { computeCacheKey } from "../../react/renderers/utils";
12
+ import type {
13
+ ClipProps,
14
+ ImageInput,
15
+ ImagePrompt,
16
+ ImageProps,
17
+ RenderProps,
18
+ VargElement,
19
+ VideoProps,
20
+ } from "../../react/types";
21
+ import { Header, HelpBlock, VargBox, VargText } from "../ui/index.ts";
22
+ import { renderStatic } from "../ui/render.ts";
23
+
24
+ const AUTO_IMPORTS = `/** @jsxImportSource vargai */
25
+ import { Captions, Clip, Image, Music, Overlay, Packshot, Render, Slider, Speech, Split, Subtitle, Swipe, TalkingHead, Title, Video, Grid, SplitLayout } from "vargai/react";
26
+ import { fal, elevenlabs, replicate } from "vargai/ai";
27
+ `;
28
+
29
+ interface FrameInfo {
30
+ clipIndex: number;
31
+ prompt?: ImagePrompt;
32
+ src?: string;
33
+ model?: unknown;
34
+ aspectRatio?: string;
35
+ duration: number;
36
+ startTime: number;
37
+ imageElement?: VargElement<"image">;
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
+ interface ImageGeneratorContext {
105
+ generateImage: typeof generateImage;
106
+ defaultModel?: unknown;
107
+ }
108
+
109
+ async function resolveImageInput(
110
+ input: ImageInput,
111
+ ctx: ImageGeneratorContext,
112
+ ): Promise<Uint8Array> {
113
+ if (input instanceof Uint8Array) {
114
+ return input;
115
+ }
116
+ if (typeof input === "string") {
117
+ const response = await fetch(toFileUrl(input));
118
+ return new Uint8Array(await response.arrayBuffer());
119
+ }
120
+ if (
121
+ input &&
122
+ typeof input === "object" &&
123
+ "type" in input &&
124
+ input.type === "image"
125
+ ) {
126
+ const imageElement = input as VargElement<"image">;
127
+ const props = imageElement.props as ImageProps;
128
+
129
+ if (props.src) {
130
+ const response = await fetch(toFileUrl(props.src));
131
+ return new Uint8Array(await response.arrayBuffer());
132
+ }
133
+
134
+ if (props.prompt) {
135
+ const model = props.model ?? ctx.defaultModel;
136
+ if (!model) {
137
+ throw new Error("Nested image requires model");
138
+ }
139
+
140
+ const resolvedPrompt = await resolvePromptForGeneration(
141
+ props.prompt,
142
+ ctx,
143
+ );
144
+ const cacheKey = computeCacheKey(imageElement);
145
+
146
+ const { images } = await ctx.generateImage({
147
+ model: model as Parameters<typeof generateImage>[0]["model"],
148
+ prompt: resolvedPrompt,
149
+ aspectRatio: props.aspectRatio as `${number}:${number}` | undefined,
150
+ n: 1,
151
+ cacheKey,
152
+ } as Parameters<typeof generateImage>[0]);
153
+
154
+ const firstImage = images[0];
155
+ if (!firstImage?.uint8Array) {
156
+ throw new Error("Nested image generation returned no data");
157
+ }
158
+ return firstImage.uint8Array;
159
+ }
160
+
161
+ throw new Error("Image element requires prompt or src");
162
+ }
163
+ throw new Error("Unknown image input type");
164
+ }
165
+
166
+ function extractFrameFromVideo(element: VargElement<"video">): {
167
+ prompt?: ImagePrompt;
168
+ nestedImage?: VargElement<"image">;
169
+ } {
170
+ const props = element.props as VideoProps;
171
+
172
+ if (props.src) {
173
+ return {};
174
+ }
175
+
176
+ const prompt = props.prompt;
177
+ if (!prompt) {
178
+ return {};
179
+ }
180
+
181
+ if (typeof prompt === "string") {
182
+ return { prompt };
183
+ }
184
+
185
+ if (prompt.images && prompt.images.length > 0) {
186
+ const firstImage = prompt.images[0];
187
+ if (
188
+ firstImage &&
189
+ typeof firstImage === "object" &&
190
+ "type" in firstImage &&
191
+ firstImage.type === "image"
192
+ ) {
193
+ return { nestedImage: firstImage as VargElement<"image"> };
194
+ }
195
+ return { prompt: { text: prompt.text, images: prompt.images } };
196
+ }
197
+
198
+ if (prompt.text) {
199
+ return { prompt: prompt.text };
200
+ }
201
+
202
+ return {};
203
+ }
204
+
205
+ function extractFrames(element: VargElement): FrameInfo[] {
206
+ const frames: FrameInfo[] = [];
207
+ let currentTime = 0;
208
+ let clipIndex = 0;
209
+
210
+ for (const child of element.children) {
211
+ if (!child || typeof child !== "object" || !("type" in child)) continue;
212
+
213
+ const childElement = child as VargElement;
214
+
215
+ if (childElement.type === "clip") {
216
+ const clipProps = childElement.props as ClipProps;
217
+ const duration =
218
+ typeof clipProps.duration === "number" ? clipProps.duration : 3;
219
+
220
+ for (const clipChild of childElement.children) {
221
+ if (
222
+ !clipChild ||
223
+ typeof clipChild !== "object" ||
224
+ !("type" in clipChild)
225
+ )
226
+ continue;
227
+
228
+ const clipChildElement = clipChild as VargElement;
229
+
230
+ if (clipChildElement.type === "image") {
231
+ const props = clipChildElement.props as ImageProps;
232
+ frames.push({
233
+ clipIndex,
234
+ prompt: props.prompt,
235
+ src: props.src,
236
+ model: props.model,
237
+ aspectRatio: props.aspectRatio,
238
+ duration,
239
+ startTime: currentTime,
240
+ imageElement: clipChildElement as VargElement<"image">,
241
+ });
242
+ break;
243
+ }
244
+
245
+ if (clipChildElement.type === "video") {
246
+ const { prompt, nestedImage } = extractFrameFromVideo(
247
+ clipChildElement as VargElement<"video">,
248
+ );
249
+ const videoProps = clipChildElement.props as VideoProps;
250
+
251
+ if (nestedImage) {
252
+ const imageProps = nestedImage.props as ImageProps;
253
+ frames.push({
254
+ clipIndex,
255
+ prompt: imageProps.prompt,
256
+ src: imageProps.src,
257
+ model: imageProps.model,
258
+ aspectRatio: imageProps.aspectRatio ?? videoProps.aspectRatio,
259
+ duration,
260
+ startTime: currentTime,
261
+ imageElement: nestedImage,
262
+ });
263
+ } else if (prompt) {
264
+ frames.push({
265
+ clipIndex,
266
+ prompt,
267
+ model: undefined,
268
+ aspectRatio: videoProps.aspectRatio,
269
+ duration,
270
+ startTime: currentTime,
271
+ });
272
+ }
273
+ break;
274
+ }
275
+ }
276
+
277
+ currentTime += duration;
278
+ clipIndex++;
279
+ }
280
+ }
281
+
282
+ return frames;
283
+ }
284
+
285
+ function findClipAtTime(
286
+ frames: FrameInfo[],
287
+ time: number,
288
+ ): FrameInfo | undefined {
289
+ for (const frame of frames) {
290
+ if (time >= frame.startTime && time < frame.startTime + frame.duration) {
291
+ return frame;
292
+ }
293
+ }
294
+ return frames[frames.length - 1];
295
+ }
296
+
297
+ async function detectDefaultImageModel() {
298
+ const falKey = process.env.FAL_API_KEY ?? process.env.FAL_KEY;
299
+ if (falKey) {
300
+ const { fal } = await import("../../ai-sdk/providers/fal");
301
+ return fal.imageModel("flux-schnell");
302
+ }
303
+ return undefined;
304
+ }
305
+
306
+ async function resolvePromptForGeneration(
307
+ prompt: ImagePrompt,
308
+ ctx: ImageGeneratorContext,
309
+ ): Promise<string | { text?: string; images: Uint8Array[] }> {
310
+ if (typeof prompt === "string") {
311
+ return prompt;
312
+ }
313
+
314
+ const resolvedImages: Uint8Array[] = [];
315
+ for (const img of prompt.images) {
316
+ const resolved = await resolveImageInput(img, ctx);
317
+ if (resolved) {
318
+ resolvedImages.push(resolved);
319
+ }
320
+ }
321
+
322
+ return { text: prompt.text, images: resolvedImages };
323
+ }
324
+
325
+ export const frameCmd = defineCommand({
326
+ meta: {
327
+ name: "frame",
328
+ description: "render still frames from component clips",
329
+ },
330
+ args: {
331
+ file: {
332
+ type: "positional" as const,
333
+ description: "component file (.tsx)",
334
+ required: true,
335
+ },
336
+ output: {
337
+ type: "string" as const,
338
+ alias: "o",
339
+ description: "output path pattern (use %d for clip number)",
340
+ },
341
+ at: {
342
+ type: "string" as const,
343
+ description: "render frame at specific timestamp (e.g., 2.5)",
344
+ },
345
+ clip: {
346
+ type: "string" as const,
347
+ alias: "c",
348
+ description: "render specific clip number only (1-indexed)",
349
+ },
350
+ all: {
351
+ type: "boolean" as const,
352
+ alias: "a",
353
+ description: "render all clips (default if no --at or --clip)",
354
+ default: false,
355
+ },
356
+ cache: {
357
+ type: "string" as const,
358
+ description: "cache directory",
359
+ default: ".cache/ai",
360
+ },
361
+ quiet: {
362
+ type: "boolean" as const,
363
+ alias: "q",
364
+ description: "minimal output",
365
+ default: false,
366
+ },
367
+ preview: {
368
+ type: "boolean" as const,
369
+ description: "use placeholder images (no AI generation)",
370
+ default: false,
371
+ },
372
+ },
373
+ async run({ args }) {
374
+ const file = args.file as string;
375
+
376
+ if (!file) {
377
+ console.error("usage: varg frame <component.tsx> [options]");
378
+ process.exit(1);
379
+ }
380
+
381
+ const component = await loadComponent(file);
382
+
383
+ if (!component || component.type !== "render") {
384
+ console.error("error: default export must be a <Render> element");
385
+ process.exit(1);
386
+ }
387
+
388
+ const renderProps = component.props as RenderProps;
389
+ const frames = extractFrames(component);
390
+
391
+ if (frames.length === 0) {
392
+ console.error("error: no clips with visual elements found");
393
+ process.exit(1);
394
+ }
395
+
396
+ let framesToRender: FrameInfo[] = [];
397
+
398
+ if (args.at) {
399
+ const time = Number.parseFloat(args.at as string);
400
+ const frame = findClipAtTime(frames, time);
401
+ if (frame) {
402
+ framesToRender = [frame];
403
+ } else {
404
+ console.error(`error: no clip found at timestamp ${time}s`);
405
+ process.exit(1);
406
+ }
407
+ } else if (args.clip) {
408
+ const clipNum = Number.parseInt(args.clip as string, 10) - 1;
409
+ const frame = frames.find((f) => f.clipIndex === clipNum);
410
+ if (frame) {
411
+ framesToRender = [frame];
412
+ } else {
413
+ console.error(
414
+ `error: clip ${args.clip} not found (have ${frames.length} clips)`,
415
+ );
416
+ process.exit(1);
417
+ }
418
+ } else {
419
+ framesToRender = frames;
420
+ }
421
+
422
+ const baseName = basename(file).replace(/\.tsx?$/, "");
423
+ const outputDir = dirname(
424
+ (args.output as string) ?? `output/${baseName}-frame-1.png`,
425
+ );
426
+
427
+ if (!existsSync(outputDir)) {
428
+ mkdirSync(outputDir, { recursive: true });
429
+ }
430
+
431
+ const defaultModel = await detectDefaultImageModel();
432
+
433
+ const cache = args.cache
434
+ ? fileCache({ dir: args.cache as string })
435
+ : undefined;
436
+ const cachedGenerateImage = cache
437
+ ? withCache(generateImage, { storage: cache })
438
+ : generateImage;
439
+
440
+ const wrapGenerateImage: typeof generateImage = async (opts) => {
441
+ if (args.preview) {
442
+ if (
443
+ typeof opts.model === "string" ||
444
+ opts.model.specificationVersion !== "v3"
445
+ ) {
446
+ return cachedGenerateImage(opts);
447
+ }
448
+ const wrappedModel = wrapImageModel({
449
+ model: opts.model,
450
+ middleware: imagePlaceholderFallbackMiddleware({
451
+ mode: "preview",
452
+ onFallback: () => {},
453
+ }),
454
+ });
455
+ return generateImage({ ...opts, model: wrappedModel });
456
+ }
457
+ return cachedGenerateImage(opts);
458
+ };
459
+
460
+ const outputPaths: string[] = [];
461
+
462
+ for (const frame of framesToRender) {
463
+ const outputPath =
464
+ (args.output as string)?.replace("%d", String(frame.clipIndex + 1)) ??
465
+ `output/${baseName}-frame-${frame.clipIndex + 1}.png`;
466
+
467
+ if (!args.quiet) {
468
+ console.log(`rendering clip ${frame.clipIndex + 1}...`);
469
+ }
470
+
471
+ if (frame.src) {
472
+ const response = await fetch(toFileUrl(frame.src));
473
+ const data = new Uint8Array(await response.arrayBuffer());
474
+ await Bun.write(outputPath, data);
475
+ outputPaths.push(outputPath);
476
+ continue;
477
+ }
478
+
479
+ if (!frame.prompt) {
480
+ if (!args.quiet) {
481
+ console.log(
482
+ ` skipping clip ${frame.clipIndex + 1} (no prompt or src)`,
483
+ );
484
+ }
485
+ continue;
486
+ }
487
+
488
+ const model = frame.model ?? defaultModel;
489
+ if (!model) {
490
+ console.error(
491
+ "error: no image model available. set FAL_API_KEY or specify model in component",
492
+ );
493
+ process.exit(1);
494
+ }
495
+
496
+ const generatorCtx: ImageGeneratorContext = {
497
+ generateImage: wrapGenerateImage,
498
+ defaultModel,
499
+ };
500
+
501
+ const resolvedPrompt = await resolvePromptForGeneration(
502
+ frame.prompt,
503
+ generatorCtx,
504
+ );
505
+
506
+ const cacheKey = frame.imageElement
507
+ ? computeCacheKey(frame.imageElement)
508
+ : undefined;
509
+
510
+ const { images } = await wrapGenerateImage({
511
+ model: model as Parameters<typeof generateImage>[0]["model"],
512
+ prompt: resolvedPrompt,
513
+ aspectRatio: frame.aspectRatio as `${number}:${number}` | undefined,
514
+ n: 1,
515
+ cacheKey,
516
+ } as Parameters<typeof generateImage>[0]);
517
+
518
+ const firstImage = images[0];
519
+ if (!firstImage?.uint8Array) {
520
+ console.error(
521
+ `error: image generation returned no data for clip ${frame.clipIndex + 1}`,
522
+ );
523
+ continue;
524
+ }
525
+
526
+ await Bun.write(outputPath, firstImage.uint8Array);
527
+ outputPaths.push(outputPath);
528
+
529
+ if (!args.quiet) {
530
+ console.log(` → ${outputPath}`);
531
+ }
532
+ }
533
+
534
+ if (!args.quiet) {
535
+ console.log(`\ndone! ${outputPaths.length} frame(s) rendered`);
536
+ }
537
+ },
538
+ });
539
+
540
+ function FrameHelpView() {
541
+ const examples = [
542
+ {
543
+ command: "varg frame video.tsx",
544
+ description: "render all clips as frames",
545
+ },
546
+ {
547
+ command: "varg frame video.tsx --clip 2",
548
+ description: "render only clip 2",
549
+ },
550
+ {
551
+ command: "varg frame video.tsx --at 5.5",
552
+ description: "render frame at 5.5 seconds",
553
+ },
554
+ {
555
+ command: "varg frame video.tsx -o frames/shot-%d.png",
556
+ description: "custom output pattern",
557
+ },
558
+ ];
559
+
560
+ return (
561
+ <VargBox title="varg frame">
562
+ <Box marginBottom={1}>
563
+ <Text>
564
+ render still frames from component clips. extracts the first visual
565
+ element from each clip and generates as png.
566
+ </Text>
567
+ </Box>
568
+
569
+ <Header>USAGE</Header>
570
+ <Box paddingLeft={2} marginBottom={1}>
571
+ <VargText variant="accent">
572
+ varg frame {"<file.tsx>"} [options]
573
+ </VargText>
574
+ </Box>
575
+
576
+ <Header>OPTIONS</Header>
577
+ <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
578
+ <Text>
579
+ <VargText variant="accent">-o, --output </VargText>output path pattern
580
+ (use %d for clip number)
581
+ </Text>
582
+ <Text>
583
+ <VargText variant="accent">--at </VargText>render frame at specific
584
+ timestamp (seconds)
585
+ </Text>
586
+ <Text>
587
+ <VargText variant="accent">-c, --clip </VargText>render specific clip
588
+ number only (1-indexed)
589
+ </Text>
590
+ <Text>
591
+ <VargText variant="accent">-a, --all </VargText>render all clips
592
+ (default)
593
+ </Text>
594
+ <Text>
595
+ <VargText variant="accent">--cache </VargText>cache directory
596
+ (default: .cache/ai)
597
+ </Text>
598
+ <Text>
599
+ <VargText variant="accent">--preview </VargText>use placeholder images
600
+ </Text>
601
+ <Text>
602
+ <VargText variant="accent">-q, --quiet </VargText>minimal output
603
+ </Text>
604
+ </Box>
605
+
606
+ <Header>EXAMPLES</Header>
607
+ <Box marginTop={1}>
608
+ <HelpBlock examples={examples} />
609
+ </Box>
610
+ </VargBox>
611
+ );
612
+ }
613
+
614
+ export function showFrameHelp() {
615
+ renderStatic(<FrameHelpView />);
616
+ }
@@ -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";