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
@@ -4,14 +4,86 @@ import type {
4
4
  ImageOverlayLayer,
5
5
  Layer,
6
6
  Position,
7
+ PositionObject,
8
+ SizeValue,
7
9
  TitleLayer,
8
10
  } from "../../ai-sdk/providers/editly/types";
9
11
  import type { PackshotProps, VargElement } from "../types";
10
12
  import type { RenderContext } from "./context";
11
13
  import { renderImage } from "./image";
14
+ import { createBlinkingButton } from "./packshot/blinking-button";
15
+
16
+ /**
17
+ * Type guard: returns true if `pos` is a PositionObject ({ x, y }).
18
+ */
19
+ function isPositionObject(pos: Position): pos is PositionObject {
20
+ return typeof pos === "object" && pos !== null && "x" in pos && "y" in pos;
21
+ }
22
+
23
+ /**
24
+ * Parse a SizeValue to a normalised 0-1 fraction.
25
+ *
26
+ * - `number` – treated as a raw pixel value; divided by `total`.
27
+ * - `"50%"` – percentage string; divided by 100.
28
+ * - `"120px"` – pixel string; parsed and divided by `total`.
29
+ *
30
+ * Returns `0.5` (centre) when the value cannot be parsed.
31
+ */
32
+ function sizeValueToFraction(value: SizeValue, total: number): number {
33
+ if (typeof value === "number") {
34
+ return total > 0 ? value / total : 0.5;
35
+ }
36
+ if (typeof value === "string") {
37
+ if (value.endsWith("%")) {
38
+ const n = parseFloat(value);
39
+ return Number.isFinite(n) ? n / 100 : 0.5;
40
+ }
41
+ if (value.endsWith("px")) {
42
+ const n = parseFloat(value);
43
+ return Number.isFinite(n) && total > 0 ? n / total : 0.5;
44
+ }
45
+ }
46
+ return 0.5;
47
+ }
48
+
49
+ /**
50
+ * Convert a PositionObject to the nearest string Position.
51
+ *
52
+ * The x axis is split into thirds: left (< 0.33), center, right (> 0.67).
53
+ * The y axis is split into thirds: top (< 0.33), center, bottom (> 0.67).
54
+ *
55
+ * `refWidth` / `refHeight` are only needed when the SizeValue is in pixels;
56
+ * when unknown, pass 1 and only percentage / fraction values will resolve
57
+ * correctly.
58
+ */
59
+ function positionObjectToString(
60
+ obj: PositionObject,
61
+ refWidth = 1,
62
+ refHeight = 1,
63
+ ): Exclude<Position, PositionObject> {
64
+ const fx = sizeValueToFraction(obj.x, refWidth);
65
+ const fy = sizeValueToFraction(obj.y, refHeight);
66
+
67
+ const col: "left" | "center" | "right" =
68
+ fx < 0.33 ? "left" : fx > 0.67 ? "right" : "center";
69
+ const row: "top" | "center" | "bottom" =
70
+ fy < 0.33 ? "top" : fy > 0.67 ? "bottom" : "center";
71
+
72
+ if (row === "center" && col === "center") return "center";
73
+ if (row === "center")
74
+ return `center-${col}` as "center-left" | "center-right";
75
+ if (col === "center") return row; // "top" | "bottom"
76
+ return `${row}-${col}` as
77
+ | "top-left"
78
+ | "top-right"
79
+ | "bottom-left"
80
+ | "bottom-right";
81
+ }
12
82
 
13
83
  function resolvePosition(pos: Position | undefined): Position {
14
- return pos ?? "center";
84
+ if (pos === undefined) return "center";
85
+ if (isPositionObject(pos)) return positionObjectToString(pos);
86
+ return pos;
15
87
  }
16
88
 
