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
@@ -1,17 +1,20 @@
1
1
  import type { generateImage } from "ai";
2
- import type { fileCache } from "../../ai-sdk/file-cache";
2
+ import type { CacheStorage } from "../../ai-sdk/cache";
3
3
  import type { generateVideo } from "../../ai-sdk/generate-video";
4
+ import type { DefaultModels } from "../types";
4
5
  import type { ProgressTracker } from "./progress";
5
6
 
6
7
  export interface RenderContext {
7
8
  width: number;
8
9
  height: number;
9
10
  fps: number;
10
- cache?: ReturnType<typeof fileCache>;
11
+ cache?: CacheStorage;
11
12
  generateImage: typeof generateImage;
12
13
  generateVideo: typeof generateVideo;
13
14
  tempFiles: string[];
14
15
  progress?: ProgressTracker;
15
16
  /** In-memory deduplication for concurrent renders of the same element */
16
17
  pending: Map<string, Promise<string>>;
18
+ /** Default models for elements that don't specify one */
19
+ defaults?: DefaultModels;
17
20
  }
@@ -54,9 +54,11 @@ export async function renderImage(
54
54
  throw new Error("Image element requires either 'prompt' or 'src'");
55
55
  }
56
56
 
57
- const model = props.model;
57
+ const model = props.model ?? ctx.defaults?.image;
58
58
  if (!model) {
59
- throw new Error("Image element requires 'model' prop when using prompt");
59
+ throw new Error(
60
+ "Image element requires 'model' prop (or set defaults.image in render options)",
61
+ );
60
62
  }
61
63
 
62
64
  // Compute cache key for deduplication
@@ -83,6 +85,7 @@ export async function renderImage(
83
85
  model,
84
86
  prompt: resolvedPrompt,
85
87
  aspectRatio: props.aspectRatio,
88
+ providerOptions: props.providerOptions,
86
89
  n: 1,
87
90
  cacheKey,
88
91
  } as Parameters<typeof generateImage>[0]);
@@ -1,4 +1,3 @@
1
- export { renderAnimate } from "./animate";
2
1
  export { renderCaptions } from "./captions";
3
2
  export { renderClip } from "./clip";
4
3
  export type { RenderContext } from "./context";
