vargai 0.4.0-alpha70 → 0.4.0-alpha71
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 +1 -1
- package/src/ai-sdk/providers/fal.ts +1 -8
- package/src/cli/commands/render.tsx +18 -4
- package/src/react/async-elements.test.ts +618 -0
- package/src/react/elements.ts +79 -8
- package/src/react/examples/async/example_we_want_to_test.tsx +101 -0
- package/src/react/examples/async/render-all.ts +41 -0
- package/src/react/examples/async/run-all.ts +76 -0
- package/src/react/examples/async/simple-with-deps.tsx +23 -0
- package/src/react/examples/async/simple.tsx +22 -0
- package/src/react/examples/async/talking-head.tsx +54 -0
- package/src/react/index.ts +3 -0
- package/src/react/render.ts +38 -2
- package/src/react/renderers/captions.ts +6 -1
- package/src/react/renderers/clip.ts +15 -1
- package/src/react/renderers/image.ts +8 -20
- package/src/react/renderers/music.ts +6 -0
- package/src/react/renderers/render.ts +154 -70
- package/src/react/renderers/resolve-lazy.ts +67 -0
- package/src/react/renderers/speech.ts +6 -0
- package/src/react/renderers/video.ts +10 -0
- package/src/react/resolve-context-rendi.test.ts +114 -0
- package/src/react/resolve-context.test.ts +384 -0
- package/src/react/resolve-context.ts +37 -0
- package/src/react/resolve.ts +494 -0
- package/src/react/resolved-element.ts +53 -0
- package/src/react/runtime/jsx-dev-runtime.ts +20 -2
- package/src/react/runtime/jsx-runtime.ts +26 -2
- package/src/react/types.ts +18 -3
- package/src/studio/server.ts +12 -3
package/package.json
CHANGED
|
@@ -583,14 +583,7 @@ class FalVideoModel implements VideoModelV3 {
|
|
|
583
583
|
}
|
|
584
584
|
} else if (isKlingV3 || isKlingV26) {
|
|
585
585
|
// Duration must be string for Kling v2.6+ and O3 (v3)
|
|
586
|
-
|
|
587
|
-
const raw = duration ?? 5;
|
|
588
|
-
const clamped = isKlingV3
|
|
589
|
-
? Math.max(3, Math.min(15, raw))
|
|
590
|
-
: raw <= 5
|
|
591
|
-
? 5
|
|
592
|
-
: 10;
|
|
593
|
-
input.duration = String(clamped);
|
|
586
|
+
input.duration = String(duration ?? 5);
|
|
594
587
|
} else if (isGrokImagine) {
|
|
595
588
|
// Grok Imagine: duration 1-15 seconds (default 6)
|
|
596
589
|
input.duration = duration ?? 6;
|
|
@@ -33,6 +33,20 @@ async function detectDefaultModels(): Promise<DefaultModels | undefined> {
|
|
|
33
33
|
return Object.keys(defaults).length > 0 ? defaults : undefined;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the default export of a module.
|
|
38
|
+
* Handles async function exports: if mod.default is a function, call it and await.
|
|
39
|
+
*/
|
|
40
|
+
async function resolveDefaultExport(mod: {
|
|
41
|
+
default: unknown;
|
|
42
|
+
}): Promise<VargElement> {
|
|
43
|
+
let component = mod.default;
|
|
44
|
+
if (typeof component === "function") {
|
|
45
|
+
component = await (component as () => unknown)();
|
|
46
|
+
}
|
|
47
|
+
return component as VargElement;
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
async function loadComponent(filePath: string): Promise<VargElement> {
|
|
37
51
|
const resolvedPath = resolve(filePath);
|
|
38
52
|
const source = await Bun.file(resolvedPath).text();
|
|
@@ -54,7 +68,7 @@ async function loadComponent(filePath: string): Promise<VargElement> {
|
|
|
54
68
|
|
|
55
69
|
if (hasRelativeImport) {
|
|
56
70
|
const mod = await import(resolvedPath);
|
|
57
|
-
return mod
|
|
71
|
+
return resolveDefaultExport(mod);
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
if (hasVargaiImport) {
|
|
@@ -63,7 +77,7 @@ async function loadComponent(filePath: string): Promise<VargElement> {
|
|
|
63
77
|
|
|
64
78
|
try {
|
|
65
79
|
const mod = await import(tmpFile);
|
|
66
|
-
return mod
|
|
80
|
+
return resolveDefaultExport(mod);
|
|
67
81
|
} finally {
|
|
68
82
|
(await Bun.file(tmpFile).exists()) && (await Bun.write(tmpFile, ""));
|
|
69
83
|
}
|
|
@@ -72,7 +86,7 @@ async function loadComponent(filePath: string): Promise<VargElement> {
|
|
|
72
86
|
const hasAnyImport = source.includes(" from ");
|
|
73
87
|
if (hasAnyImport) {
|
|
74
88
|
const mod = await import(resolvedPath);
|
|
75
|
-
return mod
|
|
89
|
+
return resolveDefaultExport(mod);
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
const tmpFile = `${tmpDir}/${Date.now()}.tsx`;
|
|
@@ -80,7 +94,7 @@ async function loadComponent(filePath: string): Promise<VargElement> {
|
|
|
80
94
|
|
|
81
95
|
try {
|
|
82
96
|
const mod = await import(tmpFile);
|
|
83
|
-
return mod
|
|
97
|
+
return resolveDefaultExport(mod);
|
|
84
98
|
} finally {
|
|
85
99
|
(await Bun.file(tmpFile).exists()) && (await Bun.write(tmpFile, ""));
|
|
86
100
|
}
|
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { File } from "../ai-sdk/file";
|
|
3
|
+
import {
|
|
4
|
+
Captions,
|
|
5
|
+
Clip,
|
|
6
|
+
Image,
|
|
7
|
+
Music,
|
|
8
|
+
Render,
|
|
9
|
+
Speech,
|
|
10
|
+
Video,
|
|
11
|
+
} from "./elements";
|
|
12
|
+
import { resolveLazy } from "./renderers/resolve-lazy";
|
|
13
|
+
import { ResolvedElement } from "./resolved-element";
|
|
14
|
+
import type { VargElement, VargNode } from "./types";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Helper: create a ResolvedElement manually (bypasses AI generation)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
function makeResolved<T extends VargElement["type"]>(
|
|
20
|
+
element: VargElement<T>,
|
|
21
|
+
duration: number,
|
|
22
|
+
): ResolvedElement<T> {
|
|
23
|
+
const file = File.fromGenerated({
|
|
24
|
+
uint8Array: new Uint8Array([0, 1, 2, 3]),
|
|
25
|
+
mediaType: "audio/mpeg",
|
|
26
|
+
});
|
|
27
|
+
return new ResolvedElement(element, { file, duration });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Phase 1: ResolvedElement class
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
describe("ResolvedElement", () => {
|
|
34
|
+
test("wraps a VargElement with meta (file + duration)", () => {
|
|
35
|
+
const speech = Speech({ voice: "adam", children: "hello" });
|
|
36
|
+
const resolved = makeResolved(speech, 3.8);
|
|
37
|
+
|
|
38
|
+
expect(resolved.type).toBe("speech");
|
|
39
|
+
expect(resolved.props.voice).toBe("adam");
|
|
40
|
+
expect(resolved.children).toEqual(["hello"]);
|
|
41
|
+
expect(resolved.duration).toBe(3.8);
|
|
42
|
+
expect(resolved.meta.duration).toBe(3.8);
|
|
43
|
+
expect(resolved.meta.file).toBeInstanceOf(File);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("instanceof check works", () => {
|
|
47
|
+
const speech = Speech({ voice: "adam", children: "hello" });
|
|
48
|
+
const resolved = makeResolved(speech, 2.5);
|
|
49
|
+
|
|
50
|
+
expect(resolved instanceof ResolvedElement).toBe(true);
|
|
51
|
+
// A plain VargElement is NOT a ResolvedElement
|
|
52
|
+
expect(speech instanceof ResolvedElement).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("satisfies VargElement shape (can be used as child)", () => {
|
|
56
|
+
const speech = Speech({ voice: "adam", children: "hello" });
|
|
57
|
+
const resolved = makeResolved(speech, 4.0);
|
|
58
|
+
|
|
59
|
+
// Can be used as a Clip child
|
|
60
|
+
const clip = Clip({
|
|
61
|
+
duration: resolved.duration,
|
|
62
|
+
children: [resolved as unknown as VargNode],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(clip.type).toBe("clip");
|
|
66
|
+
expect(clip.props.duration).toBe(4.0);
|
|
67
|
+
expect(clip.children.length).toBe(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("duration getter reads from meta", () => {
|
|
71
|
+
const music = Music({ prompt: "chill beats" });
|
|
72
|
+
const resolved = makeResolved(music, 30.5);
|
|
73
|
+
|
|
74
|
+
expect(resolved.duration).toBe(30.5);
|
|
75
|
+
expect(resolved.meta.duration).toBe(30.5);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("file getter reads from meta", () => {
|
|
79
|
+
const image = Image({ prompt: "sunset" });
|
|
80
|
+
const resolved = makeResolved(image, 0);
|
|
81
|
+
|
|
82
|
+
expect(resolved.file).toBeInstanceOf(File);
|
|
83
|
+
expect(resolved.duration).toBe(0); // images have 0 duration
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("aspectRatio on meta", () => {
|
|
87
|
+
const image = Image({ prompt: "sunset", aspectRatio: "9:16" });
|
|
88
|
+
const file = File.fromGenerated({
|
|
89
|
+
uint8Array: new Uint8Array([0]),
|
|
90
|
+
mediaType: "image/png",
|
|
91
|
+
});
|
|
92
|
+
const resolved = new ResolvedElement(image, {
|
|
93
|
+
file,
|
|
94
|
+
duration: 0,
|
|
95
|
+
aspectRatio: "9:16",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(resolved.aspectRatio).toBe("9:16");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Phase 1: Element factories are thenable
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
describe("element factories are thenable", () => {
|
|
106
|
+
test("Speech() returns a VargElement with a .then method", () => {
|
|
107
|
+
const speech = Speech({ voice: "adam", children: "hello" });
|
|
108
|
+
|
|
109
|
+
expect(speech.type).toBe("speech");
|
|
110
|
+
expect(typeof (speech as any).then).toBe("function");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("Video() returns a VargElement with a .then method", () => {
|
|
114
|
+
const video = Video({ prompt: "sunset" });
|
|
115
|
+
|
|
116
|
+
expect(video.type).toBe("video");
|
|
117
|
+
expect(typeof (video as any).then).toBe("function");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("Image() returns a VargElement with a .then method", () => {
|
|
121
|
+
const image = Image({ prompt: "cat" });
|
|
122
|
+
|
|
123
|
+
expect(image.type).toBe("image");
|
|
124
|
+
expect(typeof (image as any).then).toBe("function");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("Music() returns a VargElement with a .then method", () => {
|
|
128
|
+
const music = Music({ prompt: "chill" });
|
|
129
|
+
|
|
130
|
+
expect(music.type).toBe("music");
|
|
131
|
+
expect(typeof (music as any).then).toBe("function");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("thenable elements still have .type (not treated as pure Promises)", () => {
|
|
135
|
+
// This is critical: the JSX runtime uses .type to distinguish thenable
|
|
136
|
+
// VargElements from async component Promises
|
|
137
|
+
const speech = Speech({ voice: "adam", children: "test" });
|
|
138
|
+
expect(speech.type).toBe("speech");
|
|
139
|
+
expect("type" in speech).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("Render, Clip, Captions are NOT thenable (no .then)", () => {
|
|
143
|
+
const render = Render({ width: 1080, height: 1920 });
|
|
144
|
+
const clip = Clip({ duration: 5 });
|
|
145
|
+
const captions = Captions({ style: "tiktok" });
|
|
146
|
+
|
|
147
|
+
expect((render as any).then).toBeUndefined();
|
|
148
|
+
expect((clip as any).then).toBeUndefined();
|
|
149
|
+
expect((captions as any).then).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Phase 1: ResolvedElement in composition tree
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
describe("ResolvedElement in composition tree", () => {
|
|
157
|
+
test("audio.duration drives Clip duration", () => {
|
|
158
|
+
const speech = Speech({ voice: "adam", children: "hello world" });
|
|
159
|
+
const audio = makeResolved(speech, 3.8);
|
|
160
|
+
|
|
161
|
+
const tree = Render({
|
|
162
|
+
width: 1080,
|
|
163
|
+
height: 1920,
|
|
164
|
+
children: [
|
|
165
|
+
Clip({
|
|
166
|
+
duration: audio.duration,
|
|
167
|
+
children: [audio as unknown as VargNode],
|
|
168
|
+
}),
|
|
169
|
+
],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(tree.type).toBe("render");
|
|
173
|
+
const clip = tree.children[0] as VargElement<"clip">;
|
|
174
|
+
expect(clip.type).toBe("clip");
|
|
175
|
+
expect(clip.props.duration).toBe(3.8);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("audio.duration in template literal", () => {
|
|
179
|
+
const speech = Speech({ voice: "adam", children: "hello" });
|
|
180
|
+
const audio = makeResolved(speech, 3.8);
|
|
181
|
+
|
|
182
|
+
const prompt = `This video is exactly ${audio.duration} seconds long.`;
|
|
183
|
+
expect(prompt).toBe("This video is exactly 3.8 seconds long.");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("resolved speech as Captions src", () => {
|
|
187
|
+
const speech = Speech({ voice: "adam", children: "hello" });
|
|
188
|
+
const audio = makeResolved(speech, 3.8);
|
|
189
|
+
|
|
190
|
+
const captions = Captions({
|
|
191
|
+
src: audio as unknown as VargElement<"speech">,
|
|
192
|
+
style: "tiktok",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(captions.type).toBe("captions");
|
|
196
|
+
expect(captions.props.src).toBe(audio);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("two resolved audios in same tree", () => {
|
|
200
|
+
const s1 = makeResolved(
|
|
201
|
+
Speech({ voice: "rachel", children: "part one" }),
|
|
202
|
+
2.5,
|
|
203
|
+
);
|
|
204
|
+
const s2 = makeResolved(
|
|
205
|
+
Speech({ voice: "rachel", children: "part two" }),
|
|
206
|
+
5.3,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const tree = Render({
|
|
210
|
+
width: 1080,
|
|
211
|
+
height: 1920,
|
|
212
|
+
children: [
|
|
213
|
+
Clip({
|
|
214
|
+
duration: s1.duration,
|
|
215
|
+
children: [s1 as unknown as VargNode],
|
|
216
|
+
}),
|
|
217
|
+
Clip({
|
|
218
|
+
duration: s2.duration,
|
|
219
|
+
children: [s2 as unknown as VargNode],
|
|
220
|
+
}),
|
|
221
|
+
],
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const clips = tree.children as VargElement[];
|
|
225
|
+
expect((clips[0] as VargElement<"clip">).props.duration).toBe(2.5);
|
|
226
|
+
expect((clips[1] as VargElement<"clip">).props.duration).toBe(5.3);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("dynamic clip count from audio.duration", () => {
|
|
230
|
+
const speech = Speech({ voice: "adam", children: "long narration..." });
|
|
231
|
+
const audio = makeResolved(speech, 12.4);
|
|
232
|
+
|
|
233
|
+
const clipDuration = 2;
|
|
234
|
+
const numClips = Math.ceil(audio.duration / clipDuration);
|
|
235
|
+
|
|
236
|
+
expect(numClips).toBe(7); // ceil(12.4 / 2) = 7
|
|
237
|
+
|
|
238
|
+
const clips = Array.from({ length: numClips }, (_, i) =>
|
|
239
|
+
Clip({
|
|
240
|
+
duration: Math.min(clipDuration, audio.duration - i * clipDuration),
|
|
241
|
+
key: i,
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
expect(clips.length).toBe(7);
|
|
246
|
+
expect(clips[0]!.props.duration).toBe(2);
|
|
247
|
+
expect(clips[6]!.props.duration).toBeCloseTo(0.4); // 12.4 - 6*2 = 0.4
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Phase 2: JSX runtime lazy wrapping
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
describe("JSX runtime lazy wrapping", () => {
|
|
255
|
+
// Import the jsx function directly
|
|
256
|
+
const { jsx } = require("./runtime/jsx-runtime");
|
|
257
|
+
|
|
258
|
+
test("sync component returns VargElement directly", () => {
|
|
259
|
+
function SyncScene(props: Record<string, unknown>) {
|
|
260
|
+
return Clip({ duration: 5 });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const result = jsx(SyncScene, {});
|
|
264
|
+
expect(result.type).toBe("clip");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("async component is wrapped as __lazy element", () => {
|
|
268
|
+
async function AsyncScene(props: Record<string, unknown>) {
|
|
269
|
+
return Clip({ duration: 5 });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const result = jsx(AsyncScene, {});
|
|
273
|
+
expect(result.type).toBe("__lazy");
|
|
274
|
+
expect(result.props._promise).toBeInstanceOf(Promise);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("thenable VargElement (Speech) is NOT wrapped as __lazy", () => {
|
|
278
|
+
// Speech() returns an object with both .type and .then
|
|
279
|
+
// The JSX runtime should recognize this as a VargElement, not a Promise
|
|
280
|
+
const result = jsx(Speech, { voice: "adam", children: "test" });
|
|
281
|
+
expect(result.type).toBe("speech");
|
|
282
|
+
expect(result.type).not.toBe("__lazy");
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Phase 2: resolveLazy
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
describe("resolveLazy", () => {
|
|
290
|
+
test("passes through plain VargElements unchanged", async () => {
|
|
291
|
+
const element = Render({
|
|
292
|
+
width: 1080,
|
|
293
|
+
height: 1920,
|
|
294
|
+
children: [Clip({ duration: 5 })],
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const resolved = (await resolveLazy(element)) as VargElement;
|
|
298
|
+
expect(resolved.type).toBe("render");
|
|
299
|
+
expect((resolved.children[0] as VargElement).type).toBe("clip");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("passes through primitives and nulls", async () => {
|
|
303
|
+
expect(await resolveLazy(null)).toBe(null);
|
|
304
|
+
expect(await resolveLazy(undefined)).toBe(undefined);
|
|
305
|
+
expect(await resolveLazy("hello")).toBe("hello");
|
|
306
|
+
expect(await resolveLazy(42)).toBe(42);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("resolves a __lazy element", async () => {
|
|
310
|
+
const lazyElement: VargElement<"__lazy"> = {
|
|
311
|
+
type: "__lazy",
|
|
312
|
+
props: {
|
|
313
|
+
_promise: Promise.resolve(Clip({ duration: 10 })),
|
|
314
|
+
},
|
|
315
|
+
children: [],
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const resolved = (await resolveLazy(lazyElement)) as VargElement;
|
|
319
|
+
expect(resolved.type).toBe("clip");
|
|
320
|
+
expect(resolved.props.duration).toBe(10);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("resolves __lazy that returns an array (Fragment)", async () => {
|
|
324
|
+
const lazyElement: VargElement<"__lazy"> = {
|
|
325
|
+
type: "__lazy",
|
|
326
|
+
props: {
|
|
327
|
+
_promise: Promise.resolve([
|
|
328
|
+
Clip({ duration: 3 }),
|
|
329
|
+
Clip({ duration: 5 }),
|
|
330
|
+
]),
|
|
331
|
+
},
|
|
332
|
+
children: [],
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const resolved = await resolveLazy(lazyElement);
|
|
336
|
+
expect(Array.isArray(resolved)).toBe(true);
|
|
337
|
+
const arr = resolved as VargNode[];
|
|
338
|
+
expect(arr.length).toBe(2);
|
|
339
|
+
expect((arr[0] as VargElement).type).toBe("clip");
|
|
340
|
+
expect((arr[1] as VargElement).type).toBe("clip");
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("resolves nested __lazy elements", async () => {
|
|
344
|
+
const innerLazy: VargElement<"__lazy"> = {
|
|
345
|
+
type: "__lazy",
|
|
346
|
+
props: { _promise: Promise.resolve(Clip({ duration: 7 })) },
|
|
347
|
+
children: [],
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const outerLazy: VargElement<"__lazy"> = {
|
|
351
|
+
type: "__lazy",
|
|
352
|
+
props: { _promise: Promise.resolve(innerLazy) },
|
|
353
|
+
children: [],
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const resolved = (await resolveLazy(outerLazy)) as VargElement;
|
|
357
|
+
expect(resolved.type).toBe("clip");
|
|
358
|
+
expect(resolved.props.duration).toBe(7);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("resolves __lazy inside Render children", async () => {
|
|
362
|
+
const tree = Render({
|
|
363
|
+
width: 1080,
|
|
364
|
+
height: 1920,
|
|
365
|
+
children: [
|
|
366
|
+
Clip({ duration: 3 }),
|
|
367
|
+
{
|
|
368
|
+
type: "__lazy" as const,
|
|
369
|
+
props: {
|
|
370
|
+
_promise: Promise.resolve([
|
|
371
|
+
Clip({ duration: 5 }),
|
|
372
|
+
Clip({ duration: 4 }),
|
|
373
|
+
]),
|
|
374
|
+
},
|
|
375
|
+
children: [],
|
|
376
|
+
} as VargElement<"__lazy">,
|
|
377
|
+
Clip({ duration: 2 }),
|
|
378
|
+
],
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const resolved = (await resolveLazy(tree)) as VargElement<"render">;
|
|
382
|
+
expect(resolved.type).toBe("render");
|
|
383
|
+
|
|
384
|
+
// Should have 4 children: the original Clip(3), the two from the lazy, and Clip(2)
|
|
385
|
+
const children = resolved.children as VargElement[];
|
|
386
|
+
expect(children.length).toBe(4);
|
|
387
|
+
expect(children[0]!.props.duration).toBe(3);
|
|
388
|
+
expect(children[1]!.props.duration).toBe(5);
|
|
389
|
+
expect(children[2]!.props.duration).toBe(4);
|
|
390
|
+
expect(children[3]!.props.duration).toBe(2);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("resolves async Scene-like component pattern", async () => {
|
|
394
|
+
// Simulates the strawberry-vs-chocolate example
|
|
395
|
+
async function Scene(props: { text: string; clipCount: number }) {
|
|
396
|
+
// In real code this would be: const audio = await Speech({...})
|
|
397
|
+
// Here we simulate it with a resolved element
|
|
398
|
+
const clips = Array.from({ length: props.clipCount }, (_, i) =>
|
|
399
|
+
Clip({ duration: 2, key: i }),
|
|
400
|
+
);
|
|
401
|
+
return clips; // Fragment-like return (array)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const { jsx } = require("./runtime/jsx-runtime");
|
|
405
|
+
|
|
406
|
+
const tree = Render({
|
|
407
|
+
width: 1080,
|
|
408
|
+
height: 1920,
|
|
409
|
+
children: [
|
|
410
|
+
jsx(Scene, { text: "strawberry", clipCount: 3 }),
|
|
411
|
+
jsx(Scene, { text: "chocolate", clipCount: 2 }),
|
|
412
|
+
],
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Before resolution: children contain __lazy elements
|
|
416
|
+
expect((tree.children[0] as VargElement).type).toBe("__lazy");
|
|
417
|
+
expect((tree.children[1] as VargElement).type).toBe("__lazy");
|
|
418
|
+
|
|
419
|
+
// After resolution: lazy elements are replaced with clip arrays
|
|
420
|
+
const resolved = (await resolveLazy(tree)) as VargElement<"render">;
|
|
421
|
+
const children = resolved.children as VargElement[];
|
|
422
|
+
|
|
423
|
+
// 3 clips from strawberry + 2 from chocolate = 5 total
|
|
424
|
+
expect(children.length).toBe(5);
|
|
425
|
+
for (const child of children) {
|
|
426
|
+
expect(child.type).toBe("clip");
|
|
427
|
+
expect(child.props.duration).toBe(2);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// Nested clips (container clip pattern)
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
describe("nested clips (container clip pattern)", () => {
|
|
436
|
+
test("container clip with child clips produces correct structure", () => {
|
|
437
|
+
// Pattern 1: shared audio across sub-clips
|
|
438
|
+
const audio = makeResolved(
|
|
439
|
+
Speech({ voice: "adam", children: "hello world" }),
|
|
440
|
+
4.0,
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const tree = Render({
|
|
444
|
+
width: 1080,
|
|
445
|
+
height: 1920,
|
|
446
|
+
children: [
|
|
447
|
+
Clip({
|
|
448
|
+
duration: audio.duration,
|
|
449
|
+
children: [
|
|
450
|
+
Clip({
|
|
451
|
+
duration: 2,
|
|
452
|
+
children: [Image({ prompt: "black dog" })],
|
|
453
|
+
}),
|
|
454
|
+
Clip({
|
|
455
|
+
duration: 2,
|
|
456
|
+
children: [Image({ prompt: "orange cat" })],
|
|
457
|
+
}),
|
|
458
|
+
Captions({
|
|
459
|
+
src: audio as unknown as VargElement<"speech">,
|
|
460
|
+
style: "tiktok",
|
|
461
|
+
}),
|
|
462
|
+
],
|
|
463
|
+
}),
|
|
464
|
+
],
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
expect(tree.type).toBe("render");
|
|
468
|
+
// The outer Clip is a container — it has child clips
|
|
469
|
+
const containerClip = tree.children[0] as VargElement<"clip">;
|
|
470
|
+
expect(containerClip.type).toBe("clip");
|
|
471
|
+
expect(containerClip.props.duration).toBe(4.0);
|
|
472
|
+
|
|
473
|
+
// Container has 3 children: 2 inner clips + captions
|
|
474
|
+
expect(containerClip.children.length).toBe(3);
|
|
475
|
+
expect((containerClip.children[0] as VargElement).type).toBe("clip");
|
|
476
|
+
expect((containerClip.children[1] as VargElement).type).toBe("clip");
|
|
477
|
+
expect((containerClip.children[2] as VargElement).type).toBe("captions");
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("pattern 2: per-clip audio with auto-duration parent", () => {
|
|
481
|
+
const s1 = makeResolved(
|
|
482
|
+
Speech({ voice: "adam", children: "this is a black dog" }),
|
|
483
|
+
2.5,
|
|
484
|
+
);
|
|
485
|
+
const s2 = makeResolved(
|
|
486
|
+
Speech({ voice: "adam", children: "this is an orange cat" }),
|
|
487
|
+
3.0,
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
const tree = Render({
|
|
491
|
+
width: 1080,
|
|
492
|
+
height: 1920,
|
|
493
|
+
children: [
|
|
494
|
+
Clip({
|
|
495
|
+
children: [
|
|
496
|
+
Clip({
|
|
497
|
+
duration: s1.duration,
|
|
498
|
+
children: [
|
|
499
|
+
Image({ prompt: "black dog" }),
|
|
500
|
+
s1 as unknown as VargNode,
|
|
501
|
+
],
|
|
502
|
+
}),
|
|
503
|
+
Clip({
|
|
504
|
+
duration: s2.duration,
|
|
505
|
+
children: [
|
|
506
|
+
Image({ prompt: "orange cat" }),
|
|
507
|
+
s2 as unknown as VargNode,
|
|
508
|
+
],
|
|
509
|
+
}),
|
|
510
|
+
],
|
|
511
|
+
}),
|
|
512
|
+
],
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// Outer clip has no explicit duration — children define it
|
|
516
|
+
const containerClip = tree.children[0] as VargElement<"clip">;
|
|
517
|
+
expect(containerClip.type).toBe("clip");
|
|
518
|
+
expect(containerClip.props.duration).toBeUndefined();
|
|
519
|
+
|
|
520
|
+
// Inner clips have durations matching their speech
|
|
521
|
+
const inner0 = containerClip.children[0] as VargElement<"clip">;
|
|
522
|
+
const inner1 = containerClip.children[1] as VargElement<"clip">;
|
|
523
|
+
expect(inner0.props.duration).toBe(2.5);
|
|
524
|
+
expect(inner1.props.duration).toBe(3.0);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("3-level nesting produces correct flat structure", () => {
|
|
528
|
+
// Scene > Acts > Clips
|
|
529
|
+
const tree = Render({
|
|
530
|
+
width: 1080,
|
|
531
|
+
height: 1920,
|
|
532
|
+
children: [
|
|
533
|
+
Clip({
|
|
534
|
+
children: [
|
|
535
|
+
Clip({
|
|
536
|
+
children: [
|
|
537
|
+
Clip({ duration: 3, children: [Image({ prompt: "a" })] }),
|
|
538
|
+
Clip({ duration: 3, children: [Image({ prompt: "b" })] }),
|
|
539
|
+
],
|
|
540
|
+
}),
|
|
541
|
+
Clip({
|
|
542
|
+
duration: 4,
|
|
543
|
+
children: [Image({ prompt: "c" })],
|
|
544
|
+
}),
|
|
545
|
+
],
|
|
546
|
+
}),
|
|
547
|
+
],
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Verify the structure exists — the flattening happens inside renderRoot,
|
|
551
|
+
// which we can't easily unit test without mocking the whole render pipeline.
|
|
552
|
+
// But we can verify the element tree is valid.
|
|
553
|
+
const scene = tree.children[0] as VargElement<"clip">;
|
|
554
|
+
expect(scene.type).toBe("clip");
|
|
555
|
+
const act1 = scene.children[0] as VargElement<"clip">;
|
|
556
|
+
const act2 = scene.children[1] as VargElement<"clip">;
|
|
557
|
+
expect(act1.type).toBe("clip");
|
|
558
|
+
expect(act2.type).toBe("clip");
|
|
559
|
+
expect(act2.props.duration).toBe(4);
|
|
560
|
+
|
|
561
|
+
const clip1 = act1.children[0] as VargElement<"clip">;
|
|
562
|
+
const clip2 = act1.children[1] as VargElement<"clip">;
|
|
563
|
+
expect(clip1.props.duration).toBe(3);
|
|
564
|
+
expect(clip2.props.duration).toBe(3);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("async Scene component with container clip pattern", async () => {
|
|
568
|
+
const { jsx } = require("./runtime/jsx-runtime");
|
|
569
|
+
|
|
570
|
+
async function Scene(props: { clipCount: number }) {
|
|
571
|
+
const audio = makeResolved(
|
|
572
|
+
Speech({ voice: "adam", children: "narration" }),
|
|
573
|
+
props.clipCount * 2,
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
return Clip({
|
|
577
|
+
duration: audio.duration,
|
|
578
|
+
children: [
|
|
579
|
+
...Array.from({ length: props.clipCount }, (_, i) =>
|
|
580
|
+
Clip({
|
|
581
|
+
duration: 2,
|
|
582
|
+
children: [Image({ prompt: `image ${i}` })],
|
|
583
|
+
}),
|
|
584
|
+
),
|
|
585
|
+
Captions({
|
|
586
|
+
src: audio as unknown as VargElement<"speech">,
|
|
587
|
+
style: "tiktok",
|
|
588
|
+
}),
|
|
589
|
+
],
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const tree = Render({
|
|
594
|
+
width: 1080,
|
|
595
|
+
height: 1920,
|
|
596
|
+
children: [jsx(Scene, { clipCount: 3 }), jsx(Scene, { clipCount: 2 })],
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// Before resolution: lazy elements
|
|
600
|
+
expect((tree.children[0] as VargElement).type).toBe("__lazy");
|
|
601
|
+
expect((tree.children[1] as VargElement).type).toBe("__lazy");
|
|
602
|
+
|
|
603
|
+
// After resolution: container clips
|
|
604
|
+
const resolved = (await resolveLazy(tree)) as VargElement<"render">;
|
|
605
|
+
const children = resolved.children as VargElement[];
|
|
606
|
+
|
|
607
|
+
expect(children.length).toBe(2);
|
|
608
|
+
expect(children[0]!.type).toBe("clip");
|
|
609
|
+
expect(children[0]!.props.duration).toBe(6); // 3 clips * 2s
|
|
610
|
+
expect(children[1]!.type).toBe("clip");
|
|
611
|
+
expect(children[1]!.props.duration).toBe(4); // 2 clips * 2s
|
|
612
|
+
|
|
613
|
+
// First container has 3 inner clips + 1 captions = 4 children
|
|
614
|
+
expect(children[0]!.children.length).toBe(4);
|
|
615
|
+
// Second container has 2 inner clips + 1 captions = 3 children
|
|
616
|
+
expect(children[1]!.children.length).toBe(3);
|
|
617
|
+
});
|
|
618
|
+
});
|