varg.ai-sdk 0.1.1 → 0.4.0-alpha.1

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