vargai 0.4.0-alpha4 → 0.4.0-alpha40
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/.env.example +6 -0
- package/README.md +483 -61
- package/assets/fonts/TikTokSans-Bold.ttf +0 -0
- package/examples/grok-imagine-test.tsx +155 -0
- package/launch-videos/06-kawaii-fruits.tsx +93 -0
- package/launch-videos/07-ugc-weight-loss.tsx +132 -0
- package/launch-videos/08-talking-head-varg.tsx +107 -0
- package/launch-videos/09-girl.tsx +160 -0
- package/launch-videos/README.md +42 -0
- package/package.json +10 -4
- package/pipeline/cookbooks/round-video-character.md +1 -1
- package/skills/varg-video-generation/SKILL.md +224 -0
- package/skills/varg-video-generation/references/templates.md +380 -0
- package/skills/varg-video-generation/scripts/setup.ts +265 -0
- package/src/ai-sdk/cache.ts +1 -3
- package/src/ai-sdk/examples/google-image.ts +62 -0
- package/src/ai-sdk/index.ts +10 -0
- package/src/ai-sdk/middleware/wrap-image-model.ts +4 -21
- package/src/ai-sdk/middleware/wrap-music-model.ts +4 -16
- package/src/ai-sdk/middleware/wrap-video-model.ts +5 -17
- package/src/ai-sdk/providers/CONTRIBUTING.md +457 -0
- package/src/ai-sdk/providers/editly/backends/index.ts +8 -0
- package/src/ai-sdk/providers/editly/backends/local.ts +94 -0
- package/src/ai-sdk/providers/editly/backends/types.ts +74 -0
- package/src/ai-sdk/providers/editly/editly.test.ts +49 -1
- package/src/ai-sdk/providers/editly/index.ts +164 -80
- package/src/ai-sdk/providers/editly/layers.ts +58 -6
- package/src/ai-sdk/providers/editly/rendi/editly-with-rendi-backend.test.ts +335 -0
- package/src/ai-sdk/providers/editly/rendi/index.ts +289 -0
- package/src/ai-sdk/providers/editly/rendi/rendi.test.ts +35 -0
- package/src/ai-sdk/providers/editly/types.ts +30 -0
- package/src/ai-sdk/providers/elevenlabs.ts +10 -2
- package/src/ai-sdk/providers/fal.test.ts +214 -0
- package/src/ai-sdk/providers/fal.ts +435 -40
- package/src/ai-sdk/providers/google.ts +423 -0
- package/src/ai-sdk/providers/together.ts +191 -0
- package/src/cli/commands/find.tsx +1 -0
- package/src/cli/commands/frame.tsx +616 -0
- package/src/cli/commands/hello.ts +85 -0
- package/src/cli/commands/help.tsx +18 -30
- package/src/cli/commands/index.ts +11 -2
- package/src/cli/commands/init.tsx +570 -0
- package/src/cli/commands/list.tsx +1 -0
- package/src/cli/commands/render.tsx +322 -76
- package/src/cli/commands/run.tsx +1 -0
- package/src/cli/commands/storyboard.tsx +1714 -0
- package/src/cli/commands/which.tsx +1 -0
- package/src/cli/index.ts +23 -4
- package/src/cli/ui/components/Badge.tsx +1 -0
- package/src/cli/ui/components/DataTable.tsx +1 -0
- package/src/cli/ui/components/Header.tsx +1 -0
- package/src/cli/ui/components/HelpBlock.tsx +1 -0
- package/src/cli/ui/components/KeyValue.tsx +1 -0
- package/src/cli/ui/components/OptionRow.tsx +1 -0
- package/src/cli/ui/components/Separator.tsx +1 -0
- package/src/cli/ui/components/StatusBox.tsx +1 -0
- package/src/cli/ui/components/VargBox.tsx +1 -0
- package/src/cli/ui/components/VargProgress.tsx +1 -0
- package/src/cli/ui/components/VargSpinner.tsx +1 -0
- package/src/cli/ui/components/VargText.tsx +1 -0
- package/src/definitions/actions/grok-edit.ts +133 -0
- package/src/definitions/actions/index.ts +16 -0
- package/src/definitions/actions/qwen-angles.ts +218 -0
- package/src/index.ts +1 -0
- package/src/providers/fal.ts +196 -0
- package/src/react/assets.ts +9 -0
- package/src/react/elements.ts +0 -5
- package/src/react/examples/branching.tsx +6 -4
- package/src/react/examples/character-video.tsx +13 -10
- package/src/react/examples/local-files-test.tsx +19 -0
- package/src/react/examples/ltx2-test.tsx +25 -0
- package/src/react/examples/madi.tsx +13 -10
- package/src/react/examples/mcmeows.tsx +40 -0
- package/src/react/examples/music-defaults.tsx +24 -0
- package/src/react/examples/quickstart-test.tsx +101 -0
- package/src/react/examples/qwen-angles-test.tsx +72 -0
- package/src/react/index.ts +3 -3
- package/src/react/layouts/grid.tsx +1 -1
- package/src/react/layouts/index.ts +2 -1
- package/src/react/layouts/slot.tsx +85 -0
- package/src/react/layouts/split.tsx +18 -0
- package/src/react/react.test.ts +60 -11
- package/src/react/renderers/burn-captions.ts +95 -0
- package/src/react/renderers/cache.test.ts +182 -0
- package/src/react/renderers/captions.ts +25 -6
- package/src/react/renderers/clip.ts +56 -25
- package/src/react/renderers/context.ts +5 -2
- package/src/react/renderers/image.ts +5 -2
- package/src/react/renderers/index.ts +0 -1
- package/src/react/renderers/music.ts +8 -3
- package/src/react/renderers/packshot/blinking-button.ts +413 -0
- package/src/react/renderers/packshot.ts +170 -8
- package/src/react/renderers/progress.ts +4 -3
- package/src/react/renderers/render.ts +127 -71
- package/src/react/renderers/speech.ts +2 -2
- package/src/react/renderers/split.ts +34 -13
- package/src/react/renderers/utils.test.ts +80 -0
- package/src/react/renderers/utils.ts +37 -1
- package/src/react/renderers/video.ts +47 -9
- package/src/react/types.ts +70 -17
- package/src/studio/stages.ts +40 -39
- package/src/studio/step-renderer.ts +14 -24
- package/src/studio/ui/index.html +2 -2
- package/src/tests/all.test.ts +4 -4
- package/src/tests/index.ts +1 -1
- package/test-slot-grid.tsx +19 -0
- package/test-slot-userland.tsx +30 -0
- package/test-sync-v2.ts +30 -0
- package/test-sync-v2.tsx +29 -0
- package/tsconfig.json +1 -1
- package/video.tsx +7 -0
- package/src/ai-sdk/providers/editly/ffmpeg.ts +0 -60
- package/src/react/renderers/animate.ts +0 -59
- /package/src/cli/commands/{studio.tsx → studio.ts} +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CropPosition,
|
|
3
|
+
ResizeMode,
|
|
4
|
+
} from "../../ai-sdk/providers/editly/types";
|
|
5
|
+
import type { VargElement } from "../types";
|
|
6
|
+
|
|
7
|
+
type SlotFit = "cover" | "contain" | "contain-blur" | "fill";
|
|
8
|
+
|
|
9
|
+
type SlotPosition =
|
|
10
|
+
| "center"
|
|
11
|
+
| "top"
|
|
12
|
+
| "bottom"
|
|
13
|
+
| "left"
|
|
14
|
+
| "right"
|
|
15
|
+
| "top-left"
|
|
16
|
+
| "top-right"
|
|
17
|
+
| "bottom-left"
|
|
18
|
+
| "bottom-right";
|
|
19
|
+
|
|
20
|
+
interface SlotProps {
|
|
21
|
+
class?: string;
|
|
22
|
+
fit?: SlotFit;
|
|
23
|
+
position?: SlotPosition;
|
|
24
|
+
children: VargElement;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ParsedSlotClass {
|
|
28
|
+
fit?: SlotFit;
|
|
29
|
+
position?: SlotPosition;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseSlotClass(classString?: string): ParsedSlotClass {
|
|
33
|
+
if (!classString) return {};
|
|
34
|
+
const result: ParsedSlotClass = {};
|
|
35
|
+
|
|
36
|
+
for (const cls of classString.trim().split(/\s+/)) {
|
|
37
|
+
if (cls === "fit-cover") result.fit = "cover";
|
|
38
|
+
else if (cls === "fit-contain") result.fit = "contain";
|
|
39
|
+
else if (cls === "fit-contain-blur") result.fit = "contain-blur";
|
|
40
|
+
else if (cls === "fit-fill") result.fit = "fill";
|
|
41
|
+
else if (cls === "pos-center") result.position = "center";
|
|
42
|
+
else if (cls === "pos-top") result.position = "top";
|
|
43
|
+
else if (cls === "pos-bottom") result.position = "bottom";
|
|
44
|
+
else if (cls === "pos-left") result.position = "left";
|
|
45
|
+
else if (cls === "pos-right") result.position = "right";
|
|
46
|
+
else if (cls === "pos-top-left") result.position = "top-left";
|
|
47
|
+
else if (cls === "pos-top-right") result.position = "top-right";
|
|
48
|
+
else if (cls === "pos-bottom-left") result.position = "bottom-left";
|
|
49
|
+
else if (cls === "pos-bottom-right") result.position = "bottom-right";
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function slotFitToResize(fit: SlotFit): ResizeMode {
|
|
55
|
+
switch (fit) {
|
|
56
|
+
case "cover":
|
|
57
|
+
return "cover";
|
|
58
|
+
case "contain":
|
|
59
|
+
return "contain";
|
|
60
|
+
case "contain-blur":
|
|
61
|
+
return "contain-blur";
|
|
62
|
+
case "fill":
|
|
63
|
+
return "stretch";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const Slot = ({
|
|
68
|
+
class: className,
|
|
69
|
+
fit,
|
|
70
|
+
position,
|
|
71
|
+
children,
|
|
72
|
+
}: SlotProps) => {
|
|
73
|
+
const parsed = parseSlotClass(className);
|
|
74
|
+
const resolvedFit = fit ?? parsed.fit ?? "cover";
|
|
75
|
+
const resolvedPosition = position ?? parsed.position ?? "center";
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
...children,
|
|
79
|
+
props: {
|
|
80
|
+
...children.props,
|
|
81
|
+
resize: slotFitToResize(resolvedFit),
|
|
82
|
+
cropPosition: resolvedPosition as CropPosition,
|
|
83
|
+
},
|
|
84
|
+
} as VargElement;
|
|
85
|
+
};
|
|
@@ -18,3 +18,21 @@ export const SplitLayout = ({
|
|
|
18
18
|
{right}
|
|
19
19
|
</Grid>
|
|
20
20
|
);
|
|
21
|
+
|
|
22
|
+
export const Split = ({
|
|
23
|
+
direction = "horizontal",
|
|
24
|
+
children,
|
|
25
|
+
}: {
|
|
26
|
+
direction?: "horizontal" | "vertical";
|
|
27
|
+
children: VargElement[];
|
|
28
|
+
}) => {
|
|
29
|
+
if (children.length === 0) return null;
|
|
30
|
+
return (
|
|
31
|
+
<Grid
|
|
32
|
+
columns={direction === "horizontal" ? children.length : 1}
|
|
33
|
+
rows={direction === "vertical" ? children.length : 1}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
</Grid>
|
|
37
|
+
);
|
|
38
|
+
};
|
package/src/react/react.test.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import { existsSync, unlinkSync } from "node:fs";
|
|
3
3
|
import { fal } from "../ai-sdk/providers/fal";
|
|
4
4
|
import {
|
|
5
|
-
Animate,
|
|
6
5
|
Captions,
|
|
7
6
|
Clip,
|
|
8
7
|
Image,
|
|
@@ -80,19 +79,20 @@ describe("varg-react elements", () => {
|
|
|
80
79
|
expect(element.children).toContain("I'M IN DANGER");
|
|
81
80
|
});
|
|
82
81
|
|
|
83
|
-
test("
|
|
82
|
+
test("Video creates correct element with nested image", () => {
|
|
84
83
|
const image = Image({ prompt: "luigi in wheelchair" });
|
|
85
|
-
const element =
|
|
86
|
-
image,
|
|
84
|
+
const element = Video({
|
|
85
|
+
prompt: { text: "wheels spinning fast", images: [image] },
|
|
87
86
|
model: fal.videoModel("wan-2.5"),
|
|
88
|
-
motion: "wheels spinning fast",
|
|
89
|
-
duration: 5,
|
|
90
87
|
});
|
|
91
88
|
|
|
92
|
-
expect(element.type).toBe("
|
|
93
|
-
expect(element.props.
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
expect(element.type).toBe("video");
|
|
90
|
+
expect((element.props.prompt as { images: unknown[] }).images[0]).toBe(
|
|
91
|
+
image,
|
|
92
|
+
);
|
|
93
|
+
expect((element.props.prompt as { text: string }).text).toBe(
|
|
94
|
+
"wheels spinning fast",
|
|
95
|
+
);
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
test("nested composition builds correct tree", () => {
|
|
@@ -157,6 +157,55 @@ describe("varg-react render", () => {
|
|
|
157
157
|
|
|
158
158
|
expect(render(root)).rejects.toThrow("model");
|
|
159
159
|
});
|
|
160
|
+
|
|
161
|
+
test("parallel failures preserve successful results and report all errors", async () => {
|
|
162
|
+
let callCount = 0;
|
|
163
|
+
const mockModel = {
|
|
164
|
+
specificationVersion: "v3" as const,
|
|
165
|
+
provider: "mock",
|
|
166
|
+
modelId: "mock-model",
|
|
167
|
+
maxImagesPerCall: 1,
|
|
168
|
+
doGenerate: mock(async () => {
|
|
169
|
+
callCount++;
|
|
170
|
+
if (callCount === 2) {
|
|
171
|
+
throw new Error("Request Timeout");
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
images: [new Uint8Array([0x89, 0x50, 0x4e, 0x47])],
|
|
175
|
+
warnings: [],
|
|
176
|
+
response: {
|
|
177
|
+
timestamp: new Date(),
|
|
178
|
+
modelId: "mock",
|
|
179
|
+
headers: undefined,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const root = Render({
|
|
186
|
+
width: 720,
|
|
187
|
+
height: 720,
|
|
188
|
+
children: [
|
|
189
|
+
Clip({
|
|
190
|
+
duration: 1,
|
|
191
|
+
children: [Image({ prompt: "first", model: mockModel })],
|
|
192
|
+
}),
|
|
193
|
+
Clip({
|
|
194
|
+
duration: 1,
|
|
195
|
+
children: [Image({ prompt: "second", model: mockModel })],
|
|
196
|
+
}),
|
|
197
|
+
Clip({
|
|
198
|
+
duration: 1,
|
|
199
|
+
children: [Image({ prompt: "third", model: mockModel })],
|
|
200
|
+
}),
|
|
201
|
+
],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const error = await render(root, { quiet: true }).catch((e) => e);
|
|
205
|
+
expect(error.message).toContain("1 of 3 clips failed");
|
|
206
|
+
expect(error.message).toContain("Request Timeout");
|
|
207
|
+
expect(callCount).toBe(3);
|
|
208
|
+
});
|
|
160
209
|
});
|
|
161
210
|
|
|
162
211
|
describe("layout renderers", () => {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { localBackend } from "@/ai-sdk/providers/editly";
|
|
2
|
+
import type {
|
|
3
|
+
FFmpegBackend,
|
|
4
|
+
FFmpegOutput,
|
|
5
|
+
} from "../../ai-sdk/providers/editly/backends/types";
|
|
6
|
+
import { uploadBuffer } from "../../providers/storage";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves an FFmpegOutput to a string path/URL, uploading local files if needed.
|
|
10
|
+
*
|
|
11
|
+
* - URL input → returns URL as-is
|
|
12
|
+
* - File input + shouldUpload=false → returns local path
|
|
13
|
+
* - File input + shouldUpload=true → uploads to storage, returns URL
|
|
14
|
+
*/
|
|
15
|
+
async function resolveInputPathMaybeUpload(
|
|
16
|
+
input: FFmpegOutput,
|
|
17
|
+
options: { shouldUpload: boolean },
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
if (input.type === "url") return input.url;
|
|
20
|
+
if (!options.shouldUpload) return input.path;
|
|
21
|
+
|
|
22
|
+
const buffer = await Bun.file(input.path).arrayBuffer();
|
|
23
|
+
return uploadBuffer(
|
|
24
|
+
buffer,
|
|
25
|
+
`tmp/${Date.now()}-${input.path.split("/").pop()}`,
|
|
26
|
+
"application/octet-stream",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CaptionOverlayOptions {
|
|
31
|
+
video: FFmpegOutput;
|
|
32
|
+
assPath: string;
|
|
33
|
+
outputPath: string;
|
|
34
|
+
backend?: FFmpegBackend;
|
|
35
|
+
verbose?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Burns ASS subtitle captions onto a video using FFmpeg.
|
|
40
|
+
*
|
|
41
|
+
* {@link burnCaptions} composites the subtitle file directly into the video frames,
|
|
42
|
+
* producing a new video with hardcoded captions. Supports both local and cloud
|
|
43
|
+
* FFmpeg backends - when using a cloud backend, input files are automatically
|
|
44
|
+
* uploaded to storage.
|
|
45
|
+
*
|
|
46
|
+
* @param options - Configuration for the caption burn operation
|
|
47
|
+
* @param options.video - Source video as {@link FFmpegOutput} (file path or URL)
|
|
48
|
+
* @param options.assPath - Path to the ASS subtitle file to burn
|
|
49
|
+
* @param options.outputPath - Destination path for the output video (defaults to "output.mp4")
|
|
50
|
+
* @param options.backend - Optional {@link FFmpegBackend} for cloud processing; uses local FFmpeg if omitted
|
|
51
|
+
* @param options.verbose - Enable verbose FFmpeg logging
|
|
52
|
+
*
|
|
53
|
+
* @returns Promise resolving to {@link FFmpegOutput} containing the path or URL of the captioned video
|
|
54
|
+
*
|
|
55
|
+
* @throws May throw if FFmpeg execution fails, input files are missing, or upload fails for cloud backends
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* const result = await burnCaptions({
|
|
60
|
+
* video: { type: "file", path: "input.mp4" },
|
|
61
|
+
* assPath: "captions.ass",
|
|
62
|
+
* outputPath: "output-with-captions.mp4",
|
|
63
|
+
* });
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export async function burnCaptions(
|
|
67
|
+
options: CaptionOverlayOptions,
|
|
68
|
+
): Promise<FFmpegOutput> {
|
|
69
|
+
const { video, assPath, outputPath = "output.mp4", verbose } = options;
|
|
70
|
+
const captions: FFmpegOutput = { type: "file", path: assPath };
|
|
71
|
+
|
|
72
|
+
const isCloud = options.backend !== undefined;
|
|
73
|
+
|
|
74
|
+
const videoInput = await resolveInputPathMaybeUpload(video, {
|
|
75
|
+
shouldUpload: isCloud,
|
|
76
|
+
});
|
|
77
|
+
const assInput = await resolveInputPathMaybeUpload(captions, {
|
|
78
|
+
shouldUpload: isCloud,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const backend = options.backend ?? localBackend;
|
|
82
|
+
|
|
83
|
+
// FFmpeg filter syntax requires escaping backslashes and colons
|
|
84
|
+
const escapedAssPath = assInput.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
|
|
85
|
+
|
|
86
|
+
const result = await backend.run({
|
|
87
|
+
inputs: [videoInput, assInput],
|
|
88
|
+
videoFilter: `subtitles=${escapedAssPath}`,
|
|
89
|
+
outputArgs: ["-crf", "18", "-preset", "fast", "-c:a", "copy"],
|
|
90
|
+
outputPath,
|
|
91
|
+
verbose,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return result.output;
|
|
95
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { ImageModelV3 } from "@ai-sdk/provider";
|
|
6
|
+
import { withCache } from "../../ai-sdk/cache";
|
|
7
|
+
import { fileCache } from "../../ai-sdk/file-cache";
|
|
8
|
+
import type { VideoModelV3 } from "../../ai-sdk/video-model";
|
|
9
|
+
import { Image, Video } from "../elements";
|
|
10
|
+
import type { RenderContext } from "./context";
|
|
11
|
+
import { renderImage } from "./image";
|
|
12
|
+
import { renderVideo } from "./video";
|
|
13
|
+
|
|
14
|
+
function makeTempDir(): string {
|
|
15
|
+
return mkdtempSync(join(tmpdir(), "varg-cache-test-"));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cleanupTempDir(dir: string) {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createImageModel(): ImageModelV3 {
|
|
23
|
+
return {
|
|
24
|
+
specificationVersion: "v3",
|
|
25
|
+
provider: "test",
|
|
26
|
+
modelId: "test-image",
|
|
27
|
+
maxImagesPerCall: 1,
|
|
28
|
+
async doGenerate() {
|
|
29
|
+
return {
|
|
30
|
+
images: [new Uint8Array([1, 2, 3])],
|
|
31
|
+
warnings: [],
|
|
32
|
+
response: {
|
|
33
|
+
timestamp: new Date(),
|
|
34
|
+
modelId: "test-image",
|
|
35
|
+
headers: undefined,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createVideoModel(): VideoModelV3 {
|
|
43
|
+
return {
|
|
44
|
+
specificationVersion: "v3",
|
|
45
|
+
provider: "test",
|
|
46
|
+
modelId: "test-video",
|
|
47
|
+
maxVideosPerCall: 1,
|
|
48
|
+
async doGenerate() {
|
|
49
|
+
return {
|
|
50
|
+
videos: [new Uint8Array([9, 9, 9])],
|
|
51
|
+
warnings: [],
|
|
52
|
+
response: {
|
|
53
|
+
timestamp: new Date(),
|
|
54
|
+
modelId: "test-video",
|
|
55
|
+
headers: undefined,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type GenerateImageOptions = Parameters<RenderContext["generateImage"]>[0];
|
|
63
|
+
type GenerateVideoOptions = Parameters<RenderContext["generateVideo"]>[0];
|
|
64
|
+
|
|
65
|
+
function createContext(
|
|
66
|
+
cacheDir: string,
|
|
67
|
+
counters: { imageCalls: number; videoCalls: number },
|
|
68
|
+
): RenderContext {
|
|
69
|
+
const storage = fileCache({ dir: cacheDir });
|
|
70
|
+
|
|
71
|
+
const generateImage = withCache(async (_opts: GenerateImageOptions) => {
|
|
72
|
+
counters.imageCalls += 1;
|
|
73
|
+
return {
|
|
74
|
+
images: [
|
|
75
|
+
{
|
|
76
|
+
uint8Array: new Uint8Array([1, 2, 3]),
|
|
77
|
+
mimeType: "image/png",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
warnings: [],
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const generateVideo = withCache(async (_opts: GenerateVideoOptions) => {
|
|
85
|
+
counters.videoCalls += 1;
|
|
86
|
+
const first = new Uint8Array([9, 9, 9]);
|
|
87
|
+
return {
|
|
88
|
+
video: { uint8Array: first, mimeType: "video/mp4" },
|
|
89
|
+
videos: [{ uint8Array: first, mimeType: "video/mp4" }],
|
|
90
|
+
warnings: [],
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
width: 1080,
|
|
96
|
+
height: 1920,
|
|
97
|
+
fps: 30,
|
|
98
|
+
cache: storage,
|
|
99
|
+
generateImage: generateImage as unknown as RenderContext["generateImage"],
|
|
100
|
+
generateVideo: generateVideo as unknown as RenderContext["generateVideo"],
|
|
101
|
+
tempFiles: [],
|
|
102
|
+
pending: new Map(),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("render cache behavior", () => {
|
|
107
|
+
test("reuses cached video across separate contexts when only trim/layout differ", async () => {
|
|
108
|
+
const cacheDir = makeTempDir();
|
|
109
|
+
const counters = { imageCalls: 0, videoCalls: 0 };
|
|
110
|
+
|
|
111
|
+
const model = createVideoModel();
|
|
112
|
+
const imageModel = createImageModel();
|
|
113
|
+
|
|
114
|
+
const base = Video({
|
|
115
|
+
prompt: "walk forward",
|
|
116
|
+
model,
|
|
117
|
+
aspectRatio: "9:16",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const variant = Video({
|
|
121
|
+
prompt: "walk forward",
|
|
122
|
+
model,
|
|
123
|
+
aspectRatio: "9:16",
|
|
124
|
+
cutFrom: 0.5,
|
|
125
|
+
cutTo: 2.5,
|
|
126
|
+
left: "10%",
|
|
127
|
+
width: "80%",
|
|
128
|
+
keepAudio: true,
|
|
129
|
+
volume: 0.5,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const ctx1 = createContext(cacheDir, counters);
|
|
134
|
+
await renderVideo(base, ctx1);
|
|
135
|
+
|
|
136
|
+
const ctx2 = createContext(cacheDir, counters);
|
|
137
|
+
await renderVideo(variant, ctx2);
|
|
138
|
+
} finally {
|
|
139
|
+
cleanupTempDir(cacheDir);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
expect(counters.videoCalls).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("reuses cached image across separate contexts when only layout differs", async () => {
|
|
146
|
+
const cacheDir = makeTempDir();
|
|
147
|
+
const counters = { imageCalls: 0, videoCalls: 0 };
|
|
148
|
+
|
|
149
|
+
const videoModel = createVideoModel();
|
|
150
|
+
const imageModel = createImageModel();
|
|
151
|
+
|
|
152
|
+
const base = Image({
|
|
153
|
+
prompt: "sunset over mountains",
|
|
154
|
+
model: imageModel,
|
|
155
|
+
aspectRatio: "16:9",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const variant = Image({
|
|
159
|
+
prompt: "sunset over mountains",
|
|
160
|
+
model: imageModel,
|
|
161
|
+
aspectRatio: "16:9",
|
|
162
|
+
left: "5%",
|
|
163
|
+
top: "5%",
|
|
164
|
+
width: "90%",
|
|
165
|
+
height: "90%",
|
|
166
|
+
resize: "cover",
|
|
167
|
+
zoom: "in",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const ctx1 = createContext(cacheDir, counters);
|
|
172
|
+
await renderImage(base, ctx1);
|
|
173
|
+
|
|
174
|
+
const ctx2 = createContext(cacheDir, counters);
|
|
175
|
+
await renderImage(variant, ctx2);
|
|
176
|
+
} finally {
|
|
177
|
+
cleanupTempDir(cacheDir);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
expect(counters.imageCalls).toBe(1);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -69,14 +69,14 @@ interface SubtitleStyle {
|
|
|
69
69
|
const STYLE_PRESETS: Record<string, SubtitleStyle> = {
|
|
70
70
|
tiktok: {
|
|
71
71
|
fontName: "Montserrat",
|
|
72
|
-
fontSize:
|
|
72
|
+
fontSize: 72,
|
|
73
73
|
primaryColor: "&HFFFFFF",
|
|
74
74
|
outlineColor: "&H000000",
|
|
75
|
-
backColor: "&
|
|
75
|
+
backColor: "&H00000000",
|
|
76
76
|
bold: true,
|
|
77
|
-
outline:
|
|
77
|
+
outline: 4,
|
|
78
78
|
shadow: 0,
|
|
79
|
-
marginV:
|
|
79
|
+
marginV: 480,
|
|
80
80
|
alignment: 2,
|
|
81
81
|
},
|
|
82
82
|
karaoke: {
|
|
@@ -164,10 +164,17 @@ function formatAssTime(seconds: number): string {
|
|
|
164
164
|
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(cs).padStart(2, "0")}`;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
function convertSrtToAss(
|
|
167
|
+
function convertSrtToAss(
|
|
168
|
+
srtContent: string,
|
|
169
|
+
style: SubtitleStyle,
|
|
170
|
+
width: number,
|
|
171
|
+
height: number,
|
|
172
|
+
): string {
|
|
168
173
|
const assHeader = `[Script Info]
|
|
169
174
|
Title: Generated Subtitles
|
|
170
175
|
ScriptType: v4.00+
|
|
176
|
+
PlayResX: ${width}
|
|
177
|
+
PlayResY: ${height}
|
|
171
178
|
WrapStyle: 0
|
|
172
179
|
ScaledBorderAndShadow: yes
|
|
173
180
|
YCbCr Matrix: TV.601
|
|
@@ -193,6 +200,12 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|
|
193
200
|
return assHeader + assDialogues;
|
|
194
201
|
}
|
|
195
202
|
|
|
203
|
+
const POSITION_ALIGNMENT: Record<string, number> = {
|
|
204
|
+
top: 8,
|
|
205
|
+
center: 5,
|
|
206
|
+
bottom: 2,
|
|
207
|
+
};
|
|
208
|
+
|
|
196
209
|
function colorToAss(color: string): string {
|
|
197
210
|
if (color.startsWith("&H")) return color;
|
|
198
211
|
|
|
@@ -280,15 +293,21 @@ export async function renderCaptions(
|
|
|
280
293
|
const styleName = props.style ?? "tiktok";
|
|
281
294
|
const baseStyle = STYLE_PRESETS[styleName] ?? STYLE_PRESETS.tiktok!;
|
|
282
295
|
|
|
296
|
+
const alignment = props.position
|
|
297
|
+
? (POSITION_ALIGNMENT[props.position] ?? baseStyle.alignment)
|
|
298
|
+
: baseStyle.alignment;
|
|
299
|
+
|
|
283
300
|
const style: SubtitleStyle = {
|
|
284
301
|
...baseStyle,
|
|
285
302
|
fontSize: props.fontSize ?? baseStyle.fontSize,
|
|
286
303
|
primaryColor: props.color
|
|
287
304
|
? colorToAss(props.color)
|
|
288
305
|
: baseStyle.primaryColor,
|
|
306
|
+
alignment,
|
|
307
|
+
marginV: props.position === "center" ? 0 : baseStyle.marginV,
|
|
289
308
|
};
|
|
290
309
|
|
|
291
|
-
const assContent = convertSrtToAss(srtContent, style);
|
|
310
|
+
const assContent = convertSrtToAss(srtContent, style, ctx.width, ctx.height);
|
|
292
311
|
const assPath = `/tmp/varg-captions-${Date.now()}.ass`;
|
|
293
312
|
writeFileSync(assPath, assContent);
|
|
294
313
|
ctx.tempFiles.push(assPath);
|
|
@@ -8,17 +8,17 @@ import type {
|
|
|
8
8
|
VideoLayer,
|
|
9
9
|
} from "../../ai-sdk/providers/editly/types";
|
|
10
10
|
import type {
|
|
11
|
-
AnimateProps,
|
|
12
11
|
ClipProps,
|
|
13
12
|
ImageProps,
|
|
13
|
+
MusicProps,
|
|
14
14
|
SpeechProps,
|
|
15
15
|
VargElement,
|
|
16
16
|
VargNode,
|
|
17
17
|
VideoProps,
|
|
18
18
|
} from "../types";
|
|
19
|
-
import { renderAnimate } from "./animate";
|
|
20
19
|
import type { RenderContext } from "./context";
|
|
21
20
|
import { renderImage } from "./image";
|
|
21
|
+
import { renderMusic } from "./music";
|
|
22
22
|
import { renderPackshot } from "./packshot";
|
|
23
23
|
import { renderSlider } from "./slider";
|
|
24
24
|
import { renderSpeech } from "./speech";
|
|
@@ -26,6 +26,7 @@ import { renderSplit } from "./split";
|
|
|
26
26
|
import { renderSubtitle } from "./subtitle";
|
|
27
27
|
import { renderSwipe } from "./swipe";
|
|
28
28
|
import { renderTitle } from "./title";
|
|
29
|
+
import { resolvePath } from "./utils";
|
|
29
30
|
import { renderVideo } from "./video";
|
|
30
31
|
|
|
31
32
|
type PendingLayer =
|
|
@@ -92,7 +93,7 @@ async function renderClipLayers(
|
|
|
92
93
|
type: "video",
|
|
93
94
|
path,
|
|
94
95
|
resizeMode: props.resize,
|
|
95
|
-
|
|
96
|
+
cropPosition: props.cropPosition,
|
|
96
97
|
cutFrom: props.cutFrom ?? clipOptions?.cutFrom,
|
|
97
98
|
cutTo: props.cutTo ?? clipOptions?.cutTo,
|
|
98
99
|
mixVolume: props.keepAudio ? (props.volume ?? 1) : 0,
|
|
@@ -106,25 +107,6 @@ async function renderClipLayers(
|
|
|
106
107
|
break;
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
case "animate": {
|
|
110
|
-
const props = element.props as AnimateProps;
|
|
111
|
-
pending.push({
|
|
112
|
-
type: "async",
|
|
113
|
-
promise: renderAnimate(element as VargElement<"animate">, ctx).then(
|
|
114
|
-
(path) =>
|
|
115
|
-
({
|
|
116
|
-
type: "video",
|
|
117
|
-
path,
|
|
118
|
-
left: props.left,
|
|
119
|
-
top: props.top,
|
|
120
|
-
width: props.width,
|
|
121
|
-
height: props.height,
|
|
122
|
-
}) as VideoLayer,
|
|
123
|
-
),
|
|
124
|
-
});
|
|
125
|
-
break;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
110
|
case "title": {
|
|
129
111
|
pending.push({
|
|
130
112
|
type: "sync",
|
|
@@ -157,6 +139,35 @@ async function renderClipLayers(
|
|
|
157
139
|
break;
|
|
158
140
|
}
|
|
159
141
|
|
|
142
|
+
case "music": {
|
|
143
|
+
const props = element.props as MusicProps;
|
|
144
|
+
pending.push({
|
|
145
|
+
type: "async",
|
|
146
|
+
promise: (async () => {
|
|
147
|
+
let path: string;
|
|
148
|
+
if (props.src) {
|
|
149
|
+
path = resolvePath(props.src);
|
|
150
|
+
} else if (props.prompt) {
|
|
151
|
+
const result = await renderMusic(
|
|
152
|
+
element as VargElement<"music">,
|
|
153
|
+
ctx,
|
|
154
|
+
);
|
|
155
|
+
path = result.path;
|
|
156
|
+
} else {
|
|
157
|
+
throw new Error("Music requires either src or prompt");
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
type: "audio",
|
|
161
|
+
path,
|
|
162
|
+
mixVolume: props.volume ?? 1,
|
|
163
|
+
cutFrom: props.cutFrom,
|
|
164
|
+
cutTo: props.cutTo,
|
|
165
|
+
} as AudioLayer;
|
|
166
|
+
})(),
|
|
167
|
+
});
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
case "split": {
|
|
161
172
|
pending.push({
|
|
162
173
|
type: "async",
|
|
@@ -215,11 +226,31 @@ async function renderClipLayers(
|
|
|
215
226
|
}
|
|
216
227
|
}
|
|
217
228
|
|
|
218
|
-
const
|
|
219
|
-
pending.map((p) =>
|
|
229
|
+
const layerResults = await Promise.allSettled(
|
|
230
|
+
pending.map((p) =>
|
|
231
|
+
p.type === "sync" ? Promise.resolve(p.layer) : p.promise,
|
|
232
|
+
),
|
|
220
233
|
);
|
|
221
234
|
|
|
222
|
-
|
|
235
|
+
const failures = layerResults
|
|
236
|
+
.map((r, i) =>
|
|
237
|
+
r.status === "rejected" ? { index: i, reason: r.reason } : null,
|
|
238
|
+
)
|
|
239
|
+
.filter(Boolean) as { index: number; reason: Error }[];
|
|
240
|
+
|
|
241
|
+
if (failures.length > 0) {
|
|
242
|
+
if (failures.length === 1 && failures[0]) {
|
|
243
|
+
throw failures[0].reason;
|
|
244
|
+
}
|
|
245
|
+
const errors = failures
|
|
246
|
+
.map((f) => f.reason?.message || "Unknown error")
|
|
247
|
+
.join("; ");
|
|
248
|
+
throw new Error(
|
|
249
|
+
`${failures.length} of ${layerResults.length} layers failed: ${errors}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return layerResults.map((r) => (r as PromiseFulfilledResult<Layer>).value);
|
|
223
254
|
}
|
|
224
255
|
|
|
225
256
|
export async function renderClip(
|