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.
Files changed (114) hide show
  1. package/.env.example +6 -0
  2. package/README.md +483 -61
  3. package/assets/fonts/TikTokSans-Bold.ttf +0 -0
  4. package/examples/grok-imagine-test.tsx +155 -0
  5. package/launch-videos/06-kawaii-fruits.tsx +93 -0
  6. package/launch-videos/07-ugc-weight-loss.tsx +132 -0
  7. package/launch-videos/08-talking-head-varg.tsx +107 -0
  8. package/launch-videos/09-girl.tsx +160 -0
  9. package/launch-videos/README.md +42 -0
  10. package/package.json +10 -4
  11. package/pipeline/cookbooks/round-video-character.md +1 -1
  12. package/skills/varg-video-generation/SKILL.md +224 -0
  13. package/skills/varg-video-generation/references/templates.md +380 -0
  14. package/skills/varg-video-generation/scripts/setup.ts +265 -0
  15. package/src/ai-sdk/cache.ts +1 -3
  16. package/src/ai-sdk/examples/google-image.ts +62 -0
  17. package/src/ai-sdk/index.ts +10 -0
  18. package/src/ai-sdk/middleware/wrap-image-model.ts +4 -21
  19. package/src/ai-sdk/middleware/wrap-music-model.ts +4 -16
  20. package/src/ai-sdk/middleware/wrap-video-model.ts +5 -17
  21. package/src/ai-sdk/providers/CONTRIBUTING.md +457 -0
  22. package/src/ai-sdk/providers/editly/backends/index.ts +8 -0
  23. package/src/ai-sdk/providers/editly/backends/local.ts +94 -0
  24. package/src/ai-sdk/providers/editly/backends/types.ts +74 -0
  25. package/src/ai-sdk/providers/editly/editly.test.ts +49 -1
  26. package/src/ai-sdk/providers/editly/index.ts +164 -80
  27. package/src/ai-sdk/providers/editly/layers.ts +58 -6
  28. package/src/ai-sdk/providers/editly/rendi/editly-with-rendi-backend.test.ts +335 -0
  29. package/src/ai-sdk/providers/editly/rendi/index.ts +289 -0
  30. package/src/ai-sdk/providers/editly/rendi/rendi.test.ts +35 -0
  31. package/src/ai-sdk/providers/editly/types.ts +30 -0
  32. package/src/ai-sdk/providers/elevenlabs.ts +10 -2
  33. package/src/ai-sdk/providers/fal.test.ts +214 -0
  34. package/src/ai-sdk/providers/fal.ts +435 -40
  35. package/src/ai-sdk/providers/google.ts +423 -0
  36. package/src/ai-sdk/providers/together.ts +191 -0
  37. package/src/cli/commands/find.tsx +1 -0
  38. package/src/cli/commands/frame.tsx +616 -0
  39. package/src/cli/commands/hello.ts +85 -0
  40. package/src/cli/commands/help.tsx +18 -30
  41. package/src/cli/commands/index.ts +11 -2
  42. package/src/cli/commands/init.tsx +570 -0
  43. package/src/cli/commands/list.tsx +1 -0
  44. package/src/cli/commands/render.tsx +322 -76
  45. package/src/cli/commands/run.tsx +1 -0
  46. package/src/cli/commands/storyboard.tsx +1714 -0
  47. package/src/cli/commands/which.tsx +1 -0
  48. package/src/cli/index.ts +23 -4
  49. package/src/cli/ui/components/Badge.tsx +1 -0
  50. package/src/cli/ui/components/DataTable.tsx +1 -0
  51. package/src/cli/ui/components/Header.tsx +1 -0
  52. package/src/cli/ui/components/HelpBlock.tsx +1 -0
  53. package/src/cli/ui/components/KeyValue.tsx +1 -0
  54. package/src/cli/ui/components/OptionRow.tsx +1 -0
  55. package/src/cli/ui/components/Separator.tsx +1 -0
  56. package/src/cli/ui/components/StatusBox.tsx +1 -0
  57. package/src/cli/ui/components/VargBox.tsx +1 -0
  58. package/src/cli/ui/components/VargProgress.tsx +1 -0
  59. package/src/cli/ui/components/VargSpinner.tsx +1 -0
  60. package/src/cli/ui/components/VargText.tsx +1 -0
  61. package/src/definitions/actions/grok-edit.ts +133 -0
  62. package/src/definitions/actions/index.ts +16 -0
  63. package/src/definitions/actions/qwen-angles.ts +218 -0
  64. package/src/index.ts +1 -0
  65. package/src/providers/fal.ts +196 -0
  66. package/src/react/assets.ts +9 -0
  67. package/src/react/elements.ts +0 -5
  68. package/src/react/examples/branching.tsx +6 -4
  69. package/src/react/examples/character-video.tsx +13 -10
  70. package/src/react/examples/local-files-test.tsx +19 -0
  71. package/src/react/examples/ltx2-test.tsx +25 -0
  72. package/src/react/examples/madi.tsx +13 -10
  73. package/src/react/examples/mcmeows.tsx +40 -0
  74. package/src/react/examples/music-defaults.tsx +24 -0
  75. package/src/react/examples/quickstart-test.tsx +101 -0
  76. package/src/react/examples/qwen-angles-test.tsx +72 -0
  77. package/src/react/index.ts +3 -3
  78. package/src/react/layouts/grid.tsx +1 -1
  79. package/src/react/layouts/index.ts +2 -1
  80. package/src/react/layouts/slot.tsx +85 -0
  81. package/src/react/layouts/split.tsx +18 -0
  82. package/src/react/react.test.ts +60 -11
  83. package/src/react/renderers/burn-captions.ts +95 -0
  84. package/src/react/renderers/cache.test.ts +182 -0
  85. package/src/react/renderers/captions.ts +25 -6
  86. package/src/react/renderers/clip.ts +56 -25
  87. package/src/react/renderers/context.ts +5 -2
  88. package/src/react/renderers/image.ts +5 -2
  89. package/src/react/renderers/index.ts +0 -1
  90. package/src/react/renderers/music.ts +8 -3
  91. package/src/react/renderers/packshot/blinking-button.ts +413 -0
  92. package/src/react/renderers/packshot.ts +170 -8
  93. package/src/react/renderers/progress.ts +4 -3
  94. package/src/react/renderers/render.ts +127 -71
  95. package/src/react/renderers/speech.ts +2 -2
  96. package/src/react/renderers/split.ts +34 -13
  97. package/src/react/renderers/utils.test.ts +80 -0
  98. package/src/react/renderers/utils.ts +37 -1
  99. package/src/react/renderers/video.ts +47 -9
  100. package/src/react/types.ts +70 -17
  101. package/src/studio/stages.ts +40 -39
  102. package/src/studio/step-renderer.ts +14 -24
  103. package/src/studio/ui/index.html +2 -2
  104. package/src/tests/all.test.ts +4 -4
  105. package/src/tests/index.ts +1 -1
  106. package/test-slot-grid.tsx +19 -0
  107. package/test-slot-userland.tsx +30 -0
  108. package/test-sync-v2.ts +30 -0
  109. package/test-sync-v2.tsx +29 -0
  110. package/tsconfig.json +1 -1
  111. package/video.tsx +7 -0
  112. package/src/ai-sdk/providers/editly/ffmpeg.ts +0 -60
  113. package/src/react/renderers/animate.ts +0 -59
  114. /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
+ };
@@ -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("Animate creates correct element with nested image", () => {
82
+ test("Video creates correct element with nested image", () => {
84
83
  const image = Image({ prompt: "luigi in wheelchair" });
85
- const element = Animate({
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("animate");
93
- expect(element.props.image).toBe(image);
94
- expect(element.props.motion).toBe("wheels spinning fast");
95
- expect(element.props.duration).toBe(5);
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: 32,
72
+ fontSize: 72,
73
73
  primaryColor: "&HFFFFFF",
74
74
  outlineColor: "&H000000",
75
- backColor: "&H80000000",
75
+ backColor: "&H00000000",
76
76
  bold: true,
77
- outline: 3,
77
+ outline: 4,
78
78
  shadow: 0,
79
- marginV: 50,
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(srtContent: string, style: SubtitleStyle): string {
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
- // Video-level cutFrom/cutTo take precedence over clip-level
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 layers = await Promise.all(
219
- pending.map((p) => (p.type === "sync" ? p.layer : p.promise)),
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
- return layers;
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(