diff --git a/src/cli/commands/render.tsx b/src/cli/commands/render.tsx index 4876ed92..b2b1133a 100644 --- a/src/cli/commands/render.tsx +++ b/src/cli/commands/render.tsx @@ -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"; @@ -33,7 +38,9 @@ async function detectDefaultModels(): Promise { return Object.keys(defaults).length > 0 ? defaults : undefined; } -async function loadComponent(filePath: string): Promise { +async function loadComponent( + filePath: string, +): Promise | VargElement<"batch">> { const resolvedPath = resolve(filePath); const source = await Bun.file(resolvedPath).text(); @@ -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 element"); + if ( + !component || + (component.type !== "render" && component.type !== "batch") + ) { + console.error( + "error: default export must be a or 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("/") @@ -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, diff --git a/src/react/elements.ts b/src/react/elements.ts index 2560d9f6..a0d33ea0 100644 --- a/src/react/elements.ts +++ b/src/react/elements.ts @@ -1,4 +1,5 @@ import type { + BatchProps, CaptionsProps, ClipProps, ImageProps, @@ -38,6 +39,14 @@ function createElement( }; } +export function Batch(props: BatchProps): VargElement<"batch"> { + return createElement( + "batch", + props as Record, + props.children, + ); +} + export function Render(props: RenderProps): VargElement<"render"> { return createElement( "render", diff --git a/src/react/examples/batch-demo.tsx b/src/react/examples/batch-demo.tsx new file mode 100644 index 00000000..850b3090 --- /dev/null +++ b/src/react/examples/batch-demo.tsx @@ -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 ( + + {HOOKS.map((hook) => ( + + + + + ))} + +); diff --git a/src/react/index.ts b/src/react/index.ts index 0efb9fd4..dc133acf 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,6 +1,7 @@ export type { SizeValue } from "../ai-sdk/providers/editly/types"; export { assets } from "./assets"; export { + Batch, Captions, Clip, Image, @@ -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, diff --git a/src/react/render.ts b/src/react/render.ts index 2c6ff343..9d101578 100644 --- a/src/react/render.ts +++ b/src/react/render.ts @@ -1,10 +1,14 @@ 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 { + if (element.type === "batch") { + throw new Error("Use renderBatch() for elements"); + } if (element.type !== "render") { throw new Error("Root element must be "); } @@ -12,6 +16,8 @@ export async function 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 }; diff --git a/src/react/renderers/batch.ts b/src/react/renderers/batch.ts new file mode 100644 index 00000000..c5953bd0 --- /dev/null +++ b/src/react/renderers/batch.ts @@ -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 { + 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 child"); + } + + const results: BatchResult[] = []; + const total = renderElements.length; + + const renderOne = async ( + renderElement: VargElement<"render">, + index: number, + ): Promise => { + 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; +} diff --git a/src/react/types.ts b/src/react/types.ts index 06a0f822..1a0f6d85 100644 --- a/src/react/types.ts +++ b/src/react/types.ts @@ -9,6 +9,7 @@ import type { import type { VideoModelV3 } from "../ai-sdk/video-model"; export type VargElementType = + | "batch" | "render" | "clip" | "overlay" @@ -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; @@ -221,6 +232,7 @@ export interface RenderOptions { } export interface ElementPropsMap { + batch: BatchProps; render: RenderProps; clip: ClipProps; overlay: OverlayProps;