vargai 0.3.2 → 0.4.0-alpha2

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 (64) hide show
  1. package/biome.json +6 -1
  2. package/docs/index.html +1130 -0
  3. package/docs/prompting.md +326 -0
  4. package/docs/react.md +834 -0
  5. package/package.json +10 -4
  6. package/src/cli/commands/index.ts +1 -4
  7. package/src/cli/commands/render.tsx +94 -0
  8. package/src/cli/index.ts +3 -2
  9. package/src/react/cli.ts +52 -0
  10. package/src/react/elements.ts +146 -0
  11. package/src/react/examples/branching.tsx +66 -0
  12. package/src/react/examples/captions-demo.tsx +37 -0
  13. package/src/react/examples/character-video.tsx +84 -0
  14. package/src/react/examples/grid.tsx +53 -0
  15. package/src/react/examples/layouts-demo.tsx +57 -0
  16. package/src/react/examples/madi.tsx +60 -0
  17. package/src/react/examples/music-test.tsx +35 -0
  18. package/src/react/examples/onlyfans-1m/workflow.tsx +88 -0
  19. package/src/react/examples/orange-portrait.tsx +41 -0
  20. package/src/react/examples/split-element-demo.tsx +60 -0
  21. package/src/react/examples/split-layout-demo.tsx +60 -0
  22. package/src/react/examples/split.tsx +41 -0
  23. package/src/react/examples/video-grid.tsx +46 -0
  24. package/src/react/index.ts +43 -0
  25. package/src/react/layouts/grid.tsx +28 -0
  26. package/src/react/layouts/index.ts +2 -0
  27. package/src/react/layouts/split.tsx +20 -0
  28. package/src/react/react.test.ts +309 -0
  29. package/src/react/render.ts +21 -0
  30. package/src/react/renderers/animate.ts +59 -0
  31. package/src/react/renderers/captions.ts +297 -0
  32. package/src/react/renderers/clip.ts +248 -0
  33. package/src/react/renderers/context.ts +17 -0
  34. package/src/react/renderers/image.ts +109 -0
  35. package/src/react/renderers/index.ts +22 -0
  36. package/src/react/renderers/music.ts +60 -0
  37. package/src/react/renderers/packshot.ts +84 -0
  38. package/src/react/renderers/progress.ts +173 -0
  39. package/src/react/renderers/render.ts +319 -0
  40. package/src/react/renderers/slider.ts +69 -0
  41. package/src/react/renderers/speech.ts +53 -0
  42. package/src/react/renderers/split.ts +91 -0
  43. package/src/react/renderers/subtitle.ts +16 -0
  44. package/src/react/renderers/swipe.ts +75 -0
  45. package/src/react/renderers/title.ts +17 -0
  46. package/src/react/renderers/utils.ts +124 -0
  47. package/src/react/renderers/video.ts +127 -0
  48. package/src/react/runtime/jsx-dev-runtime.ts +43 -0
  49. package/src/react/runtime/jsx-runtime.ts +35 -0
  50. package/src/react/types.ts +235 -0
  51. package/src/studio/index.ts +26 -0
  52. package/src/studio/scanner.ts +102 -0
  53. package/src/studio/server.ts +554 -0
  54. package/src/studio/stages.ts +251 -0
  55. package/src/studio/step-renderer.ts +279 -0
  56. package/src/studio/types.ts +60 -0
  57. package/src/studio/ui/cache.html +303 -0
  58. package/src/studio/ui/index.html +1820 -0
  59. package/tsconfig.cli.json +8 -0
  60. package/tsconfig.json +3 -1
  61. package/bun.lock +0 -1255
  62. package/docs/plan.md +0 -66
  63. package/docs/todo.md +0 -14
  64. /package/docs/{varg-sdk.md → sdk.md} +0 -0
