Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 61 additions & 9 deletions src/cli/commands/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ import { existsSync, mkdirSync } from "node:fs";
import { resolve } from "node:path";
import { defineCommand } from "citty";
import { Box, Text } from "ink";
import { render } from "../../react/render";
import type { DefaultModels, RenderMode, VargElement } from "../../react/types";
import { render, renderBatch } from "../../react/render";
import type {
BatchProps,
DefaultModels,
RenderMode,
VargElement,
} from "../../react/types";
import { Header, HelpBlock, VargBox, VargText } from "../ui/index.ts";
import { renderStatic } from "../ui/render.ts";

Expand Down Expand Up @@ -33,7 +38,9 @@ async function detectDefaultModels(): Promise<DefaultModels | undefined> {
return Object.keys(defaults).length > 0 ? defaults : undefined;
}

async function loadComponent(filePath: string): Promise<VargElement> {
async function loadComponent(
filePath: string,
): Promise<VargElement<"render"> | VargElement<"batch">> {
const resolvedPath = resolve(filePath);
const source = await Bun.file(resolvedPath).text();

Expand Down Expand Up @@ -141,11 +148,60 @@ async function runRender(

const component = await loadComponent(file);

if (!component || component.type !== "render") {
console.error("error: default export must be a <Render> element");
if (
!component ||
(component.type !== "render" && component.type !== "batch")
) {
console.error(
"error: default export must be a <Render> or <Batch> element",
);
process.exit(1);
}

const useCache = !args["no-cache"] && mode !== "preview";
const defaults = await detectDefaultModels();

if (component.type === "batch") {
const batchProps = component.props as BatchProps;
const basename = file
.replace(/\.tsx?$/, "")
.split("/")
.pop();
const outputDir =
(args.output as string) ?? batchProps.output ?? `output/${basename}`;

if (!args.quiet) {
const modeLabel = mode === "preview" ? " (fast)" : "";
const parallel = batchProps.parallel ?? 1;
console.log(`batch rendering ${file} → ${outputDir}/${modeLabel}`);
console.log(` concurrency: ${parallel}`);
}

const results = await renderBatch(component as VargElement<"batch">, {
output: outputDir,
cache: useCache ? (args.cache as string) : undefined,
mode,
defaults,
verbose: args.verbose as boolean,
quiet: args.quiet as boolean,
});

if (!args.quiet) {
const totalBytes = results.reduce(
(sum, r) => sum + r.buffer.byteLength,
0,
);
console.log(`done! ${results.length} videos, ${totalBytes} bytes total`);
}

if (args.open) {
const { $ } = await import("bun");
await $`open ${outputDir}`.quiet();
}

return;
}

const basename = file
.replace(/\.tsx?$/, "")
.split("/")
Expand All @@ -157,10 +213,6 @@ async function runRender(
console.log(`rendering ${file} → ${outputPath}${modeLabel}`);
}

const useCache = !args["no-cache"] && mode !== "preview";

const defaults = await detectDefaultModels();

const buffer = await render(component, {
output: outputPath,
cache: useCache ? (args.cache as string) : undefined,
Expand Down
9 changes: 9 additions & 0 deletions src/react/elements.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
BatchProps,
CaptionsProps,
ClipProps,
ImageProps,
Expand Down Expand Up @@ -38,6 +39,14 @@ function createElement<T extends VargElement["type"]>(
};
}

export function Batch(props: BatchProps): VargElement<"batch"> {
return createElement(
"batch",
props as Record<string, unknown>,
props.children,
);
}

export function Render(props: RenderProps): VargElement<"render"> {
return createElement(
"render",
Expand Down
40 changes: 40 additions & 0 deletions src/react/examples/batch-demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/** @jsxImportSource vargai */

import { fal } from "vargai/ai";
import { Batch, Clip, Image, Render, Video } from "vargai/react";

const HOOKS = [
"Stop satisfying everyone around you",
"Your comfort zone is killing your potential",
"Nobody is coming to save you",
"The only limit is your imagination",
];

const character = Image({
prompt: "confident young entrepreneur, casual hoodie, minimalist background",
model: fal.imageModel("flux-schnell"),
aspectRatio: "9:16",
});

export default (
<Batch parallel={2} output="output/hooks">
{HOOKS.map((hook) => (
<Render
key={hook}
name={hook.toLowerCase().replace(/\s+/g, "-")}
width={1080}
height={1920}
>
<Clip duration={5}>
<Video
prompt={{
text: `person speaking directly to camera: "${hook}"`,
images: [character],
}}
model={fal.videoModel("kling-v2.5")}
/>
</Clip>
</Render>
))}
</Batch>
);
4 changes: 3 additions & 1 deletion src/react/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type { SizeValue } from "../ai-sdk/providers/editly/types";
export { assets } from "./assets";
export {
Batch,
Captions,
Clip,
Image,
Expand All @@ -18,8 +19,9 @@ export {
Video,
} from "./elements";
export { Grid, SplitLayout } from "./layouts";
export { render, renderStream } from "./render";
export { type BatchResult, render, renderBatch, renderStream } from "./render";
export type {
BatchProps,
CaptionsProps,
ClipProps,
ImageProps,
Expand Down
6 changes: 6 additions & 0 deletions src/react/render.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { renderRoot } from "./renderers";
import { type BatchResult, renderBatch } from "./renderers/batch";
import type { RenderOptions, VargElement } from "./types";

export async function render(
element: VargElement,
options: RenderOptions = {},
): Promise<Uint8Array> {
if (element.type === "batch") {
throw new Error("Use renderBatch() for <Batch> elements");
}
if (element.type !== "render") {
throw new Error("Root element must be <Render>");
}

return renderRoot(element as VargElement<"render">, options);
}

export { renderBatch, type BatchResult };

export const renderStream = {
async *stream(element: VargElement, options: RenderOptions = {}) {
yield { type: "start", progress: 0 };
Expand Down
76 changes: 76 additions & 0 deletions src/react/renderers/batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import type {
BatchProps,
RenderOptions,
RenderProps,
VargElement,
} from "../types";
import { renderRoot } from "./render";

export interface BatchResult {
name: string;
path: string;
buffer: Uint8Array;
}

export async function renderBatch(
element: VargElement<"batch">,
options: RenderOptions,
): Promise<BatchResult[]> {
const props = element.props as BatchProps;
const parallel = props.parallel ?? 1;
const outputDir = props.output ?? options.output ?? "output";

mkdirSync(outputDir, { recursive: true });

const renderElements: VargElement<"render">[] = [];
for (const child of element.children) {
if (!child || typeof child !== "object" || !("type" in child)) continue;
const childElement = child as VargElement;
if (childElement.type === "render") {
renderElements.push(childElement as VargElement<"render">);
}
}

if (renderElements.length === 0) {
throw new Error("Batch requires at least one <Render> child");
}

const results: BatchResult[] = [];
const total = renderElements.length;

const renderOne = async (
renderElement: VargElement<"render">,
index: number,
): Promise<BatchResult> => {
const renderProps = renderElement.props as RenderProps;
const name = renderProps.name ?? `video-${index}`;
const outputPath = join(outputDir, `${name}.mp4`);

if (!options.quiet) {
console.log(`[${index + 1}/${total}] rendering ${name}...`);
}

const buffer = await renderRoot(renderElement, {
...options,
output: outputPath,
});

if (!options.quiet) {
console.log(`[${index + 1}/${total}] done: ${outputPath}`);
}

return { name, path: outputPath, buffer };
};

for (let i = 0; i < renderElements.length; i += parallel) {
const batch = renderElements.slice(i, i + parallel);
const batchResults = await Promise.all(
batch.map((el, j) => renderOne(el, i + j)),
);
results.push(...batchResults);
}

return results;
}
12 changes: 12 additions & 0 deletions src/react/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
import type { VideoModelV3 } from "../ai-sdk/video-model";

export type VargElementType =
| "batch"
| "render"
| "clip"
| "overlay"
Expand Down Expand Up @@ -62,8 +63,18 @@ export type TrimProps =
| { cutFrom?: number; cutTo?: number; duration?: never }
| { cutFrom?: number; cutTo?: never; duration?: number };

export interface BatchProps extends BaseProps {
/** Number of concurrent renders (default: 1) */
parallel?: number;
/** Output directory for all videos */
output?: string;
children?: VargNode;
}

// Root container - sets dimensions, fps, contains clips
export interface RenderProps extends BaseProps {
/** Name for output file (used in batch mode) */
name?: string;
width?: number;
height?: number;
fps?: number;
Expand Down Expand Up @@ -221,6 +232,7 @@ export interface RenderOptions {
}

export interface ElementPropsMap {
batch: BatchProps;
render: RenderProps;
clip: ClipProps;
overlay: OverlayProps;
Expand Down