@@ -10,9 +10,9 @@ export async function renderMusic(
10
10
  const props = element.props as MusicProps;
11
11
 
12
12
  const prompt = props.prompt;
13
- const model = props.model;
13
+ const model = props.model ?? ctx.defaults?.music;
14
14
  if (!prompt || !model) {
15
- throw new Error("Music generation requires both prompt and model");
15
+ throw new Error("Music requires prompt and model (or set defaults.music)");
16
16
  }
17
17
 
18
18
  const cacheKey = JSON.stringify({
@@ -22,7 +22,7 @@ export async function renderMusic(
22
22
  duration: props.duration,
23
23
  });
24
24
 
25
- const modelId = model.modelId;
25
+ const modelId = model.modelId ?? "music";
26
26
  const taskId = ctx.progress ? addTask(ctx.progress, "music", modelId) : null;
27
27
 
28
28
  const generateFn = async () => {
@@ -40,6 +40,11 @@ export async function renderMusic(
40
40
  const cached = await ctx.cache.get(cacheKey);
41
41
  if (cached) {
42
42
  audioData = cached as Uint8Array;
43
+ // Signal cache hit to progress tracker
44
+ if (taskId && ctx.progress) {
45
+ startTask(ctx.progress, taskId);
46
+ completeTask(ctx.progress, taskId);
47
+ }
43
48
  } else {
44
49
  if (taskId && ctx.progress) startTask(ctx.progress, taskId);
45
50
  audioData = await generateFn();
@@ -0,0 +1,413 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import sharp from "sharp";
5
+
6
+ export interface BlinkingButtonOptions {
7
+ text: string;
8
+ width: number; // Video frame width
9
+ height: number; // Video frame height
10
+ duration: number;
11
+ fps: number;
12
+ bgColor: string; // Hex color like "#FF6B00"
13
+ textColor: string; // Hex color like "#FFFFFF"
14
+ blinkFrequency?: number; // Seconds per cycle (default: 0.8)
15
+ position?: "top" | "center" | "bottom"; // Vertical position
16
+ buttonWidth?: number; // Button width in pixels
17
+ buttonHeight?: number; // Button height in pixels
18
+ }
19
+
20
+ /**
21
+ * Parse hex color to RGB values
22
+ */
23
+ function hexToRgb(hex: string): { r: number; g: number; b: number } {
24
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
25
+ if (!result) {
26
+ return { r: 255, g: 107, b: 0 }; // Default orange
27
+ }
28
+ return {
29
+ r: parseInt(result[1] as string, 16),
30
+ g: parseInt(result[2] as string, 16),
31
+ b: parseInt(result[3] as string, 16),
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Clamp value to max (for color brightening)
37
+ */
38
+ function clamp(value: number, max = 255): number {
39
+ return Math.min(Math.floor(value), max);
40
+ }
41
+
42
+ /**
43
+ * Create SVG for button background with gradient and rounded corners
44
+ */
45
+ function createButtonSvg(
46
+ width: number,
47
+ height: number,
48
+ radius: number,
49
+ topColor: { r: number; g: number; b: number },
50
+ bottomColor: { r: number; g: number; b: number },
51
+ ): string {
52
+ return `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
53
+ <defs>
54
+ <linearGradient id="btnGrad" x1="0%" y1="0%" x2="0%" y2="100%">
55
+ <stop offset="0%" style="stop-color:rgb(${topColor.r},${topColor.g},${topColor.b})" />
56
+ <stop offset="100%" style="stop-color:rgb(${bottomColor.r},${bottomColor.g},${bottomColor.b})" />
57
+ </linearGradient>
58
+ </defs>
59
+ <rect width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="url(#btnGrad)" />
60
+ </svg>`;
61
+ }
62
+
63
+ /**
64
+ * Create a blinking CTA button video using Sharp for image generation
65
+ * and ffmpeg for video assembly.
66
+ *
67
+ * Matches Python SDK quality:
68
+ * - Gradient background (lighter top -> darker bottom)
69
+ * - Rounded corners (45% of height)
70
+ * - Scale animation (1.0 -> 1.03)
71
+ * - Brightness animation (0.85 -> 1.2)
72
+ * - Custom font support (TikTokSans-Bold)
73
+ */
74
+ export interface BlinkingButtonResult {
75
+ path: string;
76
+ x: number;
77
+ y: number;
78
+ }
79
+
80
+ export async function createBlinkingButton(
81
+ options: BlinkingButtonOptions,
82
+ ): Promise<BlinkingButtonResult> {
83
+ const {
84
+ text,
85
+ width,
86
+ height,
87
+ duration,
88
+ fps,
89
+ bgColor,
90
+ textColor,
91
+ blinkFrequency = 0.8,
92
+ position = "bottom",
93
+ } = options;
94
+
95
+ const totalFrames = Math.ceil(duration * fps);
96
+
97
+ // Button dimensions — large and prominent like app store CTAs
98
+ const btnWidth = options.buttonWidth ?? Math.floor(width * 0.7);
99
+ const btnHeight = options.buttonHeight ?? Math.floor(height * 0.09);
100
+ const cornerRadius = Math.floor(btnHeight * 0.45);
101
+
102
+ // Animation padding (button can grow ~14% with overshoot + glow radius)
103
+ const maxScale = 1.14; // accounts for 1.12 * 1.15 overshoot peak
104
+ const glowRadius = 18;
105
+ const glowExtraScale = 1.15; // glow is 15% larger than button
106
+ const totalMaxScale = maxScale * glowExtraScale; // ~1.31 for glow bounds
107
+ const scalePadding = Math.ceil(
108
+ Math.max(btnWidth, btnHeight) * (totalMaxScale - 1.0) * 2,
109
+ );
110
+ const padding = scalePadding + glowRadius * 2;
111
+ const canvasWidth = btnWidth + padding * 2;
112
+ const canvasHeight = btnHeight + padding * 2;
113
+
114
+ // Parse colors and create gradient (lighter top, darker bottom)
115
+ const rgb = hexToRgb(bgColor);
116
+ const topColor = {
117
+ r: clamp(rgb.r * 1.15),
118
+ g: clamp(rgb.g * 1.15),
119
+ b: clamp(rgb.b * 1.15),
120
+ };
121
+ const bottomColor = {
122
+ r: Math.floor(rgb.r * 0.95),
123
+ g: Math.floor(rgb.g * 0.95),
124
+ b: Math.floor(rgb.b * 0.95),
125
+ };
126
+
127
+ // Font path (relative to this file's compiled location)
128
+ const fontPath = path.resolve(
129
+ import.meta.dirname,
130
+ "../../../assets/fonts/TikTokSans-Bold.ttf",
131
+ );
132
+
133
+ // Create button SVG with gradient
134
+ const buttonSvg = createButtonSvg(
135
+ btnWidth,
136
+ btnHeight,
137
+ cornerRadius,
138
+ topColor,
139
+ bottomColor,
140
+ );
141
+
142
+ // Create text image using Sharp's text feature
143
+ const fontSize = Math.floor(btnHeight * 0.55);
144
+ const textBuffer = await sharp({
145
+ text: {
146
+ text: `<span foreground="${textColor}" font_weight="bold">${escapeXml(text)}</span>`,
147
+ font: "TikTokSans",
148
+ fontfile: fontPath,
149
+ rgba: true,
150
+ align: "center",
151
+ dpi: Math.floor(fontSize * 2.8), // Larger DPI for bolder text
152
+ },
153
+ })
154
+ .png()
155
+ .toBuffer();
156
+
157
+ // Get text dimensions for centering
158
+ const textMeta = await sharp(textBuffer).metadata();
159
+ const textWidth = textMeta.width ?? 0;
160
+ const textHeight = textMeta.height ?? 0;
161
+
162
+ // Create base button frame (button + text on transparent canvas)
163
+ const baseButtonBuffer = await sharp({
164
+ create: {
165
+ width: canvasWidth,
166
+ height: canvasHeight,
167
+ channels: 4,
168
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
169
+ },
170
+ })
171
+ .composite([
172
+ // Button background (centered in canvas)
173
+ {
174
+ input: Buffer.from(buttonSvg),
175
+ top: padding,
176
+ left: padding,
177
+ },
178
+ // Text centered on button
179
+ {
180
+ input: textBuffer,
181
+ top: padding + Math.floor((btnHeight - textHeight) / 2),
182
+ left: padding + Math.floor((btnWidth - textWidth) / 2),
183
+ },
184
+ ])
185
+ .png()
186
+ .toBuffer();
187
+
188
+ // Pre-render glow buffer: blurred, brightened copy of the button for halo effect
189
+ const glowBuffer = await sharp(baseButtonBuffer)
190
+ .blur(glowRadius)
191
+ .modulate({ brightness: 1.4 })
192
+ .png()
193
+ .toBuffer();
194
+
195
+ // Calculate button position on full frame
196
+ const btnY = getButtonYPosition(position, height, canvasHeight);
197
+ const btnX = Math.floor((width - canvasWidth) / 2);
198
+
199
+ // Create frames directory for intermediate files
200
+ const framesDir = `/tmp/varg-btn-frames-${Date.now()}`;
201
+ await mkdir(framesDir, { recursive: true });
202
+
203
+ // Generate animation frames
204
+ // Using file-based approach for reliability with alpha channel
205
+ for (let i = 0; i < totalFrames; i++) {
206
+ const t = i / fps;
207
+ // Elastic pulse curve: fast expand with overshoot, settle, slow contract
208
+ const phase = (t % blinkFrequency) / blinkFrequency; // 0 -> 1 within each cycle
209
+ let osc: number;
210
+ if (phase < 0.25) {
211
+ // Fast rise with overshoot to 1.15
212
+ osc = Math.sin((phase / 0.25) * Math.PI * 0.5) * 1.15;
213
+ } else if (phase < 0.4) {
214
+ // Settle back from 1.15 to 1.0
215
+ const settle = (phase - 0.25) / 0.15;
216
+ osc = 1.15 - 0.15 * settle;
217
+ } else {
218
+ // Slow ease-out fall back to 0
219
+ const fall = (phase - 0.4) / 0.6;
220
+ osc = Math.cos(fall * Math.PI * 0.5);
221
+ }
222
+
223
+ const scale = 1.0 + 0.12 * osc; // 1.0 -> 1.14 -> 1.12 -> 1.0
224
+ const brightness = 0.85 + 0.35 * Math.max(0, osc); // 0.85 -> 1.2 -> 0.85
225
+ const glowOpacity = Math.max(0, osc) * 0.6; // 0 -> 0.6 -> 0
226
+
227
+ const scaledW = Math.round(canvasWidth * scale);
228
+ const scaledH = Math.round(canvasHeight * scale);
229
+
230
+ // Calculate offset to keep button centered after scaling
231
+ const offsetX = Math.floor((canvasWidth - scaledW) / 2);
232
+ const offsetY = Math.floor((canvasHeight - scaledH) / 2);
233
+
234
+ // Scale button, apply brightness, then fit to canvas
235
+ let btnPipeline = sharp(baseButtonBuffer)
236
+ .resize(scaledW, scaledH, { kernel: "lanczos3" })
237
+ .modulate({ brightness });
238
+
239
+ if (scaledW > canvasWidth || scaledH > canvasHeight) {
240
+ // Button exceeds canvas during overshoot — crop from center
241
+ const cropLeft = Math.floor((scaledW - canvasWidth) / 2);
242
+ const cropTop = Math.floor((scaledH - canvasHeight) / 2);
243
+ btnPipeline = btnPipeline.extract({
244
+ left: Math.max(0, cropLeft),
245
+ top: Math.max(0, cropTop),
246
+ width: Math.min(scaledW, canvasWidth),
247
+ height: Math.min(scaledH, canvasHeight),
248
+ });
249
+ } else {
250
+ btnPipeline = btnPipeline.extend({
251
+ top: Math.max(0, offsetY),
252
+ bottom: Math.max(0, canvasHeight - scaledH - offsetY),
253
+ left: Math.max(0, offsetX),
254
+ right: Math.max(0, canvasWidth - scaledW - offsetX),
255
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
256
+ });
257
+ }
258
+
259
+ const btnFrame = await btnPipeline.png().toBuffer();
260
+
261
+ // Scale glow slightly larger than button for halo effect
262
+ const glowScale = scale * 1.15;
263
+ const glowW = Math.round(canvasWidth * glowScale);
264
+ const glowH = Math.round(canvasHeight * glowScale);
265
+ const glowOffX = Math.floor((canvasWidth - glowW) / 2);
266
+ const glowOffY = Math.floor((canvasHeight - glowH) / 2);
267
+
268
+ // Render glow frame with animated opacity
269
+ // Scale alpha channel using raw pixel manipulation for precise opacity control
270
+ let glowResized: sharp.Sharp;
271
+ if (glowW > canvasWidth || glowH > canvasHeight) {
272
+ // Glow is larger than canvas — resize then crop to canvas from center
273
+ const cropLeft = Math.floor((glowW - canvasWidth) / 2);
274
+ const cropTop = Math.floor((glowH - canvasHeight) / 2);
275
+ glowResized = sharp(glowBuffer)
276
+ .resize(glowW, glowH, { kernel: "lanczos3" })
277
+ .extract({
278
+ left: Math.max(0, cropLeft),
279
+ top: Math.max(0, cropTop),
280
+ width: canvasWidth,
281
+ height: canvasHeight,
282
+ });
283
+ } else {
284
+ // Glow fits — extend with transparent padding
285
+ glowResized = sharp(glowBuffer)
286
+ .resize(glowW, glowH, { kernel: "lanczos3" })
287
+ .extend({
288
+ top: Math.max(0, glowOffY),
289
+ bottom: Math.max(0, canvasHeight - glowH - glowOffY),
290
+ left: Math.max(0, glowOffX),
291
+ right: Math.max(0, canvasWidth - glowW - glowOffX),
292
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
293
+ });
294
+ }
295
+
296
+ const { data: glowPixels, info: glowInfo } = await glowResized
297
+ .raw()
298
+ .toBuffer({ resolveWithObject: true });
299
+
300
+ // Multiply alpha channel by glowOpacity
301
+ for (let p = 3; p < glowPixels.length; p += 4) {
302
+ glowPixels[p] = Math.round((glowPixels[p] as number) * glowOpacity);
303
+ }
304
+
305
+ const glowFrame = await sharp(glowPixels, {
306
+ raw: {
307
+ width: glowInfo.width,
308
+ height: glowInfo.height,
309
+ channels: 4,
310
+ },
311
+ })
312
+ .png()
313
+ .toBuffer();
314
+
315
+ // Composite: transparent canvas <- glow (behind) <- button (on top)
316
+ await sharp({
317
+ create: {
318
+ width: canvasWidth,
319
+ height: canvasHeight,
320
+ channels: 4,
321
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
322
+ },
323
+ })
324
+ .composite([
325
+ { input: glowFrame, top: 0, left: 0 },
326
+ { input: btnFrame, top: 0, left: 0 },
327
+ ])
328
+ .png()
329
+ .toFile(`${framesDir}/frame_${String(i).padStart(5, "0")}.png`);
330
+ }
331
+
332
+ // Combine frames into video with alpha channel (ProRes 4444)
333
+ const outputPath = `/tmp/varg-blink-btn-${Date.now()}.mov`;
334
+
335
+ await runFfmpeg([
336
+ "-y",
337
+ "-framerate",
338
+ String(fps),
339
+ "-i",
340
+ `${framesDir}/frame_%05d.png`,
341
+ "-c:v",
342
+ "prores_ks",
343
+ "-profile:v",
344
+ "4444",
345
+ "-pix_fmt",
346
+ "yuva444p10le",
347
+ "-t",
348
+ String(duration),
349
+ outputPath,
350
+ ]);
351
+
352
+ // Cleanup frames directory
353
+ await rm(framesDir, { recursive: true, force: true });
354
+
355
+ return { path: outputPath, x: btnX, y: btnY };
356
+ }
357
+
358
+ /**
359
+ * Calculate button Y position based on position prop
360
+ */
361
+ function getButtonYPosition(
362
+ position: "top" | "center" | "bottom",
363
+ videoHeight: number,
364
+ buttonHeight: number,
365
+ ): number {
366
+ switch (position) {
367
+ case "top":
368
+ return Math.floor(videoHeight * 0.15);
369
+ case "center":
370
+ return Math.floor((videoHeight - buttonHeight) / 2);
371
+ case "bottom":
372
+ default:
373
+ return Math.floor(videoHeight * 0.78 - buttonHeight / 2);
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Escape XML special characters for SVG/Pango text
379
+ */
380
+ function escapeXml(text: string): string {
381
+ return text
382
+ .replace(/&/g, "&amp;")
383
+ .replace(/</g, "&lt;")
384
+ .replace(/>/g, "&gt;")
385
+ .replace(/"/g, "&quot;")
386
+ .replace(/'/g, "&apos;");
387
+ }
388
+
389
+ /**
390
+ * Run ffmpeg command and wait for completion
391
+ */
392
+ function runFfmpeg(args: string[]): Promise<void> {
393
+ return new Promise((resolve, reject) => {
394
+ const ffmpeg = spawn("ffmpeg", args, {
395
+ stdio: ["pipe", "pipe", "pipe"],
396
+ });
397
+
398
+ let stderr = "";
399
+ ffmpeg.stderr?.on("data", (data) => {
400
+ stderr += data.toString();
401
+ });
402
+
403
+ ffmpeg.on("close", (code) => {
404
+ if (code === 0) {
405
+ resolve();
406
+ } else {
407
+ reject(new Error(`ffmpeg exited with code ${code}: ${stderr}`));
408
+ }
409
+ });
410
+
411
+ ffmpeg.on("error", reject);
412
+ });
413
+ }