vargai 0.3.0

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 (154) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.env.example +27 -0
  3. package/.github/workflows/ci.yml +23 -0
  4. package/.husky/README.md +102 -0
  5. package/.husky/commit-msg +6 -0
  6. package/.husky/pre-commit +9 -0
  7. package/.husky/pre-push +6 -0
  8. package/.size-limit.json +8 -0
  9. package/.test-hooks.ts +5 -0
  10. package/CLAUDE.md +125 -0
  11. package/CONTRIBUTING.md +150 -0
  12. package/LICENSE.md +53 -0
  13. package/README.md +78 -0
  14. package/SKILLS.md +173 -0
  15. package/STRUCTURE.md +92 -0
  16. package/biome.json +34 -0
  17. package/bun.lock +1254 -0
  18. package/commitlint.config.js +22 -0
  19. package/docs/plan.md +66 -0
  20. package/docs/todo.md +14 -0
  21. package/docs/varg-sdk.md +812 -0
  22. package/ffmpeg/CLAUDE.md +68 -0
  23. package/package.json +69 -0
  24. package/pipeline/cookbooks/SKILL.md +285 -0
  25. package/pipeline/cookbooks/remotion-video.md +585 -0
  26. package/pipeline/cookbooks/round-video-character.md +337 -0
  27. package/pipeline/cookbooks/scripts/animate-frames-parallel.ts +84 -0
  28. package/pipeline/cookbooks/scripts/combine-scenes.sh +53 -0
  29. package/pipeline/cookbooks/scripts/generate-frames-parallel.ts +99 -0
  30. package/pipeline/cookbooks/scripts/still-to-video.sh +37 -0
  31. package/pipeline/cookbooks/talking-character.md +59 -0
  32. package/pipeline/cookbooks/text-to-tiktok.md +669 -0
  33. package/pipeline/cookbooks/trendwatching.md +156 -0
  34. package/plan.md +281 -0
  35. package/scripts/.gitkeep +0 -0
  36. package/src/ai-sdk/cache.ts +142 -0
  37. package/src/ai-sdk/examples/cached-generation.ts +53 -0
  38. package/src/ai-sdk/examples/duet-scene-4.ts +53 -0
  39. package/src/ai-sdk/examples/duet-scene-5-audio.ts +32 -0
  40. package/src/ai-sdk/examples/duet-video.ts +56 -0
  41. package/src/ai-sdk/examples/editly-composition.ts +63 -0
  42. package/src/ai-sdk/examples/editly-test.ts +57 -0
  43. package/src/ai-sdk/examples/editly-video-test.ts +52 -0
  44. package/src/ai-sdk/examples/fal-lipsync.ts +43 -0
  45. package/src/ai-sdk/examples/higgsfield-image.ts +61 -0
  46. package/src/ai-sdk/examples/music-generation.ts +19 -0
  47. package/src/ai-sdk/examples/openai-sora.ts +34 -0
  48. package/src/ai-sdk/examples/replicate-bg-removal.ts +52 -0
  49. package/src/ai-sdk/examples/simpsons-scene.ts +61 -0
  50. package/src/ai-sdk/examples/talking-lion.ts +55 -0
  51. package/src/ai-sdk/examples/video-generation.ts +39 -0
  52. package/src/ai-sdk/examples/workflow-animated-girl.ts +104 -0
  53. package/src/ai-sdk/examples/workflow-before-after.ts +114 -0
  54. package/src/ai-sdk/examples/workflow-character-grid.ts +112 -0
  55. package/src/ai-sdk/examples/workflow-slideshow.ts +161 -0
  56. package/src/ai-sdk/file-cache.ts +112 -0
  57. package/src/ai-sdk/file.ts +238 -0
  58. package/src/ai-sdk/generate-element.ts +92 -0
  59. package/src/ai-sdk/generate-music.ts +46 -0
  60. package/src/ai-sdk/generate-video.ts +165 -0
  61. package/src/ai-sdk/index.ts +72 -0
  62. package/src/ai-sdk/music-model.ts +110 -0
  63. package/src/ai-sdk/providers/editly/editly.test.ts +1108 -0
  64. package/src/ai-sdk/providers/editly/ffmpeg.ts +60 -0
  65. package/src/ai-sdk/providers/editly/index.ts +817 -0
  66. package/src/ai-sdk/providers/editly/layers.ts +772 -0
  67. package/src/ai-sdk/providers/editly/plan.md +144 -0
  68. package/src/ai-sdk/providers/editly/types.ts +328 -0
  69. package/src/ai-sdk/providers/elevenlabs-provider.ts +255 -0
  70. package/src/ai-sdk/providers/fal-provider.ts +512 -0
  71. package/src/ai-sdk/providers/higgsfield.ts +379 -0
  72. package/src/ai-sdk/providers/openai.ts +251 -0
  73. package/src/ai-sdk/providers/replicate.ts +16 -0
  74. package/src/ai-sdk/video-model.ts +185 -0
  75. package/src/cli/commands/find.tsx +137 -0
  76. package/src/cli/commands/help.tsx +85 -0
  77. package/src/cli/commands/index.ts +9 -0
  78. package/src/cli/commands/list.tsx +238 -0
  79. package/src/cli/commands/run.tsx +511 -0
  80. package/src/cli/commands/which.tsx +253 -0
  81. package/src/cli/index.ts +112 -0
  82. package/src/cli/quiet.ts +44 -0
  83. package/src/cli/types.ts +32 -0
  84. package/src/cli/ui/components/Badge.tsx +29 -0
  85. package/src/cli/ui/components/DataTable.tsx +51 -0
  86. package/src/cli/ui/components/Header.tsx +23 -0
  87. package/src/cli/ui/components/HelpBlock.tsx +44 -0
  88. package/src/cli/ui/components/KeyValue.tsx +33 -0
  89. package/src/cli/ui/components/OptionRow.tsx +81 -0
  90. package/src/cli/ui/components/Separator.tsx +23 -0
  91. package/src/cli/ui/components/StatusBox.tsx +108 -0
  92. package/src/cli/ui/components/VargBox.tsx +51 -0
  93. package/src/cli/ui/components/VargProgress.tsx +36 -0
  94. package/src/cli/ui/components/VargSpinner.tsx +34 -0
  95. package/src/cli/ui/components/VargText.tsx +56 -0
  96. package/src/cli/ui/components/index.ts +19 -0
  97. package/src/cli/ui/index.ts +12 -0
  98. package/src/cli/ui/render.ts +35 -0
  99. package/src/cli/ui/theme.ts +63 -0
  100. package/src/cli/utils.ts +78 -0
  101. package/src/core/executor/executor.ts +201 -0
  102. package/src/core/executor/index.ts +13 -0
  103. package/src/core/executor/job.ts +214 -0
  104. package/src/core/executor/pipeline.ts +222 -0
  105. package/src/core/index.ts +11 -0
  106. package/src/core/registry/index.ts +9 -0
  107. package/src/core/registry/loader.ts +149 -0
  108. package/src/core/registry/registry.ts +221 -0
  109. package/src/core/registry/resolver.ts +206 -0
  110. package/src/core/schema/helpers.ts +134 -0
  111. package/src/core/schema/index.ts +8 -0
  112. package/src/core/schema/shared.ts +102 -0
  113. package/src/core/schema/types.ts +279 -0
  114. package/src/core/schema/validator.ts +92 -0
  115. package/src/definitions/actions/captions.ts +261 -0
  116. package/src/definitions/actions/edit.ts +298 -0
  117. package/src/definitions/actions/image.ts +125 -0
  118. package/src/definitions/actions/index.ts +114 -0
  119. package/src/definitions/actions/music.ts +205 -0
  120. package/src/definitions/actions/sync.ts +128 -0
  121. package/src/definitions/actions/transcribe.ts +200 -0
  122. package/src/definitions/actions/upload.ts +111 -0
  123. package/src/definitions/actions/video.ts +163 -0
  124. package/src/definitions/actions/voice.ts +119 -0
  125. package/src/definitions/index.ts +23 -0
  126. package/src/definitions/models/elevenlabs.ts +50 -0
  127. package/src/definitions/models/flux.ts +56 -0
  128. package/src/definitions/models/index.ts +36 -0
  129. package/src/definitions/models/kling.ts +56 -0
  130. package/src/definitions/models/llama.ts +54 -0
  131. package/src/definitions/models/nano-banana-pro.ts +102 -0
  132. package/src/definitions/models/sonauto.ts +68 -0
  133. package/src/definitions/models/soul.ts +65 -0
  134. package/src/definitions/models/wan.ts +54 -0
  135. package/src/definitions/models/whisper.ts +44 -0
  136. package/src/definitions/skills/index.ts +12 -0
  137. package/src/definitions/skills/talking-character.ts +87 -0
  138. package/src/definitions/skills/text-to-tiktok.ts +97 -0
  139. package/src/index.ts +118 -0
  140. package/src/providers/apify.ts +269 -0
  141. package/src/providers/base.ts +264 -0
  142. package/src/providers/elevenlabs.ts +217 -0
  143. package/src/providers/fal.ts +392 -0
  144. package/src/providers/ffmpeg.ts +544 -0
  145. package/src/providers/fireworks.ts +193 -0
  146. package/src/providers/groq.ts +149 -0
  147. package/src/providers/higgsfield.ts +145 -0
  148. package/src/providers/index.ts +143 -0
  149. package/src/providers/replicate.ts +147 -0
  150. package/src/providers/storage.ts +206 -0
  151. package/src/tests/all.test.ts +509 -0
  152. package/src/tests/index.ts +33 -0
  153. package/src/tests/unit.test.ts +403 -0
  154. package/tsconfig.json +45 -0