@@ -0,0 +1,173 @@
1
+ export type GenerationType =
2
+ | "image"
3
+ | "video"
4
+ | "animate"
5
+ | "speech"
6
+ | "music"
7
+ | "editly"
8
+ | "captions"
9
+ | "transcribe";
10
+
11
+ export const TIME_ESTIMATES: Record<GenerationType, number> = {
12
+ image: 30,
13
+ video: 120,
14
+ animate: 90,
15
+ speech: 5,
16
+ music: 45,
17
+ editly: 15,
18
+ captions: 10,
19
+ transcribe: 15,
20
+ };
21
+
22
+ export const MODEL_TIME_ESTIMATES: Record<string, number> = {
23
+ // image models - use partial matching
24
+ "flux/schnell": 3,
25
+ "flux/dev": 8,
26
+ "flux-pro": 12,
27
+ recraft: 10,
28
+ ideogram: 8,
29
+ // video models
30
+ kling: 180,
31
+ "kling-v2": 180,
32
+ "kling-v2.5": 180,
33
+ minimax: 90,
34
+ luma: 90,
35
+ runway: 45,
36
+ veo: 120,
37
+ // speech models
38
+ elevenlabs: 5,
39
+ eleven: 5,
40
+ // music models
41
+ "stable-audio": 30,
42
+ musicgen: 45,
43
+ };
44
+
45
+ export interface ProgressTask {
46
+ id: string;
47
+ type: GenerationType;
48
+ model: string;
49
+ status: "pending" | "running" | "done";
50
+ startedAt?: number;
51
+ completedAt?: number;
52
+ }
53
+
54
+ export interface ProgressTracker {
55
+ tasks: ProgressTask[];
56
+ onUpdate?: (tracker: ProgressTracker) => void;
57
+ quiet: boolean;
58
+ }
59
+
60
+ export function createProgressTracker(quiet = false): ProgressTracker {
61
+ return {
62
+ tasks: [],
63
+ quiet,
64
+ };
65
+ }
66
+
67
+ export function addTask(
68
+ tracker: ProgressTracker,
69
+ type: GenerationType,
70
+ model: string,
71
+ ): string {
72
+ const id = `${type}-${tracker.tasks.length}`;
73
+ tracker.tasks.push({ id, type, model, status: "pending" });
74
+ return id;
75
+ }
76
+
77
+ export function startTask(tracker: ProgressTracker, id: string): void {
78
+ const task = tracker.tasks.find((t) => t.id === id);
79
+ if (task) {
80
+ task.status = "running";
81
+ task.startedAt = Date.now();
82
+ if (!tracker.quiet) {
83
+ logTaskStart(task);
84
+ }
85
+ }
86
+ tracker.onUpdate?.(tracker);
87
+ }
88
+
89
+ const CACHE_THRESHOLD_MS = 1000;
90
+
91
+ export function completeTask(tracker: ProgressTracker, id: string): void {
92
+ const task = tracker.tasks.find((t) => t.id === id);
93
+ if (task) {
94
+ task.status = "done";
95
+ task.completedAt = Date.now();
96
+ const duration = task.startedAt ? task.completedAt - task.startedAt : 0;
97
+ const wasCached = duration < CACHE_THRESHOLD_MS;
98
+ if (!tracker.quiet) {
99
+ logTaskComplete(task, wasCached);
100
+ }
101
+ }
102
+ tracker.onUpdate?.(tracker);
103
+ }
104
+
105
+ function getEstimate(task: ProgressTask): number {
106
+ const modelLower = task.model.toLowerCase();
107
+ for (const [key, estimate] of Object.entries(MODEL_TIME_ESTIMATES)) {
108
+ if (modelLower.includes(key.toLowerCase())) {
109
+ return estimate;
110
+ }
111
+ }
112
+ return TIME_ESTIMATES[task.type];
113
+ }
114
+
115
+ function logTaskStart(task: ProgressTask): void {
116
+ const estimate = getEstimate(task);
117
+ console.log(`⏳ generating ${task.type} with ${task.model} (~${estimate}s)`);
118
+ }
119
+
120
+ function logTaskComplete(task: ProgressTask, cached: boolean): void {
121
+ const duration =
122
+ task.completedAt && task.startedAt
123
+ ? ((task.completedAt - task.startedAt) / 1000).toFixed(1)
124
+ : "?";
125
+ if (cached) {
126
+ console.log(`⚡ ${task.type} from cache`);
127
+ } else {
128
+ console.log(`✓ ${task.type} done (${duration}s)`);
129
+ }
130
+ }
131
+
132
+ export function getTotalEstimate(tracker: ProgressTracker): number {
133
+ return tracker.tasks.reduce((sum, task) => sum + getEstimate(task), 0);
134
+ }
135
+
136
+ export function getElapsedTime(tracker: ProgressTracker): number {
137
+ const completed = tracker.tasks.filter((t) => t.status === "done");
138
+ const running = tracker.tasks.find((t) => t.status === "running");
139
+
140
+ let elapsed = 0;
141
+ for (const task of completed) {
142
+ if (task.startedAt && task.completedAt) {
143
+ elapsed += (task.completedAt - task.startedAt) / 1000;
144
+ }
145
+ }
146
+ if (running?.startedAt) {
147
+ elapsed += (Date.now() - running.startedAt) / 1000;
148
+ }
149
+ return elapsed;
150
+ }
151
+
152
+ export function getProgress(tracker: ProgressTracker): number {
153
+ const total = tracker.tasks.length;
154
+ if (total === 0) return 0;
155
+ const done = tracker.tasks.filter((t) => t.status === "done").length;
156
+ return Math.round((done / total) * 100);
157
+ }
158
+
159
+ export function renderProgressBar(tracker: ProgressTracker): string {
160
+ const progress = getProgress(tracker);
161
+ const total = getTotalEstimate(tracker);
162
+ const elapsed = getElapsedTime(tracker);
163
+
164
+ const width = 30;
165
+ const filled = Math.round((progress / 100) * width);
166
+ const empty = width - filled;
167
+ const bar = "█".repeat(filled) + "░".repeat(empty);
168
+
169
+ const done = tracker.tasks.filter((t) => t.status === "done").length;
170
+ const totalTasks = tracker.tasks.length;
171
+
172
+ return `[${bar}] ${progress}% (${done}/${totalTasks} tasks, ~${Math.round(total - elapsed)}s remaining)`;
173
+ }
@@ -0,0 +1,319 @@
1
+ import { generateImage, wrapImageModel } from "ai";
2
+ import { withCache } from "../../ai-sdk/cache";
3
+ import { fileCache } from "../../ai-sdk/file-cache";
4
+ import { generateVideo } from "../../ai-sdk/generate-video";
5
+ import {
6
+ imagePlaceholderFallbackMiddleware,
7
+ placeholderFallbackMiddleware,
8
+ wrapVideoModel,
9
+ } from "../../ai-sdk/middleware";
10
+ import { editly } from "../../ai-sdk/providers/editly";
11
+ import type {
12
+ AudioTrack,
13
+ Clip,
14
+ Layer,
15
+ VideoLayer,
16
+ } from "../../ai-sdk/providers/editly/types";
17
+ import type {
18
+ CaptionsProps,
19
+ ClipProps,
20
+ MusicProps,
21
+ OverlayProps,
22
+ RenderMode,
23
+ RenderOptions,
24
+ RenderProps,
25
+ SpeechProps,
26
+ VargElement,
27
+ } from "../types";
28
+ import { renderAnimate } from "./animate";
29
+ import { renderCaptions } from "./captions";
30
+ import { renderClip } from "./clip";
31
+ import type { RenderContext } from "./context";
32
+ import { renderImage } from "./image";
33
+ import { renderMusic } from "./music";
34
+ import {
35
+ addTask,
36
+ completeTask,
37
+ createProgressTracker,
38
+ startTask,
39
+ } from "./progress";
40
+ import { renderSpeech } from "./speech";
41
+ import { resolvePath } from "./utils";
42
+ import { renderVideo } from "./video";
43
+
44
+ interface RenderedOverlay {
45
+ path: string;
46
+ props: OverlayProps;
47
+ isVideo: boolean;
48
+ }
49
+
50
+ export async function renderRoot(
51
+ element: VargElement<"render">,
52
+ options: RenderOptions,
53
+ ): Promise<Uint8Array> {
54
+ const props = element.props as RenderProps;
55
+ const progress = createProgressTracker(options.quiet ?? false);
56
+
57
+ const mode: RenderMode = options.mode ?? "default";
58
+ const placeholderCount = { images: 0, videos: 0, total: 0 };
59
+
60
+ const onFallback = (error: Error, prompt: string) => {
61
+ if (!options.quiet) {
62
+ console.warn(
63
+ `\x1b[33m⚠ provider failed: ${error.message} → placeholder\x1b[0m`,
64
+ );
65
+ }
66
+ };
67
+
68
+ const trackPlaceholder = (type: "image" | "video") => {
69
+ placeholderCount[type === "image" ? "images" : "videos"]++;
70
+ placeholderCount.total++;
71
+ };
72
+
73
+ const wrapGenerateImage: typeof generateImage = async (opts) => {
74
+ if (
75
+ typeof opts.model === "string" ||
76
+ opts.model.specificationVersion !== "v3"
77
+ ) {
78
+ return generateImage(opts);
79
+ }
80
+ const wrappedModel = wrapImageModel({
81
+ model: opts.model,
82
+ middleware: imagePlaceholderFallbackMiddleware({
83
+ mode,
84
+ onFallback: (error, prompt) => {
85
+ trackPlaceholder("image");
86
+ onFallback(error, prompt);
87
+ },
88
+ }),
89
+ });
90
+ const result = await generateImage({ ...opts, model: wrappedModel });
91
+ if (mode === "preview") trackPlaceholder("image");
92
+ return result;
93
+ };
94
+
95
+ const wrapGenerateVideo: typeof generateVideo = async (opts) => {
96
+ const wrappedModel = wrapVideoModel({
97
+ model: opts.model,
98
+ middleware: placeholderFallbackMiddleware({
99
+ mode,
100
+ onFallback: (error, prompt) => {
101
+ trackPlaceholder("video");
102
+ onFallback(error, prompt);
103
+ },
104
+ }),
105
+ });
106
+ const result = await generateVideo({ ...opts, model: wrappedModel });
107
+ if (mode === "preview") trackPlaceholder("video");
108
+ return result;
109
+ };
110
+
111
+ const ctx: RenderContext = {
112
+ width: props.width ?? 1920,
113
+ height: props.height ?? 1080,
114
+ fps: props.fps ?? 30,
115
+ cache: options.cache ? fileCache({ dir: options.cache }) : undefined,
116
+ generateImage: options.cache
117
+ ? withCache(wrapGenerateImage, {
118
+ storage: fileCache({ dir: options.cache }),
119
+ })
120
+ : wrapGenerateImage,
121
+ generateVideo: options.cache
122
+ ? withCache(wrapGenerateVideo, {
123
+ storage: fileCache({ dir: options.cache }),
124
+ })
125
+ : wrapGenerateVideo,
126
+ tempFiles: [],
127
+ progress,
128
+ pending: new Map(),
129
+ };
130
+
131
+ const clipElements: VargElement<"clip">[] = [];
132
+ const overlayElements: VargElement<"overlay">[] = [];
133
+ const musicElements: VargElement<"music">[] = [];
134
+ const audioTracks: AudioTrack[] = [];
135
+ let captionsResult: Awaited<ReturnType<typeof renderCaptions>> | undefined;
136
+
137
+ for (const child of element.children) {
138
+ if (!child || typeof child !== "object" || !("type" in child)) continue;
139
+
140
+ const childElement = child as VargElement;
141
+
142
+ if (childElement.type === "clip") {
143
+ clipElements.push(childElement as VargElement<"clip">);
144
+ } else if (childElement.type === "overlay") {
145
+ overlayElements.push(childElement as VargElement<"overlay">);
146
+ } else if (childElement.type === "captions") {
147
+ captionsResult = await renderCaptions(
148
+ childElement as VargElement<"captions">,
149
+ ctx,
150
+ );
151
+ if (captionsResult.audioPath) {
152
+ audioTracks.push({
153
+ path: captionsResult.audioPath,
154
+ mixVolume: 1,
155
+ });
156
+ }
157
+ } else if (childElement.type === "speech") {
158
+ const result = await renderSpeech(
159
+ childElement as VargElement<"speech">,
160
+ ctx,
161
+ );
162
+ const speechProps = childElement.props as SpeechProps;
163
+ audioTracks.push({
164
+ path: result.path,
165
+ mixVolume: speechProps.volume ?? 1,
166
+ });
167
+ } else if (childElement.type === "music") {
168
+ musicElements.push(childElement as VargElement<"music">);
169
+ }
170
+ }
171
+
172
+ const renderedOverlays: RenderedOverlay[] = [];
173
+ for (const overlay of overlayElements) {
174
+ const overlayProps = overlay.props as OverlayProps;
175
+ for (const child of overlay.children) {
176
+ if (!child || typeof child !== "object" || !("type" in child)) continue;
177
+ const childElement = child as VargElement;
178
+
179
+ let path: string | undefined;
180
+ const isVideo =
181
+ childElement.type === "video" || childElement.type === "animate";
182
+
183
+ if (childElement.type === "video") {
184
+ path = await renderVideo(childElement as VargElement<"video">, ctx);
185
+ } else if (childElement.type === "animate") {
186
+ path = await renderAnimate(childElement as VargElement<"animate">, ctx);
187
+ } else if (childElement.type === "image") {
188
+ path = await renderImage(childElement as VargElement<"image">, ctx);
189
+ }
190
+
191
+ if (path) {
192
+ renderedOverlays.push({ path, props: overlayProps, isVideo });
193
+
194
+ if (isVideo && overlayProps.keepAudio) {
195
+ audioTracks.push({
196
+ path,
197
+ mixVolume: overlayProps.volume ?? 1,
198
+ });
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ const renderedClips = await Promise.all(
205
+ clipElements.map((clipElement) => renderClip(clipElement, ctx)),
206
+ );
207
+
208
+ const clips: Clip[] = [];
209
+ let currentTime = 0;
210
+
211
+ for (let i = 0; i < clipElements.length; i++) {
212
+ const clipElement = clipElements[i];
213
+ const clip = renderedClips[i];
214
+ if (!clipElement || !clip) {
215
+ throw new Error(`Missing clip data at index ${i}`);
216
+ }
217
+ const clipProps = clipElement.props as ClipProps;
218
+ const clipDuration =
219
+ typeof clipProps.duration === "number" ? clipProps.duration : 3;
220
+
221
+ for (const overlay of renderedOverlays) {
222
+ const overlayLayer: VideoLayer = {
223
+ type: "video",
224
+ path: overlay.path,
225
+ cutFrom: currentTime,
226
+ cutTo: currentTime + clipDuration,
227
+ left: overlay.props.left,
228
+ top: overlay.props.top,
229
+ width: overlay.props.width,
230
+ height: overlay.props.height,
231
+ };
232
+ clip.layers.push(overlayLayer as Layer);
233
+ }
234
+
235
+ clips.push(clip);
236
+
237
+ currentTime += clipDuration;
238
+ if (i < clipElements.length - 1 && clip.transition) {
239
+ currentTime -= clip.transition.duration ?? 0;
240
+ }
241
+ }
242
+
243
+ const totalDuration = currentTime;
244
+
245
+ // process music after clips so we know total duration for auto-trim
246
+ for (const musicElement of musicElements) {
247
+ const musicProps = musicElement.props as MusicProps;
248
+ const cutFrom = musicProps.cutFrom ?? 0;
249
+ const cutTo =
250
+ musicProps.cutTo ??
251
+ (musicProps.duration !== undefined
252
+ ? cutFrom + musicProps.duration
253
+ : totalDuration); // auto-trim to video length
254
+
255
+ let path: string;
256
+ if (musicProps.src) {
257
+ path = resolvePath(musicProps.src);
258
+ } else if (musicProps.prompt && musicProps.model) {
259
+ const result = await renderMusic(musicElement, ctx);
260
+ path = result.path;
261
+ } else {
262
+ throw new Error("Music requires either src or prompt+model");
263
+ }
264
+
265
+ audioTracks.push({
266
+ path,
267
+ mixVolume: musicProps.volume ?? 1,
268
+ cutFrom,
269
+ cutTo,
270
+ });
271
+ }
272
+
273
+ const hasCaptions = captionsResult !== undefined;
274
+
275
+ const tempOutPath = hasCaptions
276
+ ? `/tmp/varg-pre-captions-${Date.now()}.mp4`
277
+ : (options.output ?? `output/varg-${Date.now()}.mp4`);
278
+ const finalOutPath = options.output ?? `output/varg-${Date.now()}.mp4`;
279
+
280
+ const editlyTaskId = addTask(progress, "editly", "ffmpeg");
281
+ startTask(progress, editlyTaskId);
282
+
283
+ await editly({
284
+ outPath: tempOutPath,
285
+ width: ctx.width,
286
+ height: ctx.height,
287
+ fps: ctx.fps,
288
+ clips,
289
+ audioTracks: audioTracks.length > 0 ? audioTracks : undefined,
290
+ });
291
+
292
+ completeTask(progress, editlyTaskId);
293
+
294
+ if (hasCaptions && captionsResult) {
295
+ const captionsTaskId = addTask(progress, "captions", "ffmpeg");
296
+ startTask(progress, captionsTaskId);
297
+
298
+ const { $ } = await import("bun");
299
+ await $`ffmpeg -y -i ${tempOutPath} -vf "ass=${captionsResult.assPath}" -c:a copy ${finalOutPath}`.quiet();
300
+
301
+ ctx.tempFiles.push(tempOutPath);
302
+ completeTask(progress, captionsTaskId);
303
+ }
304
+
305
+ if (!options.quiet && placeholderCount.total > 0) {
306
+ if (mode === "preview") {
307
+ console.log(
308
+ `\x1b[36mℹ preview mode: ${placeholderCount.total} placeholders used (${placeholderCount.images} images, ${placeholderCount.videos} videos)\x1b[0m`,
309
+ );
310
+ } else {
311
+ console.warn(
312
+ `\x1b[33m⚠ ${placeholderCount.total} elements used placeholders - run with --strict for production\x1b[0m`,
313
+ );
314
+ }
315
+ }
316
+
317
+ const result = await Bun.file(finalOutPath).arrayBuffer();
318
+ return new Uint8Array(result);
319
+ }
@@ -0,0 +1,69 @@
1
+ import { editly } from "../../ai-sdk/providers/editly";
2
+ import type { Clip } from "../../ai-sdk/providers/editly/types";
3
+ import type { SliderProps, VargElement } from "../types";
4
+ import type { RenderContext } from "./context";
5
+ import { renderImage } from "./image";
6
+ import { renderVideo } from "./video";
7
+
8
+ export async function renderSlider(
9
+ element: VargElement<"slider">,
10
+ ctx: RenderContext,
11
+ ): Promise<string> {
12
+ const props = element.props as SliderProps;
13
+ const direction = props.direction ?? "horizontal";
14
+
15
+ const childPaths: string[] = [];
16
+
17
+ for (const child of element.children) {
18
+ if (!child || typeof child !== "object" || !("type" in child)) continue;
19
+ const childElement = child as VargElement;
20
+
21
+ if (childElement.type === "image") {
22
+ const path = await renderImage(childElement as VargElement<"image">, ctx);
23
+ childPaths.push(path);
24
+ } else if (childElement.type === "video") {
25
+ const path = await renderVideo(childElement as VargElement<"video">, ctx);
26
+ childPaths.push(path);
27
+ }
28
+ }
29
+
30
+ if (childPaths.length === 0) {
31
+ throw new Error(
32
+ "Slider element requires at least one image or video child",
33
+ );
34
+ }
35
+
36
+ if (childPaths.length === 1) {
37
+ return childPaths[0]!;
38
+ }
39
+
40
+ const transitionName = direction === "horizontal" ? "slideleft" : "slideup";
41
+
42
+ const clips: Clip[] = childPaths.map((path, i) => {
43
+ const isVideo = path.endsWith(".mp4") || path.endsWith(".webm");
44
+ const isLast = i === childPaths.length - 1;
45
+
46
+ return {
47
+ layers: [
48
+ isVideo
49
+ ? { type: "video" as const, path, resizeMode: "cover" as const }
50
+ : { type: "image" as const, path, resizeMode: "cover" as const },
51
+ ],
52
+ duration: 3,
53
+ transition: isLast ? null : { name: transitionName, duration: 0.5 },
54
+ };
55
+ });
56
+
57
+ const outPath = `/tmp/varg-slider-${Date.now()}.mp4`;
58
+
59
+ await editly({
60
+ outPath,
61
+ width: ctx.width,
62
+ height: ctx.height,
63
+ fps: ctx.fps,
64
+ clips,
65
+ });
66
+
67
+ ctx.tempFiles.push(outPath);
68
+ return outPath;
69
+ }
@@ -0,0 +1,53 @@
1
+ import { experimental_generateSpeech as generateSpeech } from "ai";
2
+ import { File } from "../../ai-sdk/file";
3
+ import type { SpeechProps, VargElement } from "../types";
4
+ import type { RenderContext } from "./context";
5
+ import { addTask, completeTask, startTask } from "./progress";
6
+ import { computeCacheKey, getTextContent } from "./utils";
7
+
8
+ export interface SpeechResult {
9
+ path: string;
10
+ duration?: number;
11
+ }
12
+
13
+ export async function renderSpeech(
14
+ element: VargElement<"speech">,
15
+ ctx: RenderContext,
16
+ ): Promise<SpeechResult> {
17
+ const props = element.props as SpeechProps;
18
+ const text = getTextContent(element.children);
19
+
20
+ if (!text) {
21
+ throw new Error("Speech element requires text content");
22
+ }
23
+
24
+ const model = props.model;
25
+ if (!model) {
26
+ throw new Error("Speech element requires 'model' prop");
27
+ }
28
+
29
+ const cacheKey = computeCacheKey(element);
30
+
31
+ const modelId = typeof model === "string" ? model : model.modelId;
32
+ const taskId = ctx.progress ? addTask(ctx.progress, "speech", modelId) : null;
33
+ if (taskId && ctx.progress) startTask(ctx.progress, taskId);
34
+
35
+ const { audio } = await generateSpeech({
36
+ model,
37
+ text,
38
+ voice: props.voice ?? "adam",
39
+ cacheKey,
40
+ } as Parameters<typeof generateSpeech>[0]);
41
+
42
+ if (taskId && ctx.progress) completeTask(ctx.progress, taskId);
43
+
44
+ const tempPath = await File.toTemp({
45
+ uint8Array: audio.uint8Array,
46
+ mimeType: "audio/mpeg",
47
+ });
48
+ ctx.tempFiles.push(tempPath);
49
+
50
+ return {
51
+ path: tempPath,
52
+ };
53
+ }
@@ -0,0 +1,91 @@
1
+ import { editly } from "../../ai-sdk/providers/editly";
2
+ import type { Clip, Layer } from "../../ai-sdk/providers/editly/types";
3
+ import type { SplitProps, VargElement } from "../types";
4
+ import type { RenderContext } from "./context";
5
+ import { renderImage } from "./image";
6
+ import { renderVideo } from "./video";
7
+
8
+ export async function renderSplit(
9
+ element: VargElement<"split">,
10
+ ctx: RenderContext,
11
+ ): Promise<string> {
12
+ const props = element.props as SplitProps;
13
+ const direction = props.direction ?? "horizontal";
14
+
15
+ const childPaths: string[] = [];
16
+
17
+ for (const child of element.children) {
18
+ if (!child || typeof child !== "object" || !("type" in child)) continue;
19
+ const childElement = child as VargElement;
20
+
21
+ if (childElement.type === "image") {
22
+ const path = await renderImage(childElement as VargElement<"image">, ctx);
23
+ childPaths.push(path);
24
+ } else if (childElement.type === "video") {
25
+ const path = await renderVideo(childElement as VargElement<"video">, ctx);
26
+ childPaths.push(path);
27
+ }
28
+ }
29
+
30
+ if (childPaths.length === 0) {
31
+ throw new Error("Split element requires at least one image or video child");
32
+ }
33
+
34
+ if (childPaths.length === 1) {
35
+ return childPaths[0]!;
36
+ }
37
+
38
+ const numChildren = childPaths.length;
39
+ const cellWidth =
40
+ direction === "horizontal"
41
+ ? Math.floor(ctx.width / numChildren)
42
+ : ctx.width;
43
+ const cellHeight =
44
+ direction === "vertical"
45
+ ? Math.floor(ctx.height / numChildren)
46
+ : ctx.height;
47
+
48
+ const layers: Layer[] = childPaths.map((path, i) => {
49
+ const isVideo = path.endsWith(".mp4") || path.endsWith(".webm");
50
+ const left = direction === "horizontal" ? cellWidth * i : 0;
51
+ const top = direction === "vertical" ? cellHeight * i : 0;
52
+
53
+ if (isVideo) {
54
+ return {
55
+ type: "video" as const,
56
+ path,
57
+ left,
58
+ top,
59
+ width: cellWidth,
60
+ height: cellHeight,
61
+ };
62
+ }
63
+ return {
64
+ type: "image-overlay" as const,
65
+ path,
66
+ position: { x: left, y: top },
67
+ width: cellWidth,
68
+ height: cellHeight,
69
+ };
70
+ });
71
+
72
+ layers.unshift({ type: "fill-color" as const, color: "#000000" });
73
+
74
+ const clip: Clip = {
75
+ layers,
76
+ duration: 5,
77
+ };
78
+
79
+ const outPath = `/tmp/varg-split-${Date.now()}.mp4`;
80
+
81
+ await editly({
82
+ outPath,
83
+ width: ctx.width,
84
+ height: ctx.height,
85
+ fps: ctx.fps,
86
+ clips: [clip],
87
+ });
88
+
89
+ ctx.tempFiles.push(outPath);
90
+ return outPath;
91
+ }