17
89
  export async function renderPackshot(
@@ -23,6 +95,7 @@ export async function renderPackshot(
23
95
 
24
96
  const layers: Layer[] = [];
25
97
 
98
+ // ===== BACKGROUND LAYER =====
26
99
  if (props.background) {
27
100
  if (typeof props.background === "string") {
28
101
  layers.push({
@@ -44,17 +117,30 @@ export async function renderPackshot(
44
117
  });
45
118
  }
46
119
 
120
+ // ===== LOGO LAYER =====
47
121
  if (props.logo) {
48
122
  const logoLayer: ImageOverlayLayer = {
49
123
  type: "image-overlay",
50
124
  path: props.logo,
51
- position: resolvePosition(props.logoPosition),
52
- width: props.logoSize ?? "30%",
125
+ position: resolvePosition(props.logoPosition ?? "center"),
126
+ width: props.logoSize ?? "40%",
53
127
  };
54
128
  layers.push(logoLayer);
55
129
  }
56
130
 
57
- if (props.cta) {
131
+ // ===== TITLE LAYER =====
132
+ if (props.title) {
133
+ const titleLayer: TitleLayer = {
134
+ type: "title",
135
+ text: props.title,
136
+ textColor: props.titleColor ?? "#FFFFFF",
137
+ position: resolvePosition(props.titlePosition ?? "center"),
138
+ };
139
+ layers.push(titleLayer);
140
+ }
141
+
142
+ // ===== STATIC CTA (non-blinking) =====
143
+ if (props.cta && !props.blinkCta) {
58
144
  const ctaLayer: TitleLayer = {
59
145
  type: "title",
60
146
  text: props.cta,
@@ -64,21 +150,97 @@ export async function renderPackshot(
64
150
  layers.push(ctaLayer);
65
151
  }
66
152
 
153
+ // Create base packshot video
67
154
  const clip: Clip = {
68
155
  layers,
69
156
  duration,
70
157
  };
71
158
 
72
- const outPath = `/tmp/varg-packshot-${Date.now()}.mp4`;
159
+ const basePath = `/tmp/varg-packshot-${Date.now()}.mp4`;
73
160
 
74
161
  await editly({
75
- outPath,
162
+ outPath: basePath,
76
163
  width: ctx.width,
77
164
  height: ctx.height,
78
165
  fps: ctx.fps,
79
166
  clips: [clip],
80
167
  });
81
168
 
82
- ctx.tempFiles.push(outPath);
83
- return outPath;
169
+ // ===== BLINKING CTA OVERLAY =====
170
+ if (props.cta && props.blinkCta) {
171
+ // Create animated button with Sharp at button-size canvas (fast)
172
+ const btn = await createBlinkingButton({
173
+ text: props.cta,
174
+ width: ctx.width,
175
+ height: ctx.height,
176
+ duration,
177
+ fps: ctx.fps,
178
+ bgColor: props.ctaColor ?? "#FF6B00",
179
+ textColor: props.ctaTextColor ?? "#FFFFFF",
180
+ blinkFrequency: props.blinkFrequency ?? 0.8,
181
+ position: mapCtaPosition(props.ctaPosition, ctx.height),
182
+ buttonWidth: props.ctaSize?.width,
183
+ buttonHeight: props.ctaSize?.height,
184
+ });
185
+
186
+ // Composite button-sized overlay at correct position on base video
187
+ const finalPath = `/tmp/varg-packshot-final-${Date.now()}.mp4`;
188
+ const { $ } = await import("bun");
189
+
190
+ // Overlay the blinking button (with alpha) on the packshot
191
+ await $`ffmpeg -y \
192
+ -i ${basePath} \
193
+ -i ${btn.path} \
194
+ -filter_complex "[0:v][1:v]overlay=${btn.x}:${btn.y}:format=auto" \
195
+ -c:v libx264 -preset fast -crf 18 -pix_fmt yuv420p \
196
+ ${finalPath}`.quiet();
197
+
198
+ ctx.tempFiles.push(basePath, btn.path);
199
+ return finalPath;
200
+ }
201
+
202
+ ctx.tempFiles.push(basePath);
203
+ return basePath;
204
+ }
205
+
206
+ /**
207
+ * Map a Position (string literal **or** PositionObject) to the vertical
208
+ * bucket that the blinking-button renderer understands.
209
+ *
210
+ * When a PositionObject ({ x, y }) is provided the y-coordinate is
211
+ * normalised to a 0-1 fraction and mapped to "top" (< 0.33),
212
+ * "center" (0.33-0.67), or "bottom" (> 0.67). Pixel values are
213
+ * resolved against `refHeight` (defaults to 1, which means only
214
+ * percentages will convert correctly when the caller does not supply it).
215
+ */
216
+ function mapCtaPosition(
217
+ pos: Position | undefined,
218
+ refHeight = 1,
219
+ ): "top" | "center" | "bottom" {
220
+ if (pos === undefined) return "bottom";
221
+
222
+ // Handle PositionObject ({ x, y }) explicitly
223
+ if (isPositionObject(pos)) {
224
+ const fy = sizeValueToFraction(pos.y, refHeight);
225
+ if (fy < 0.33) return "top";
226
+ if (fy > 0.67) return "bottom";
227
+ return "center";
228
+ }
229
+
230
+ // String literal positions
231
+ switch (pos) {
232
+ case "top":
233
+ case "top-left":
234
+ case "top-right":
235
+ return "top";
236
+ case "center":
237
+ case "center-left":
238
+ case "center-right":
239
+ return "center";
240
+ case "bottom":
241
+ case "bottom-left":
242
+ case "bottom-right":
243
+ default:
244
+ return "bottom";
245
+ }
84
246
  }
@@ -1,7 +1,6 @@
1
1
  export type GenerationType =
2
2
  | "image"
3
3
  | "video"
4
- | "animate"
5
4
  | "speech"
6
5
  | "music"
7
6
  | "editly"
@@ -11,7 +10,6 @@ export type GenerationType =
11
10
  export const TIME_ESTIMATES: Record<GenerationType, number> = {
12
11
  image: 30,
13
12
  video: 120,
14
- animate: 90,
15
13
  speech: 5,
16
14
  music: 45,
17
15
  editly: 15,
@@ -30,6 +28,9 @@ export const MODEL_TIME_ESTIMATES: Record<string, number> = {
30
28
  kling: 180,
31
29
  "kling-v2": 180,
32
30
  "kling-v2.5": 180,
31
+ "kling-v2.6": 180,
32
+ "kling-v2.6-motion": 240,
33
+ "kling-v2.6-motion-standard": 180,
33
34
  minimax: 90,
34
35
  luma: 90,
35
36
  runway: 45,
@@ -103,7 +104,7 @@ export function completeTask(tracker: ProgressTracker, id: string): void {
103
104
  }
104
105
 
105
106
  function getEstimate(task: ProgressTask): number {
106
- const modelLower = task.model.toLowerCase();
107
+ const modelLower = task.model?.toLowerCase() ?? "";
107
108
  for (const [key, estimate] of Object.entries(MODEL_TIME_ESTIMATES)) {
108
109
  if (modelLower.includes(key.toLowerCase())) {
109
110
  return estimate;
@@ -1,5 +1,5 @@
1
1
  import { generateImage, wrapImageModel } from "ai";
2
- import { withCache } from "../../ai-sdk/cache";
2
+ import { type CacheStorage, withCache } from "../../ai-sdk/cache";
3
3
  import { fileCache } from "../../ai-sdk/file-cache";
4
4
  import { generateVideo } from "../../ai-sdk/generate-video";
5
5
  import {
@@ -14,6 +14,7 @@ import type {
14
14
  Layer,
15
15
  VideoLayer,
16
16
  } from "../../ai-sdk/providers/editly/types";
17
+
17
18
  import type {
18
19
  CaptionsProps,
19
20
  ClipProps,
@@ -25,7 +26,7 @@ import type {
25
26
  SpeechProps,
26
27
  VargElement,
27
28
  } from "../types";
28
- import { renderAnimate } from "./animate";
29
+ import { burnCaptions } from "./burn-captions";
29
30
  import { renderCaptions } from "./captions";
30
31
  import { renderClip } from "./clip";
31
32
  import type { RenderContext } from "./context";
@@ -47,6 +48,16 @@ interface RenderedOverlay {
47
48
  isVideo: boolean;
48
49
  }
49
50
 
51
+ function resolveCacheStorage(
52
+ cache: string | CacheStorage | undefined,
53
+ ): CacheStorage | undefined {
54
+ if (!cache) return undefined;
55
+ if (typeof cache === "string") {
56
+ return fileCache({ dir: cache });
57
+ }
58
+ return cache;
59
+ }
60
+
50
61
  export async function renderRoot(
51
62
  element: VargElement<"render">,
52
63
  options: RenderOptions,
@@ -54,78 +65,74 @@ export async function renderRoot(
54
65
  const props = element.props as RenderProps;
55
66
  const progress = createProgressTracker(options.quiet ?? false);
56
67
 
57
- const mode: RenderMode = options.mode ?? "default";
68
+ const mode: RenderMode = options.mode ?? "strict";
58
69
  const placeholderCount = { images: 0, videos: 0, total: 0 };
59
70
 
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
71
  const trackPlaceholder = (type: "image" | "video") => {
69
72
  placeholderCount[type === "image" ? "images" : "videos"]++;
70
73
  placeholderCount.total++;
71
74
  };
72
75
 
76
+ const cacheStorage = resolveCacheStorage(options.cache);
77
+
78
+ const cachedGenerateImage = cacheStorage
79
+ ? withCache(generateImage, { storage: cacheStorage })
80
+ : generateImage;
81
+
82
+ const cachedGenerateVideo = cacheStorage
83
+ ? withCache(generateVideo, { storage: cacheStorage })
84
+ : generateVideo;
85
+
73
86
  const wrapGenerateImage: typeof generateImage = async (opts) => {
74
87
  if (
75
88
  typeof opts.model === "string" ||
76
89
  opts.model.specificationVersion !== "v3"
77
90
  ) {
78
- return generateImage(opts);
91
+ return cachedGenerateImage(opts);
79
92
  }
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
+ if (mode === "preview") {
95
+ trackPlaceholder("image");
96
+ const wrappedModel = wrapImageModel({
97
+ model: opts.model,
98
+ middleware: imagePlaceholderFallbackMiddleware({
99
+ mode: "preview",
100
+ onFallback: () => {},
101
+ }),
102
+ });
103
+ return generateImage({ ...opts, model: wrappedModel });
104
+ }
105
+
106
+ return cachedGenerateImage(opts);
93
107
  };
94
108
 
95
109
  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;
110
+ if (mode === "preview") {
111
+ trackPlaceholder("video");
112
+ const wrappedModel = wrapVideoModel({
113
+ model: opts.model,
114
+ middleware: placeholderFallbackMiddleware({
115
+ mode: "preview",
116
+ onFallback: () => {},
117
+ }),
118
+ });
119
+ return generateVideo({ ...opts, model: wrappedModel });
120
+ }
121
+
122
+ return cachedGenerateVideo(opts);
109
123
  };
110
124
 
111
125
  const ctx: RenderContext = {
112
126
  width: props.width ?? 1920,
113
127
  height: props.height ?? 1080,
114
128
  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,
129
+ cache: cacheStorage,
130
+ generateImage: wrapGenerateImage,
131
+ generateVideo: wrapGenerateVideo,
126
132
  tempFiles: [],
127
133
  progress,
128
134
  pending: new Map(),
135
+ defaults: options.defaults,
129
136
  };
130
137
 
131
138
  const clipElements: VargElement<"clip">[] = [];
@@ -177,13 +184,10 @@ export async function renderRoot(
177
184
  const childElement = child as VargElement;
178
185
 
179
186
  let path: string | undefined;
180
- const isVideo =
181
- childElement.type === "video" || childElement.type === "animate";
187
+ const isVideo = childElement.type === "video";
182
188
 
183
189
  if (childElement.type === "video") {
184
190
  path = await renderVideo(childElement as VargElement<"video">, ctx);
185
- } else if (childElement.type === "animate") {
186
- path = await renderAnimate(childElement as VargElement<"animate">, ctx);
187
191
  } else if (childElement.type === "image") {
188
192
  path = await renderImage(childElement as VargElement<"image">, ctx);
189
193
  }
@@ -201,10 +205,42 @@ export async function renderRoot(
201
205
  }
202
206
  }
203
207
 
204
- const renderedClips = await Promise.all(
208
+ const clipResults = await Promise.allSettled(
205
209
  clipElements.map((clipElement) => renderClip(clipElement, ctx)),
206
210
  );
207
211
 
212
+ const failures = clipResults
213
+ .map((r, i) =>
214
+ r.status === "rejected" ? { index: i, reason: r.reason } : null,
215
+ )
216
+ .filter(Boolean) as { index: number; reason: Error }[];
217
+
218
+ if (failures.length > 0) {
219
+ const successCount = clipResults.length - failures.length;
220
+ if (successCount > 0) {
221
+ console.log(
222
+ `\x1b[33mℹ ${successCount} clip(s) cached, ${failures.length} failed\x1b[0m`,
223
+ );
224
+ }
225
+ const errorCounts = new Map<string, number>();
226
+ for (const f of failures) {
227
+ const msg = f.reason?.message || "Unknown error";
228
+ errorCounts.set(msg, (errorCounts.get(msg) || 0) + 1);
229
+ }
230
+ const errors = [...errorCounts.entries()]
231
+ .map(([msg, count]) => (count > 1 ? `${msg} (x${count})` : msg))
232
+ .join("; ");
233
+ throw new Error(
234
+ `${failures.length} of ${clipResults.length} clips failed: ${errors}`,
235
+ );
236
+ }
237
+
238
+ const renderedClips = clipResults.map(
239
+ (r) =>
240
+ (r as PromiseFulfilledResult<Awaited<ReturnType<typeof renderClip>>>)
241
+ .value,
242
+ );
243
+
208
244
  const clips: Clip[] = [];
209
245
  let currentTime = 0;
210
246
 
@@ -255,11 +291,11 @@ export async function renderRoot(
255
291
  let path: string;
256
292
  if (musicProps.src) {
257
293
  path = resolvePath(musicProps.src);
258
- } else if (musicProps.prompt && musicProps.model) {
294
+ } else if (musicProps.prompt) {
259
295
  const result = await renderMusic(musicElement, ctx);
260
296
  path = result.path;
261
297
  } else {
262
- throw new Error("Music requires either src or prompt+model");
298
+ throw new Error("Music requires either src or prompt");
263
299
  }
264
300
 
265
301
  audioTracks.push({
@@ -267,6 +303,7 @@ export async function renderRoot(
267
303
  mixVolume: musicProps.volume ?? 1,
268
304
  cutFrom,
269
305
  cutTo,
306
+ start: musicProps.start,
270
307
  });
271
308
  }
272
309
 
@@ -280,40 +317,59 @@ export async function renderRoot(
280
317
  const editlyTaskId = addTask(progress, "editly", "ffmpeg");
281
318
  startTask(progress, editlyTaskId);
282
319
 
283
- await editly({
320
+ const editlyResult = await editly({
284
321
  outPath: tempOutPath,
285
322
  width: ctx.width,
286
323
  height: ctx.height,
287
324
  fps: ctx.fps,
288
325
  clips,
289
326
  audioTracks: audioTracks.length > 0 ? audioTracks : undefined,
327
+ shortest: props.shortest,
328
+ verbose: options.verbose,
329
+ backend: options.backend,
290
330
  });
291
331
 
292
332
  completeTask(progress, editlyTaskId);
293
333
 
334
+ let output = editlyResult.output;
335
+
294
336
  if (hasCaptions && captionsResult) {
295
337
  const captionsTaskId = addTask(progress, "captions", "ffmpeg");
296
338
  startTask(progress, captionsTaskId);
297
339
 
298
- const { $ } = await import("bun");
299
- await $`ffmpeg -y -i ${tempOutPath} -vf "ass=${captionsResult.assPath}" -c:a copy ${finalOutPath}`.quiet();
340
+ output = await burnCaptions({
341
+ video: output,
342
+ assPath: captionsResult.assPath,
343
+ outputPath: finalOutPath,
344
+ backend: options.backend,
345
+ verbose: options.verbose,
346
+ });
347
+
348
+ if (!options.backend) {
349
+ ctx.tempFiles.push(tempOutPath);
350
+ }
300
351
 
301
- ctx.tempFiles.push(tempOutPath);
302
352
  completeTask(progress, captionsTaskId);
303
353
  }
304
354
 
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
- );
355
+ let finalBuffer: ArrayBuffer;
356
+ if (output.type === "url") {
357
+ const res = await fetch(output.url);
358
+ if (!res.ok)
359
+ throw new Error(`Failed to download final render: ${res.status}`);
360
+ finalBuffer = await res.arrayBuffer();
361
+ if (options.output) {
362
+ await Bun.write(options.output, finalBuffer);
314
363
  }
364
+ } else {
365
+ finalBuffer = await Bun.file(output.path).arrayBuffer();
366
+ }
367
+
368
+ if (!options.quiet && mode === "preview" && placeholderCount.total > 0) {
369
+ console.log(
370
+ `\x1b[36mℹ preview mode: ${placeholderCount.total} placeholders used (${placeholderCount.images} images, ${placeholderCount.videos} videos)\x1b[0m`,
371
+ );
315
372
  }
316
373
 
317
- const result = await Bun.file(finalOutPath).arrayBuffer();
318
- return new Uint8Array(result);
374
+ return new Uint8Array(finalBuffer);
319
375
  }
@@ -21,9 +21,9 @@ export async function renderSpeech(
21
21
  throw new Error("Speech element requires text content");
22
22
  }
23
23
 
24
- const model = props.model;
24
+ const model = props.model ?? ctx.defaults?.speech;
25
25
  if (!model) {
26
- throw new Error("Speech element requires 'model' prop");
26
+ throw new Error("Speech requires 'model' prop (or set defaults.speech)");
27
27
  }
28
28
 
29
29
  const cacheKey = computeCacheKey(element);