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
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import type { generateImage } from "ai";
|
|
2
|
-
import type {
|
|
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?:
|
|
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(
|
|
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]);
|
|
@@ -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
|
|
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, "&")
|
|
383
|
+
.replace(/</g, "<")
|
|
384
|
+
.replace(/>/g, ">")
|
|
385
|
+
.replace(/"/g, """)
|
|
386
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|