@@ -0,0 +1,772 @@
1
+ import type {
2
+ FillColorLayer,
3
+ ImageLayer,
4
+ ImageOverlayLayer,
5
+ Layer,
6
+ LinearGradientLayer,
7
+ NewsTitleLayer,
8
+ Position,
9
+ RadialGradientLayer,
10
+ RainbowColorsLayer,
11
+ SlideInTextLayer,
12
+ SubtitleLayer,
13
+ TitleBackgroundLayer,
14
+ TitleLayer,
15
+ VideoLayer,
16
+ } from "./types";
17
+
18
+ function escapeDrawText(text: string): string {
19
+ return text
20
+ .replace(/\\/g, "\\\\")
21
+ .replace(/'/g, "'\\''")
22
+ .replace(/:/g, "\\:")
23
+ .replace(/\[/g, "\\[")
24
+ .replace(/\]/g, "\\]");
25
+ }
26
+
27
+ function parseSize(val: number | string | undefined, base: number): number {
28
+ if (val === undefined) return base;
29
+ if (typeof val === "number") return Math.round(val);
30
+ if (val.endsWith("%")) {
31
+ return Math.round((parseFloat(val) / 100) * base);
32
+ }
33
+ if (val.endsWith("px")) {
34
+ return Math.round(parseFloat(val));
35
+ }
36
+ return Math.round(parseFloat(val));
37
+ }
38
+
39
+ export interface FilterInput {
40
+ label: string;
41
+ path?: string;
42
+ duration?: number;
43
+ }
44
+
45
+ export interface LayerFilter {
46
+ inputs: FilterInput[];
47
+ filterComplex: string;
48
+ outputLabel: string;
49
+ }
50
+
51
+ export function getVideoFilter(
52
+ layer: VideoLayer,
53
+ index: number,
54
+ width: number,
55
+ height: number,
56
+ clipDuration: number,
57
+ isOverlay = false,
58
+ ): LayerFilter {
59
+ const inputLabel = `${index}:v`;
60
+ const outputLabel = `vout${index}`;
61
+ const filters: string[] = [];
62
+
63
+ const start = layer.cutFrom ?? 0;
64
+ const end = layer.cutTo ?? start + clipDuration;
65
+ filters.push(`trim=start=${start}:end=${end}`);
66
+ filters.push("setpts=PTS-STARTPTS");
67
+
68
+ const layerWidth = parseSize(layer.width, width);
69
+ const layerHeight = parseSize(layer.height, height);
70
+
71
+ if (isOverlay) {
72
+ filters.push(
73
+ `scale=${layerWidth}:${layerHeight}:force_original_aspect_ratio=decrease`,
74
+ );
75
+ filters.push("setsar=1");
76
+ filters.push("fps=30");
77
+ filters.push("settb=1/30");
78
+ return {
79
+ inputs: [
80
+ {
81
+ label: inputLabel,
82
+ path: layer.path,
83
+ duration: layer.cutTo
84
+ ? layer.cutTo - (layer.cutFrom ?? 0)
85
+ : undefined,
86
+ },
87
+ ],
88
+ filterComplex: `[${inputLabel}]${filters.join(",")}[${outputLabel}]`,
89
+ outputLabel,
90
+ };
91
+ }
92
+
93
+ if (layer.resizeMode === "contain-blur") {
94
+ const baseFilters = filters.join(",");
95
+ const blurLabel = `vblur${index}`;
96
+ const fgLabel = `vfg${index}`;
97
+ const filterComplex = [
98
+ `[${inputLabel}]${baseFilters},split[${blurLabel}][${fgLabel}]`,
99
+ `[${blurLabel}]scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},boxblur=20:5,setsar=1[${blurLabel}bg]`,
100
+ `[${fgLabel}]scale=${width}:${height}:force_original_aspect_ratio=decrease,setsar=1[${fgLabel}fg]`,
101
+ `[${blurLabel}bg][${fgLabel}fg]overlay=(W-w)/2:(H-h)/2,fps=30,settb=1/30[${outputLabel}]`,
102
+ ].join(";");
103
+ return {
104
+ inputs: [
105
+ {
106
+ label: inputLabel,
107
+ path: layer.path,
108
+ duration: layer.cutTo
109
+ ? layer.cutTo - (layer.cutFrom ?? 0)
110
+ : undefined,
111
+ },
112
+ ],
113
+ filterComplex,
114
+ outputLabel,
115
+ };
116
+ }
117
+
118
+ let scaleFilter = `scale=${width}:${height}:force_original_aspect_ratio=decrease`;
119
+ if (layer.resizeMode === "cover") {
120
+ scaleFilter = `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}`;
121
+ } else if (layer.resizeMode === "stretch") {
122
+ scaleFilter = `scale=${width}:${height}`;
123
+ }
124
+
125
+ filters.push(scaleFilter);
126
+ filters.push(`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`);
127
+ filters.push("setsar=1");
128
+ filters.push("fps=30");
129
+ filters.push("settb=1/30");
130
+
131
+ return {
132
+ inputs: [
133
+ {
134
+ label: inputLabel,
135
+ path: layer.path,
136
+ duration: layer.cutTo ? layer.cutTo - (layer.cutFrom ?? 0) : undefined,
137
+ },
138
+ ],
139
+ filterComplex: `[${inputLabel}]${filters.join(",")}[${outputLabel}]`,
140
+ outputLabel,
141
+ };
142
+ }
143
+
144
+ export function getVideoFilterWithTrim(
145
+ layer: VideoLayer,
146
+ inputIndex: number,
147
+ width: number,
148
+ height: number,
149
+ trimStart: number,
150
+ trimEnd: number,
151
+ outputLabel: string,
152
+ isOverlay = false,
153
+ ): LayerFilter {
154
+ const inputLabel = `${inputIndex}:v`;
155
+ const filters: string[] = [];
156
+
157
+ filters.push(`trim=start=${trimStart}:end=${trimEnd}`);
158
+ filters.push("setpts=PTS-STARTPTS");
159
+
160
+ const layerWidth = parseSize(layer.width, width);
161
+ const layerHeight = parseSize(layer.height, height);
162
+
163
+ if (isOverlay) {
164
+ filters.push(
165
+ `scale=${layerWidth}:${layerHeight}:force_original_aspect_ratio=decrease`,
166
+ );
167
+ filters.push("setsar=1");
168
+ filters.push("fps=30");
169
+ filters.push("settb=1/30");
170
+ } else {
171
+ let scaleFilter = `scale=${width}:${height}:force_original_aspect_ratio=decrease`;
172
+ if (layer.resizeMode === "cover") {
173
+ scaleFilter = `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}`;
174
+ } else if (layer.resizeMode === "stretch") {
175
+ scaleFilter = `scale=${width}:${height}`;
176
+ }
177
+
178
+ filters.push(scaleFilter);
179
+ filters.push(`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2`);
180
+ filters.push("setsar=1");
181
+ filters.push("fps=30");
182
+ filters.push("settb=1/30");
183
+ }
184
+
185
+ return {
186
+ inputs: [],
187
+ filterComplex: `[${inputLabel}]${filters.join(",")}[${outputLabel}]`,
188
+ outputLabel,
189
+ };
190
+ }
191
+
192
+ export function getOverlayFilter(
193
+ baseLabel: string,
194
+ overlayLabel: string,
195
+ layer: VideoLayer,
196
+ width: number,
197
+ height: number,
198
+ outputLabel: string,
199
+ ): string {
200
+ const baseX = layer.left !== undefined ? parseSize(layer.left, width) : 0;
201
+ const baseY = layer.top !== undefined ? parseSize(layer.top, height) : 0;
202
+
203
+ let xExpr = String(baseX);
204
+ let yExpr = String(baseY);
205
+
206
+ if (layer.originX === "center") {
207
+ xExpr = `${baseX}-overlay_w/2`;
208
+ } else if (layer.originX === "right") {
209
+ xExpr = `${baseX}-overlay_w`;
210
+ }
211
+
212
+ if (layer.originY === "center") {
213
+ yExpr = `${baseY}-overlay_h/2`;
214
+ } else if (layer.originY === "bottom") {
215
+ yExpr = `${baseY}-overlay_h`;
216
+ }
217
+
218
+ return `[${baseLabel}][${overlayLabel}]overlay=${xExpr}:${yExpr}:shortest=1[${outputLabel}]`;
219
+ }
220
+
221
+ export function getImageFilter(
222
+ layer: ImageLayer,
223
+ index: number,
224
+ width: number,
225
+ height: number,
226
+ duration: number,
227
+ ): LayerFilter {
228
+ const inputLabel = `${index}:v`;
229
+ const outputLabel = `imgout${index}`;
230
+ const filters: string[] = [];
231
+
232
+ const zoomDir =
233
+ layer.zoomDirection === null ? null : (layer.zoomDirection ?? "in");
234
+ const zoomAmt = layer.zoomAmount ?? 0.1;
235
+ const totalFrames = Math.ceil(duration * 30);
236
+
237
+ if (zoomDir !== null) {
238
+ let zoomExpr: string;
239
+ let xExpr: string;
240
+ let yExpr: string;
241
+
242
+ if (zoomDir === "left" || zoomDir === "right") {
243
+ // Pan horizontally while zoomed in slightly - creates cinematic pan effect
244
+ // Zoom is constant, x position animates from one side to other
245
+ const zoom = 1 + zoomAmt;
246
+ zoomExpr = String(zoom);
247
+ yExpr = "trunc((ih-ih/zoom)/2)";
248
+ if (zoomDir === "left") {
249
+ // Start right, pan left (x decreases)
250
+ xExpr = `trunc((iw-iw/zoom)*(1-on/${totalFrames}))`;
251
+ } else {
252
+ // Start left, pan right (x increases)
253
+ xExpr = `trunc((iw-iw/zoom)*on/${totalFrames})`;
254
+ }
255
+ } else {
256
+ // in/out: zoom animation, centered
257
+ const startZoom = zoomDir === "in" ? 1 : 1 + zoomAmt;
258
+ const endZoom = zoomDir === "in" ? 1 + zoomAmt : 1;
259
+ zoomExpr = `${startZoom}+(${endZoom}-${startZoom})*on/${totalFrames}`;
260
+ xExpr = "trunc((iw-iw/zoom)/2)";
261
+ yExpr = "trunc((ih-ih/zoom)/2)";
262
+ }
263
+
264
+ // DO NOT REMOVE: zoompan needs high resolution to avoid shaking at zoom edges.
265
+ // cover mode: use output aspect ratio (4x) - faster, ~40s for 3 clips at 1080x1920
266
+ // contain mode: use square (maxDim * 4) - slower but handles any input aspect ratio
267
+ // we tried animated crop instead of zoompan but ffmpeg's crop filter doesn't
268
+ // support frame-based expressions properly with looped static images.
269
+ const zoomWidth = width * 4;
270
+ const zoomHeight = height * 4;
271
+ const maxDim = Math.max(width, height);
272
+ const zoomSize = maxDim * 4;
273
+
274
+ if (layer.resizeMode === "cover") {
275
+ filters.push(
276
+ `scale=${zoomWidth}:${zoomHeight}:force_original_aspect_ratio=increase`,
277
+ );
278
+ filters.push(`crop=${zoomWidth}:${zoomHeight}`);
279
+ filters.push(
280
+ `zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${totalFrames}:s=${zoomWidth}x${zoomHeight}:fps=30`,
281
+ );
282
+ filters.push(
283
+ `scale=${width}:${height}:force_original_aspect_ratio=increase`,
284
+ );
285
+ filters.push(`crop=${width}:${height}`);
286
+ } else if (layer.resizeMode === "stretch") {
287
+ filters.push(`scale=${zoomWidth}:${zoomHeight}`);
288
+ filters.push(
289
+ `zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${totalFrames}:s=${width}x${height}:fps=30`,
290
+ );
291
+ } else if (layer.resizeMode === "contain") {
292
+ filters.push(
293
+ `scale=${zoomSize}:${zoomSize}:force_original_aspect_ratio=increase`,
294
+ );
295
+ filters.push(
296
+ `zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${totalFrames}:s=${zoomSize}x${zoomSize}:fps=30`,
297
+ );
298
+ filters.push(
299
+ `scale=${width}:${height}:force_original_aspect_ratio=decrease`,
300
+ );
301
+ filters.push(`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`);
302
+ } else {
303
+ // Default: fast path - zoompan at target resolution directly
304
+ filters.push(
305
+ `scale=${zoomWidth}:${zoomHeight}:force_original_aspect_ratio=increase`,
306
+ );
307
+ filters.push(
308
+ `zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${totalFrames}:s=${width}x${height}:fps=30`,
309
+ );
310
+ }
311
+ } else {
312
+ filters.push(`loop=loop=-1:size=1:start=0`);
313
+ filters.push(`fps=30`);
314
+ filters.push(`trim=duration=${duration}`);
315
+
316
+ if (layer.resizeMode === "contain-blur") {
317
+ const blurLabel = `imgblur${index}`;
318
+ const fgLabel = `imgfg${index}`;
319
+ const baseFilters = filters.join(",");
320
+ const filterComplex = [
321
+ `[${inputLabel}]${baseFilters},split[${blurLabel}][${fgLabel}]`,
322
+ `[${blurLabel}]scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height},boxblur=20:5,setsar=1[${blurLabel}bg]`,
323
+ `[${fgLabel}]scale=${width}:${height}:force_original_aspect_ratio=decrease,setsar=1[${fgLabel}fg]`,
324
+ `[${blurLabel}bg][${fgLabel}fg]overlay=(W-w)/2:(H-h)/2,settb=1/30[${outputLabel}]`,
325
+ ].join(";");
326
+ return {
327
+ inputs: [{ label: inputLabel, path: layer.path }],
328
+ filterComplex,
329
+ outputLabel,
330
+ };
331
+ }
332
+
333
+ let scaleFilter = `scale=${width}:${height}:force_original_aspect_ratio=decrease`;
334
+ if (layer.resizeMode === "cover") {
335
+ scaleFilter = `scale=${width}:${height}:force_original_aspect_ratio=increase,crop=${width}:${height}`;
336
+ } else if (layer.resizeMode === "stretch") {
337
+ scaleFilter = `scale=${width}:${height}`;
338
+ }
339
+ filters.push(scaleFilter);
340
+ filters.push(`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`);
341
+ }
342
+
343
+ filters.push("setsar=1");
344
+ filters.push("settb=1/30");
345
+
346
+ return {
347
+ inputs: [{ label: inputLabel, path: layer.path }],
348
+ filterComplex: `[${inputLabel}]${filters.join(",")}[${outputLabel}]`,
349
+ outputLabel,
350
+ };
351
+ }
352
+
353
+ export function getFillColorFilter(
354
+ layer: FillColorLayer,
355
+ index: number,
356
+ width: number,
357
+ height: number,
358
+ duration: number,
359
+ ): LayerFilter {
360
+ const color = layer.color ?? "#000000";
361
+ const outputLabel = `color${index}`;
362
+
363
+ return {
364
+ inputs: [],
365
+ filterComplex: `color=c=${color}:s=${width}x${height}:d=${duration}:r=30[${outputLabel}]`,
366
+ outputLabel,
367
+ };
368
+ }
369
+
370
+ function hexToRgb(hex: string): string {
371
+ const h = hex.replace("#", "");
372
+ const r = parseInt(h.substring(0, 2), 16);
373
+ const g = parseInt(h.substring(2, 4), 16);
374
+ const b = parseInt(h.substring(4, 6), 16);
375
+ return `0x${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
376
+ }
377
+
378
+ export function getGradientFilter(
379
+ layer: RadialGradientLayer | LinearGradientLayer,
380
+ index: number,
381
+ width: number,
382
+ height: number,
383
+ duration: number,
384
+ ): LayerFilter {
385
+ const colors = layer.colors ?? ["#ff6b6b", "#4ecdc4"];
386
+ const outputLabel = `grad${index}`;
387
+
388
+ const c0 = hexToRgb(colors[0]);
389
+ const c1 = hexToRgb(colors[1]);
390
+
391
+ if (layer.type === "radial-gradient") {
392
+ return {
393
+ inputs: [],
394
+ filterComplex: `gradients=s=${width}x${height}:c0=${c0}:c1=${c1}:type=radial:d=${duration}:r=30[${outputLabel}]`,
395
+ outputLabel,
396
+ };
397
+ }
398
+
399
+ return {
400
+ inputs: [],
401
+ filterComplex: `gradients=s=${width}x${height}:c0=${c0}:c1=${c1}:d=${duration}:r=30[${outputLabel}]`,
402
+ outputLabel,
403
+ };
404
+ }
405
+
406
+ // IMAGE-OVERLAY IMPLEMENTATION NOTES:
407
+ // Unlike full-screen image layer, image-overlay:
408
+ // 1. Has position/width/height like video overlay
409
+ // 2. Supports Ken Burns (zoom/pan) effects
410
+ // 3. Gets composited on top of base layers (not as base layer)
411
+ // 4. Uses overlay filter for positioning instead of pad filter
412
+
413
+ function resolvePositionForOverlay(
414
+ position: Position | undefined,
415
+ width: number,
416
+ height: number,
417
+ ): { x: string; y: string } {
418
+ if (!position) {
419
+ return { x: "(W-w)/2", y: "(H-h)/2" };
420
+ }
421
+
422
+ if (typeof position === "object") {
423
+ const baseX = parseSize(position.x, width);
424
+ const baseY = parseSize(position.y, height);
425
+
426
+ let xExpr = String(baseX);
427
+ let yExpr = String(baseY);
428
+
429
+ if (position.originX === "center") {
430
+ xExpr = `${baseX}-overlay_w/2`;
431
+ } else if (position.originX === "right") {
432
+ xExpr = `${baseX}-overlay_w`;
433
+ }
434
+
435
+ if (position.originY === "center") {
436
+ yExpr = `${baseY}-overlay_h/2`;
437
+ } else if (position.originY === "bottom") {
438
+ yExpr = `${baseY}-overlay_h`;
439
+ }
440
+
441
+ return { x: xExpr, y: yExpr };
442
+ }
443
+
444
+ const posMap: Record<string, { x: string; y: string }> = {
445
+ "top-left": { x: "W*0.1", y: "H*0.1" },
446
+ top: { x: "(W-w)/2", y: "H*0.1" },
447
+ "top-right": { x: "W*0.9-w", y: "H*0.1" },
448
+ "center-left": { x: "W*0.1", y: "(H-h)/2" },
449
+ center: { x: "(W-w)/2", y: "(H-h)/2" },
450
+ "center-right": { x: "W*0.9-w", y: "(H-h)/2" },
451
+ "bottom-left": { x: "W*0.1", y: "H*0.9-h" },
452
+ bottom: { x: "(W-w)/2", y: "H*0.9-h" },
453
+ "bottom-right": { x: "W*0.9-w", y: "H*0.9-h" },
454
+ };
455
+
456
+ return posMap[position] ?? { x: "(W-w)/2", y: "(H-h)/2" };
457
+ }
458
+
459
+ export function getImageOverlayFilter(
460
+ layer: ImageOverlayLayer,
461
+ index: number,
462
+ width: number,
463
+ height: number,
464
+ duration: number,
465
+ ): LayerFilter {
466
+ const inputLabel = `${index}:v`;
467
+ const outputLabel = `imgovout${index}`;
468
+ const filters: string[] = [];
469
+
470
+ const targetWidth = layer.width
471
+ ? parseSize(layer.width, width)
472
+ : Math.round(width * 0.3);
473
+ const scaleExpr = layer.height
474
+ ? `scale=${targetWidth}:${parseSize(layer.height, height)}`
475
+ : `scale=${targetWidth}:-2`;
476
+
477
+ const zoomDir = layer.zoomDirection ?? null;
478
+ const zoomAmt = layer.zoomAmount ?? 0.1;
479
+ const totalFrames = Math.ceil(duration * 30);
480
+
481
+ if (zoomDir) {
482
+ let zoomExpr: string;
483
+ let xExpr: string;
484
+ let yExpr: string;
485
+
486
+ if (zoomDir === "left" || zoomDir === "right") {
487
+ const zoom = 1 + zoomAmt;
488
+ zoomExpr = String(zoom);
489
+ yExpr = "trunc((ih-ih/zoom)/2)";
490
+ if (zoomDir === "left") {
491
+ xExpr = `trunc((iw-iw/zoom)*(1-on/${totalFrames}))`;
492
+ } else {
493
+ xExpr = `trunc((iw-iw/zoom)*on/${totalFrames})`;
494
+ }
495
+ } else {
496
+ const startZoom = zoomDir === "in" ? 1 : 1 + zoomAmt;
497
+ const endZoom = zoomDir === "in" ? 1 + zoomAmt : 1;
498
+ zoomExpr = `${startZoom}+(${endZoom}-${startZoom})*on/${totalFrames}`;
499
+ xExpr = "trunc((iw-iw/zoom)/2)";
500
+ yExpr = "trunc((ih-ih/zoom)/2)";
501
+ }
502
+
503
+ // Upscale, zoompan at high res, then scale to target preserving aspect ratio
504
+ filters.push("scale=4000:-2");
505
+ filters.push(
506
+ `zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${totalFrames}:s=4000x4000:fps=30`,
507
+ );
508
+ filters.push(scaleExpr);
509
+ } else {
510
+ filters.push(scaleExpr);
511
+ filters.push("loop=loop=-1:size=1:start=0");
512
+ filters.push("fps=30");
513
+ filters.push(`trim=duration=${duration}`);
514
+ }
515
+
516
+ filters.push("setsar=1");
517
+ filters.push("settb=1/30");
518
+
519
+ return {
520
+ inputs: [{ label: inputLabel, path: layer.path }],
521
+ filterComplex: `[${inputLabel}]${filters.join(",")}[${outputLabel}]`,
522
+ outputLabel,
523
+ };
524
+ }
525
+
526
+ export function getImageOverlayPositionFilter(
527
+ baseLabel: string,
528
+ overlayLabel: string,
529
+ layer: ImageOverlayLayer,
530
+ width: number,
531
+ height: number,
532
+ outputLabel: string,
533
+ ): string {
534
+ const { x, y } = resolvePositionForOverlay(layer.position, width, height);
535
+ return `[${baseLabel}][${overlayLabel}]overlay=${x}:${y}:shortest=1[${outputLabel}]`;
536
+ }
537
+
538
+ function getEnableExpr(
539
+ start: number | undefined,
540
+ stop: number | undefined,
541
+ clipDuration: number,
542
+ ): string {
543
+ if (start === undefined && stop === undefined) return "";
544
+ const s = start ?? 0;
545
+ const e = stop ?? clipDuration;
546
+ return `:enable='between(t,${s},${e})'`;
547
+ }
548
+
549
+ export function getTitleFilter(
550
+ layer: TitleLayer,
551
+ baseLabel: string,
552
+ width: number,
553
+ height: number,
554
+ clipDuration?: number,
555
+ ): string {
556
+ const text = escapeDrawText(layer.text);
557
+ const color = layer.textColor ?? "white";
558
+ const fontSize = Math.round(Math.min(width, height) * 0.08);
559
+
560
+ let x = "(w-text_w)/2";
561
+ let y = "(h-text_h)/2";
562
+
563
+ const pos = layer.position ?? "center";
564
+ if (typeof pos === "string") {
565
+ if (pos.includes("left")) x = "w*0.1";
566
+ if (pos.includes("right")) x = "w*0.9-text_w";
567
+ if (pos.includes("top")) y = "h*0.1";
568
+ if (pos.includes("bottom")) y = "h*0.9-text_h";
569
+ }
570
+
571
+ const fontFile = layer.fontPath
572
+ ? `:fontfile='${escapeDrawText(layer.fontPath)}'`
573
+ : "";
574
+ const fontFamily = layer.fontFamily ? `:font='${layer.fontFamily}'` : "";
575
+ const enable = getEnableExpr(layer.start, layer.stop, clipDuration ?? 9999);
576
+
577
+ return `[${baseLabel}]drawtext=text='${text}':fontsize=${fontSize}:fontcolor=${color}:x=${x}:y=${y}${fontFile}${fontFamily}${enable}`;
578
+ }
579
+
580
+ export function getSubtitleFilter(
581
+ layer: SubtitleLayer,
582
+ baseLabel: string,
583
+ width: number,
584
+ height: number,
585
+ clipDuration?: number,
586
+ ): string {
587
+ const text = escapeDrawText(layer.text);
588
+ const textColor = layer.textColor ?? "white";
589
+ const bgColor = layer.backgroundColor ?? "black@0.7";
590
+ const fontSize = Math.round(Math.min(width, height) * 0.05);
591
+ const boxPadding = Math.round(fontSize * 0.4);
592
+
593
+ const fontFile = layer.fontPath
594
+ ? `:fontfile='${escapeDrawText(layer.fontPath)}'`
595
+ : "";
596
+ const fontFamily = layer.fontFamily ? `:font='${layer.fontFamily}'` : "";
597
+ const enable = getEnableExpr(layer.start, layer.stop, clipDuration ?? 9999);
598
+
599
+ return `[${baseLabel}]drawtext=text='${text}':fontsize=${fontSize}:fontcolor=${textColor}:x=(w-text_w)/2:y=h*0.85-text_h/2:box=1:boxcolor=${bgColor}:boxborderw=${boxPadding}${fontFile}${fontFamily}${enable}`;
600
+ }
601
+
602
+ export function getTitleBackgroundFilter(
603
+ layer: TitleBackgroundLayer,
604
+ index: number,
605
+ width: number,
606
+ height: number,
607
+ duration: number,
608
+ ): LayerFilter {
609
+ const bg = layer.background ?? {
610
+ type: "fill-color" as const,
611
+ color: "#000000",
612
+ };
613
+ let bgFilter: LayerFilter;
614
+
615
+ if (bg.type === "radial-gradient" || bg.type === "linear-gradient") {
616
+ bgFilter = getGradientFilter(bg, index, width, height, duration);
617
+ } else {
618
+ bgFilter = getFillColorFilter(
619
+ bg as FillColorLayer,
620
+ index,
621
+ width,
622
+ height,
623
+ duration,
624
+ );
625
+ }
626
+
627
+ const text = escapeDrawText(layer.text);
628
+ const textColor = layer.textColor ?? "white";
629
+ const fontSize = Math.round(Math.min(width, height) * 0.1);
630
+
631
+ const fontFile = layer.fontPath
632
+ ? `:fontfile='${escapeDrawText(layer.fontPath)}'`
633
+ : "";
634
+ const fontFamily = layer.fontFamily ? `:font='${layer.fontFamily}'` : "";
635
+
636
+ const outputLabel = `titlebg${index}`;
637
+ const drawText = `drawtext=text='${text}':fontsize=${fontSize}:fontcolor=${textColor}:x=(w-text_w)/2:y=(h-text_h)/2${fontFile}${fontFamily}`;
638
+
639
+ return {
640
+ inputs: bgFilter.inputs,
641
+ filterComplex: `${bgFilter.filterComplex};[${bgFilter.outputLabel}]${drawText}[${outputLabel}]`,
642
+ outputLabel,
643
+ };
644
+ }
645
+
646
+ export function getRainbowColorsFilter(
647
+ _layer: RainbowColorsLayer,
648
+ index: number,
649
+ width: number,
650
+ height: number,
651
+ duration: number,
652
+ ): LayerFilter {
653
+ const outputLabel = `rainbow${index}`;
654
+ const fps = 30;
655
+
656
+ return {
657
+ inputs: [],
658
+ filterComplex: `color=c=red:s=${width}x${height}:d=${duration}:r=${fps},hue=h=t*60[${outputLabel}]`,
659
+ outputLabel,
660
+ };
661
+ }
662
+
663
+ export function getNewsTitleFilter(
664
+ layer: NewsTitleLayer,
665
+ baseLabel: string,
666
+ width: number,
667
+ height: number,
668
+ clipDuration?: number,
669
+ ): string {
670
+ const text = escapeDrawText(layer.text);
671
+ const textColor = layer.textColor ?? "white";
672
+ const bgColor = layer.backgroundColor ?? "red";
673
+ const fontSize = Math.round(Math.min(width, height) * 0.05);
674
+ const barHeight = Math.round(fontSize * 2.5);
675
+ const padding = Math.round(fontSize * 0.5);
676
+
677
+ const fontFile = layer.fontPath
678
+ ? `:fontfile='${escapeDrawText(layer.fontPath)}'`
679
+ : "";
680
+ const fontFamily = layer.fontFamily ? `:font='${layer.fontFamily}'` : "";
681
+ const enable = getEnableExpr(layer.start, layer.stop, clipDuration ?? 9999);
682
+
683
+ const pos = layer.position ?? "bottom";
684
+ const yBar = pos === "top" ? 0 : height - barHeight;
685
+ const yText = pos === "top" ? padding : height - barHeight + padding;
686
+
687
+ return `[${baseLabel}]drawbox=x=0:y=${yBar}:w=iw:h=${barHeight}:color=${bgColor}:t=fill${enable},drawtext=text='${text}':fontsize=${fontSize}:fontcolor=${textColor}:x=${padding}:y=${yText}${fontFile}${fontFamily}${enable}`;
688
+ }
689
+
690
+ export function getSlideInTextFilter(
691
+ layer: SlideInTextLayer,
692
+ baseLabel: string,
693
+ width: number,
694
+ height: number,
695
+ duration: number,
696
+ ): string {
697
+ const text = escapeDrawText(layer.text);
698
+ const textColor = layer.color ?? layer.textColor ?? "white";
699
+ const fontSize = layer.fontSize ?? Math.round(Math.min(width, height) * 0.08);
700
+
701
+ const fontFile = layer.fontPath
702
+ ? `:fontfile='${escapeDrawText(layer.fontPath)}'`
703
+ : "";
704
+ const fontFamily = layer.fontFamily ? `:font='${layer.fontFamily}'` : "";
705
+ const enable = getEnableExpr(layer.start, layer.stop, duration);
706
+
707
+ const pos = layer.position ?? "center";
708
+ let yExpr = "(h-text_h)/2";
709
+ if (typeof pos === "string") {
710
+ if (pos.includes("top")) yExpr = "h*0.2";
711
+ if (pos.includes("bottom")) yExpr = "h*0.8-text_h";
712
+ }
713
+
714
+ const slideInFrames = Math.round(duration * 30 * 0.3);
715
+ const xExpr = `if(lt(t\\,${slideInFrames}/30)\\,-text_w+(w/2+text_w/2)*t/(${slideInFrames}/30)\\,(w-text_w)/2)`;
716
+
717
+ return `[${baseLabel}]drawtext=text='${text}':fontsize=${fontSize}:fontcolor=${textColor}:x='${xExpr}':y=${yExpr}${fontFile}${fontFamily}${enable}`;
718
+ }
719
+
720
+ export function processLayer(
721
+ layer: Layer,
722
+ index: number,
723
+ width: number,
724
+ height: number,
725
+ duration: number,
726
+ isOverlay = false,
727
+ ): LayerFilter | null {
728
+ switch (layer.type) {
729
+ case "video":
730
+ return getVideoFilter(layer, index, width, height, duration, isOverlay);
731
+ case "image":
732
+ return getImageFilter(layer, index, width, height, duration);
733
+ case "fill-color":
734
+ case "pause":
735
+ return getFillColorFilter(
736
+ layer as FillColorLayer,
737
+ index,
738
+ width,
739
+ height,
740
+ duration,
741
+ );
742
+ case "radial-gradient":
743
+ case "linear-gradient":
744
+ return getGradientFilter(
745
+ layer as RadialGradientLayer | LinearGradientLayer,
746
+ index,
747
+ width,
748
+ height,
749
+ duration,
750
+ );
751
+ case "title-background":
752
+ return getTitleBackgroundFilter(
753
+ layer as TitleBackgroundLayer,
754
+ index,
755
+ width,
756
+ height,
757
+ duration,
758
+ );
759
+ case "rainbow-colors":
760
+ return getRainbowColorsFilter(
761
+ layer as RainbowColorsLayer,
762
+ index,
763
+ width,
764
+ height,
765
+ duration,
766
+ );
767
+ case "audio":
768
+ return null;
769
+ default:
770
+ return null;
771
+ }
772
+ }