varg.ai-sdk 0.1.0 → 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 (236) 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 +48 -8
  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} +63 -90
  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 -227
  206. package/action/edit/SKILL.md +0 -235
  207. package/action/edit/index.ts +0 -493
  208. package/action/image/SKILL.md +0 -140
  209. package/action/image/index.ts +0 -112
  210. package/action/sync/SKILL.md +0 -136
  211. package/action/sync/index.ts +0 -187
  212. package/action/transcribe/SKILL.md +0 -179
  213. package/action/video/SKILL.md +0 -116
  214. package/action/video/index.ts +0 -135
  215. package/action/voice/SKILL.md +0 -125
  216. package/action/voice/index.ts +0 -201
  217. package/index.ts +0 -38
  218. package/lib/README.md +0 -144
  219. package/lib/ai-sdk/fal.ts +0 -106
  220. package/lib/ai-sdk/replicate.ts +0 -107
  221. package/lib/elevenlabs.ts +0 -382
  222. package/lib/fal.ts +0 -478
  223. package/lib/ffmpeg.ts +0 -467
  224. package/lib/fireworks.ts +0 -235
  225. package/lib/groq.ts +0 -246
  226. package/lib/higgsfield.ts +0 -176
  227. package/lib/remotion/SKILL.md +0 -823
  228. package/lib/remotion/cli.ts +0 -115
  229. package/lib/remotion/functions.ts +0 -283
  230. package/lib/remotion/index.ts +0 -19
  231. package/lib/remotion/templates.ts +0 -73
  232. package/lib/replicate.ts +0 -304
  233. package/output.txt +0 -1
  234. package/test-import.ts +0 -7
  235. package/test-services.ts +0 -97
  236. package/utilities/s3.ts +0 -147
@@ -0,0 +1,1108 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, unlinkSync } from "node:fs";
3
+ import { ffprobe } from "./ffmpeg";
4
+ import { editly } from "./index";
5
+
6
+ const VIDEO_1 = "output/sora-landscape.mp4";
7
+ const VIDEO_2 = "output/simpsons-scene.mp4";
8
+ const VIDEO_TALKING = "output/workflow-talking-synced.mp4";
9
+ const IMAGE_SQUARE = "media/replicate-forest.png";
10
+ const IMAGE_PORTRAIT = "media/madi-portrait.png";
11
+
12
+ describe("editly", () => {
13
+ test("requires outPath", async () => {
14
+ await expect(
15
+ editly({ outPath: "", clips: [{ layers: [{ type: "fill-color" }] }] }),
16
+ ).rejects.toThrow();
17
+ });
18
+
19
+ test("requires at least one clip", async () => {
20
+ await expect(editly({ outPath: "test.mp4", clips: [] })).rejects.toThrow(
21
+ "At least one clip is required",
22
+ );
23
+ });
24
+
25
+ test("creates video with fill-color", async () => {
26
+ const outPath = "output/editly-test-fill.mp4";
27
+ if (existsSync(outPath)) unlinkSync(outPath);
28
+
29
+ await editly({
30
+ outPath,
31
+ width: 640,
32
+ height: 480,
33
+ fps: 30,
34
+ clips: [
35
+ { duration: 2, layers: [{ type: "fill-color", color: "#ff0000" }] },
36
+ ],
37
+ });
38
+
39
+ expect(existsSync(outPath)).toBe(true);
40
+ const info = await ffprobe(outPath);
41
+ expect(info.duration).toBeCloseTo(2, 0);
42
+ });
43
+
44
+ test("creates video with title overlay", async () => {
45
+ const outPath = "output/editly-test-title.mp4";
46
+ if (existsSync(outPath)) unlinkSync(outPath);
47
+
48
+ await editly({
49
+ outPath,
50
+ width: 640,
51
+ height: 480,
52
+ fps: 30,
53
+ clips: [
54
+ {
55
+ duration: 2,
56
+ layers: [
57
+ { type: "fill-color", color: "#1a1a2e" },
58
+ { type: "title", text: "Hello World", textColor: "#ffffff" },
59
+ ],
60
+ },
61
+ ],
62
+ });
63
+
64
+ expect(existsSync(outPath)).toBe(true);
65
+ });
66
+
67
+ test("merges two videos with fade transition", async () => {
68
+ const outPath = "output/editly-test-merge.mp4";
69
+ if (existsSync(outPath)) unlinkSync(outPath);
70
+
71
+ await editly({
72
+ outPath,
73
+ width: 1280,
74
+ height: 720,
75
+ fps: 30,
76
+ clips: [
77
+ {
78
+ layers: [{ type: "video", path: VIDEO_1 }],
79
+ transition: { name: "fade", duration: 0.5 },
80
+ },
81
+ {
82
+ layers: [{ type: "video", path: VIDEO_2 }],
83
+ },
84
+ ],
85
+ });
86
+
87
+ expect(existsSync(outPath)).toBe(true);
88
+ const info = await ffprobe(outPath);
89
+ expect(info.duration).toBeGreaterThan(2);
90
+ });
91
+
92
+ test("picture-in-picture (pip)", async () => {
93
+ const outPath = "output/editly-test-pip.mp4";
94
+ if (existsSync(outPath)) unlinkSync(outPath);
95
+
96
+ await editly({
97
+ outPath,
98
+ width: 1280,
99
+ height: 720,
100
+ fps: 30,
101
+ clips: [
102
+ {
103
+ duration: 3,
104
+ layers: [
105
+ { type: "video", path: VIDEO_1 },
106
+ {
107
+ type: "video",
108
+ path: VIDEO_2,
109
+ width: "30%",
110
+ height: "30%",
111
+ left: "68%",
112
+ top: "2%",
113
+ },
114
+ ],
115
+ },
116
+ ],
117
+ });
118
+
119
+ expect(existsSync(outPath)).toBe(true);
120
+ });
121
+
122
+ test("pip with originX/originY", async () => {
123
+ const outPath = "output/editly-test-pip-origin.mp4";
124
+ if (existsSync(outPath)) unlinkSync(outPath);
125
+
126
+ await editly({
127
+ outPath,
128
+ width: 1280,
129
+ height: 720,
130
+ fps: 30,
131
+ clips: [
132
+ {
133
+ duration: 3,
134
+ layers: [
135
+ { type: "video", path: VIDEO_1 },
136
+ {
137
+ type: "video",
138
+ path: VIDEO_2,
139
+ width: "30%",
140
+ height: "30%",
141
+ left: "95%",
142
+ top: "5%",
143
+ originX: "right",
144
+ originY: "top",
145
+ },
146
+ ],
147
+ },
148
+ ],
149
+ });
150
+
151
+ expect(existsSync(outPath)).toBe(true);
152
+ });
153
+
154
+ test("pip continuous across clips", async () => {
155
+ const outPath = "output/editly-test-pip-continuous.mp4";
156
+ if (existsSync(outPath)) unlinkSync(outPath);
157
+
158
+ const colors = ["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff"];
159
+ const clips = colors.map((color, i) => ({
160
+ duration: 1,
161
+ layers: [
162
+ { type: "fill-color" as const, color },
163
+ {
164
+ type: "video" as const,
165
+ path: VIDEO_TALKING,
166
+ width: "30%" as const,
167
+ height: "30%" as const,
168
+ left: "95%" as const,
169
+ top: "5%" as const,
170
+ originX: "right" as const,
171
+ originY: "top" as const,
172
+ },
173
+ ],
174
+ transition:
175
+ i < colors.length - 1 ? { name: "fade", duration: 0.3 } : undefined,
176
+ }));
177
+
178
+ await editly({
179
+ outPath,
180
+ width: 1280,
181
+ height: 720,
182
+ fps: 30,
183
+ clips,
184
+ });
185
+
186
+ expect(existsSync(outPath)).toBe(true);
187
+ });
188
+
189
+ test("gradients", async () => {
190
+ const outPath = "output/editly-test-gradients.mp4";
191
+ if (existsSync(outPath)) unlinkSync(outPath);
192
+
193
+ await editly({
194
+ outPath,
195
+ width: 640,
196
+ height: 480,
197
+ fps: 30,
198
+ clips: [
199
+ {
200
+ duration: 1,
201
+ layers: [{ type: "linear-gradient", colors: ["#02aab0", "#00cdac"] }],
202
+ },
203
+ {
204
+ duration: 1,
205
+ layers: [{ type: "radial-gradient", colors: ["#b002aa", "#ac00cd"] }],
206
+ },
207
+ ],
208
+ });
209
+
210
+ expect(existsSync(outPath)).toBe(true);
211
+ });
212
+
213
+ test("multiple clips with transitions", async () => {
214
+ const outPath = "output/editly-test-transitions.mp4";
215
+ if (existsSync(outPath)) unlinkSync(outPath);
216
+
217
+ await editly({
218
+ outPath,
219
+ width: 640,
220
+ height: 480,
221
+ fps: 30,
222
+ clips: [
223
+ {
224
+ duration: 2,
225
+ layers: [{ type: "fill-color", color: "#ff0000" }],
226
+ transition: { name: "fade", duration: 0.5 },
227
+ },
228
+ {
229
+ duration: 2,
230
+ layers: [{ type: "fill-color", color: "#00ff00" }],
231
+ transition: { name: "fade", duration: 0.5 },
232
+ },
233
+ {
234
+ duration: 2,
235
+ layers: [{ type: "fill-color", color: "#0000ff" }],
236
+ },
237
+ ],
238
+ });
239
+
240
+ expect(existsSync(outPath)).toBe(true);
241
+ const info = await ffprobe(outPath);
242
+ expect(info.duration).toBeGreaterThan(3);
243
+ });
244
+
245
+ test("image ken burns preserves aspect ratio", async () => {
246
+ const outPath = "output/editly-test-image-aspect.mp4";
247
+ if (existsSync(outPath)) unlinkSync(outPath);
248
+
249
+ await editly({
250
+ outPath,
251
+ width: 1280,
252
+ height: 720,
253
+ fps: 30,
254
+ clips: [
255
+ {
256
+ duration: 3,
257
+ layers: [
258
+ {
259
+ type: "image",
260
+ path: IMAGE_SQUARE,
261
+ zoomDirection: "in",
262
+ zoomAmount: 0.1,
263
+ resizeMode: "contain",
264
+ },
265
+ ],
266
+ },
267
+ ],
268
+ });
269
+
270
+ expect(existsSync(outPath)).toBe(true);
271
+ });
272
+
273
+ test("image pan left/right", async () => {
274
+ const outPath = "output/editly-test-image-pan.mp4";
275
+ if (existsSync(outPath)) unlinkSync(outPath);
276
+
277
+ await editly({
278
+ outPath,
279
+ width: 1280,
280
+ height: 720,
281
+ fps: 30,
282
+ clips: [
283
+ {
284
+ duration: 3,
285
+ layers: [
286
+ {
287
+ type: "image",
288
+ path: "media/cyberpunk-street.png",
289
+ zoomDirection: "left",
290
+ zoomAmount: 0.15,
291
+ resizeMode: "contain",
292
+ },
293
+ ],
294
+ transition: { name: "fade", duration: 0.5 },
295
+ },
296
+ {
297
+ duration: 3,
298
+ layers: [
299
+ {
300
+ type: "image",
301
+ path: "media/cyberpunk-street.png",
302
+ zoomDirection: "right",
303
+ zoomAmount: 0.15,
304
+ resizeMode: "contain",
305
+ },
306
+ ],
307
+ },
308
+ ],
309
+ });
310
+
311
+ expect(existsSync(outPath)).toBe(true);
312
+ });
313
+
314
+ test("title with custom font", async () => {
315
+ const outPath = "output/editly-test-title-font.mp4";
316
+ if (existsSync(outPath)) unlinkSync(outPath);
317
+
318
+ await editly({
319
+ outPath,
320
+ width: 1280,
321
+ height: 720,
322
+ fps: 30,
323
+ clips: [
324
+ {
325
+ duration: 3,
326
+ layers: [
327
+ { type: "fill-color", color: "#1a1a2e" },
328
+ {
329
+ type: "title",
330
+ text: "Custom Font Test",
331
+ textColor: "#ffffff",
332
+ fontPath:
333
+ "/System/Library/Fonts/Supplemental/Comic Sans MS Bold.ttf",
334
+ position: "center",
335
+ },
336
+ ],
337
+ },
338
+ ],
339
+ });
340
+
341
+ expect(existsSync(outPath)).toBe(true);
342
+ });
343
+
344
+ test("image-overlay with position presets", async () => {
345
+ const outPath = "output/editly-test-image-overlay.mp4";
346
+ if (existsSync(outPath)) unlinkSync(outPath);
347
+
348
+ await editly({
349
+ outPath,
350
+ width: 1280,
351
+ height: 720,
352
+ fps: 30,
353
+ clips: [
354
+ {
355
+ duration: 3,
356
+ layers: [
357
+ { type: "video", path: VIDEO_1 },
358
+ {
359
+ type: "image-overlay",
360
+ path: IMAGE_SQUARE,
361
+ position: "bottom-right",
362
+ width: "20%",
363
+ },
364
+ ],
365
+ },
366
+ ],
367
+ });
368
+
369
+ expect(existsSync(outPath)).toBe(true);
370
+ });
371
+
372
+ test("image-overlay with ken burns zoom", async () => {
373
+ const outPath = "output/editly-test-image-overlay-zoom.mp4";
374
+ if (existsSync(outPath)) unlinkSync(outPath);
375
+
376
+ await editly({
377
+ outPath,
378
+ width: 1280,
379
+ height: 720,
380
+ fps: 30,
381
+ clips: [
382
+ {
383
+ duration: 3,
384
+ layers: [
385
+ { type: "fill-color", color: "#1a1a2e" },
386
+ {
387
+ type: "image-overlay",
388
+ path: IMAGE_SQUARE,
389
+ position: "center",
390
+ width: "40%",
391
+ zoomDirection: "in",
392
+ zoomAmount: 0.15,
393
+ },
394
+ ],
395
+ },
396
+ ],
397
+ });
398
+
399
+ expect(existsSync(outPath)).toBe(true);
400
+ });
401
+
402
+ test("image-overlay continuous across clips", async () => {
403
+ const outPath = "output/editly-test-image-overlay-continuous.mp4";
404
+ if (existsSync(outPath)) unlinkSync(outPath);
405
+
406
+ const colors = ["#ff6b6b", "#4ecdc4", "#45b7d1"];
407
+ const clips = colors.map((color, i) => ({
408
+ duration: 2,
409
+ layers: [
410
+ { type: "fill-color" as const, color },
411
+ {
412
+ type: "image-overlay" as const,
413
+ path: IMAGE_SQUARE,
414
+ position: "top-right" as const,
415
+ width: "20%" as const,
416
+ },
417
+ ],
418
+ transition:
419
+ i < colors.length - 1 ? { name: "fade", duration: 0.3 } : undefined,
420
+ }));
421
+
422
+ await editly({
423
+ outPath,
424
+ width: 1280,
425
+ height: 720,
426
+ fps: 30,
427
+ clips,
428
+ });
429
+
430
+ expect(existsSync(outPath)).toBe(true);
431
+ });
432
+
433
+ test("subtitle layer", async () => {
434
+ const outPath = "output/editly-test-subtitle.mp4";
435
+ if (existsSync(outPath)) unlinkSync(outPath);
436
+
437
+ await editly({
438
+ outPath,
439
+ width: 1280,
440
+ height: 720,
441
+ fps: 30,
442
+ clips: [
443
+ {
444
+ duration: 3,
445
+ layers: [
446
+ { type: "video", path: VIDEO_1 },
447
+ {
448
+ type: "subtitle",
449
+ text: "This is a subtitle at the bottom",
450
+ },
451
+ ],
452
+ },
453
+ {
454
+ duration: 3,
455
+ layers: [
456
+ { type: "video", path: VIDEO_2 },
457
+ {
458
+ type: "subtitle",
459
+ text: "Another subtitle with custom colors",
460
+ textColor: "yellow",
461
+ backgroundColor: "blue@0.8",
462
+ },
463
+ ],
464
+ },
465
+ ],
466
+ });
467
+
468
+ expect(existsSync(outPath)).toBe(true);
469
+ });
470
+
471
+ test("title-background layer", async () => {
472
+ const outPath = "output/editly-test-title-background.mp4";
473
+ if (existsSync(outPath)) unlinkSync(outPath);
474
+
475
+ await editly({
476
+ outPath,
477
+ width: 1280,
478
+ height: 720,
479
+ fps: 30,
480
+ clips: [
481
+ {
482
+ duration: 3,
483
+ layers: [
484
+ {
485
+ type: "title-background",
486
+ text: "Welcome",
487
+ textColor: "white",
488
+ background: {
489
+ type: "radial-gradient",
490
+ colors: ["#667eea", "#764ba2"],
491
+ },
492
+ },
493
+ ],
494
+ },
495
+ {
496
+ duration: 3,
497
+ layers: [
498
+ {
499
+ type: "title-background",
500
+ text: "Goodbye",
501
+ textColor: "#ffff00",
502
+ background: {
503
+ type: "linear-gradient",
504
+ colors: ["#11998e", "#38ef7d"],
505
+ },
506
+ },
507
+ ],
508
+ },
509
+ ],
510
+ });
511
+
512
+ expect(existsSync(outPath)).toBe(true);
513
+ });
514
+
515
+ test("rainbow-colors layer", async () => {
516
+ const outPath = "output/editly-test-rainbow.mp4";
517
+ if (existsSync(outPath)) unlinkSync(outPath);
518
+
519
+ await editly({
520
+ outPath,
521
+ width: 640,
522
+ height: 480,
523
+ fps: 30,
524
+ clips: [
525
+ {
526
+ duration: 4,
527
+ layers: [{ type: "rainbow-colors" }],
528
+ },
529
+ ],
530
+ });
531
+
532
+ expect(existsSync(outPath)).toBe(true);
533
+ });
534
+
535
+ test("news-title layer", async () => {
536
+ const outPath = "output/editly-test-news-title.mp4";
537
+ if (existsSync(outPath)) unlinkSync(outPath);
538
+
539
+ await editly({
540
+ outPath,
541
+ width: 1280,
542
+ height: 720,
543
+ fps: 30,
544
+ clips: [
545
+ {
546
+ duration: 3,
547
+ layers: [
548
+ { type: "video", path: VIDEO_1 },
549
+ {
550
+ type: "news-title",
551
+ text: "BREAKING NEWS: Something important happened",
552
+ backgroundColor: "red",
553
+ },
554
+ ],
555
+ },
556
+ {
557
+ duration: 3,
558
+ layers: [
559
+ { type: "video", path: VIDEO_2 },
560
+ {
561
+ type: "news-title",
562
+ text: "TOP STORY",
563
+ backgroundColor: "blue",
564
+ position: "top",
565
+ },
566
+ ],
567
+ },
568
+ ],
569
+ });
570
+
571
+ expect(existsSync(outPath)).toBe(true);
572
+ });
573
+
574
+ test("slide-in-text layer", async () => {
575
+ const outPath = "output/editly-test-slide-in.mp4";
576
+ if (existsSync(outPath)) unlinkSync(outPath);
577
+
578
+ await editly({
579
+ outPath,
580
+ width: 1280,
581
+ height: 720,
582
+ fps: 30,
583
+ clips: [
584
+ {
585
+ duration: 3,
586
+ layers: [
587
+ { type: "fill-color", color: "#1a1a2e" },
588
+ {
589
+ type: "slide-in-text",
590
+ text: "Sliding In!",
591
+ color: "white",
592
+ },
593
+ ],
594
+ },
595
+ ],
596
+ });
597
+
598
+ expect(existsSync(outPath)).toBe(true);
599
+ });
600
+
601
+ test("subtitle continuous across clips", async () => {
602
+ const outPath = "output/editly-test-subtitle-continuous.mp4";
603
+ if (existsSync(outPath)) unlinkSync(outPath);
604
+
605
+ await editly({
606
+ outPath,
607
+ width: 1280,
608
+ height: 720,
609
+ fps: 30,
610
+ clips: [
611
+ {
612
+ duration: 2,
613
+ layers: [
614
+ { type: "video", path: VIDEO_1 },
615
+ { type: "subtitle", text: "Continuous subtitle test" },
616
+ ],
617
+ transition: { name: "fade", duration: 0.5 },
618
+ },
619
+ {
620
+ duration: 2,
621
+ layers: [
622
+ { type: "video", path: VIDEO_2 },
623
+ { type: "subtitle", text: "Continuous subtitle test" },
624
+ ],
625
+ transition: { name: "fade", duration: 0.5 },
626
+ },
627
+ {
628
+ duration: 2,
629
+ layers: [
630
+ { type: "fill-color", color: "#1a1a2e" },
631
+ { type: "subtitle", text: "Continuous subtitle test" },
632
+ ],
633
+ },
634
+ ],
635
+ });
636
+
637
+ expect(existsSync(outPath)).toBe(true);
638
+ });
639
+
640
+ test("audio layer in clip", async () => {
641
+ const outPath = "output/editly-test-audio-layer.mp4";
642
+ if (existsSync(outPath)) unlinkSync(outPath);
643
+
644
+ await editly({
645
+ outPath,
646
+ width: 640,
647
+ height: 480,
648
+ fps: 30,
649
+ clips: [
650
+ {
651
+ duration: 3,
652
+ layers: [
653
+ { type: "fill-color", color: "#1a1a2e" },
654
+ { type: "title", text: "Audio Layer Test" },
655
+ { type: "audio", path: "media/kirill-voice.mp3" },
656
+ ],
657
+ },
658
+ ],
659
+ });
660
+
661
+ expect(existsSync(outPath)).toBe(true);
662
+ });
663
+
664
+ test("detached-audio layer with start offset", async () => {
665
+ const outPath = "output/editly-test-detached-audio.mp4";
666
+ if (existsSync(outPath)) unlinkSync(outPath);
667
+
668
+ await editly({
669
+ outPath,
670
+ width: 640,
671
+ height: 480,
672
+ fps: 30,
673
+ clips: [
674
+ {
675
+ duration: 5,
676
+ layers: [
677
+ { type: "fill-color", color: "#1a1a2e" },
678
+ { type: "title", text: "Audio starts at 2s" },
679
+ {
680
+ type: "detached-audio",
681
+ path: "media/kirill-voice.mp3",
682
+ start: 2,
683
+ },
684
+ ],
685
+ },
686
+ ],
687
+ });
688
+
689
+ expect(existsSync(outPath)).toBe(true);
690
+ });
691
+
692
+ test("loopAudio", async () => {
693
+ const outPath = "output/editly-test-loop-audio.mp4";
694
+ if (existsSync(outPath)) unlinkSync(outPath);
695
+
696
+ await editly({
697
+ outPath,
698
+ width: 640,
699
+ height: 480,
700
+ fps: 30,
701
+ audioFilePath: "media/kirill-voice.mp3",
702
+ loopAudio: true,
703
+ clips: [
704
+ {
705
+ duration: 10,
706
+ layers: [
707
+ { type: "fill-color", color: "#1a1a2e" },
708
+ { type: "title", text: "Audio loops for 10s" },
709
+ ],
710
+ },
711
+ ],
712
+ });
713
+
714
+ expect(existsSync(outPath)).toBe(true);
715
+ });
716
+
717
+ test("keepSourceAudio preserves original video audio", async () => {
718
+ const outPath = "output/editly-test-keep-source-audio.mp4";
719
+ if (existsSync(outPath)) unlinkSync(outPath);
720
+
721
+ await editly({
722
+ outPath,
723
+ width: 1280,
724
+ height: 720,
725
+ fps: 30,
726
+ keepSourceAudio: true,
727
+ clips: [
728
+ {
729
+ layers: [
730
+ { type: "video", path: VIDEO_TALKING },
731
+ { type: "subtitle", text: "Original audio should play" },
732
+ ],
733
+ },
734
+ ],
735
+ });
736
+
737
+ expect(existsSync(outPath)).toBe(true);
738
+ });
739
+
740
+ test("keepSourceAudio with multiple clips and transitions", async () => {
741
+ const outPath = "output/editly-test-keep-source-audio-multi.mp4";
742
+ if (existsSync(outPath)) unlinkSync(outPath);
743
+
744
+ await editly({
745
+ outPath,
746
+ width: 1280,
747
+ height: 720,
748
+ fps: 30,
749
+ keepSourceAudio: true,
750
+ clips: [
751
+ {
752
+ duration: 3,
753
+ layers: [{ type: "video", path: VIDEO_TALKING }],
754
+ transition: { name: "fade", duration: 0.5 },
755
+ },
756
+ {
757
+ duration: 3,
758
+ layers: [
759
+ { type: "fill-color", color: "#1a1a2e" },
760
+ { type: "title", text: "No audio clip" },
761
+ ],
762
+ transition: { name: "fade", duration: 0.5 },
763
+ },
764
+ {
765
+ duration: 3,
766
+ layers: [{ type: "video", path: VIDEO_TALKING }],
767
+ },
768
+ ],
769
+ });
770
+
771
+ expect(existsSync(outPath)).toBe(true);
772
+ });
773
+
774
+ test("keepSourceAudio with cutFrom stays in sync", async () => {
775
+ const outPath = "output/editly-test-keep-source-audio-cutfrom.mp4";
776
+ if (existsSync(outPath)) unlinkSync(outPath);
777
+
778
+ await editly({
779
+ outPath,
780
+ width: 1280,
781
+ height: 720,
782
+ fps: 30,
783
+ keepSourceAudio: true,
784
+ clips: [
785
+ {
786
+ layers: [
787
+ { type: "video", path: VIDEO_TALKING, cutFrom: 2, cutTo: 6 },
788
+ ],
789
+ },
790
+ ],
791
+ });
792
+
793
+ expect(existsSync(outPath)).toBe(true);
794
+ });
795
+
796
+ test("clipsAudioVolume controls source video audio level", async () => {
797
+ const outPath = "output/editly-test-clips-audio-volume.mp4";
798
+ if (existsSync(outPath)) unlinkSync(outPath);
799
+
800
+ await editly({
801
+ outPath,
802
+ width: 1280,
803
+ height: 720,
804
+ fps: 30,
805
+ keepSourceAudio: true,
806
+ clipsAudioVolume: 0.3,
807
+ clips: [
808
+ {
809
+ duration: 4,
810
+ layers: [{ type: "video", path: VIDEO_TALKING }],
811
+ },
812
+ ],
813
+ });
814
+
815
+ expect(existsSync(outPath)).toBe(true);
816
+ });
817
+
818
+ test("audioNorm normalizes audio levels", async () => {
819
+ const outPath = "output/editly-test-audio-norm.mp4";
820
+ if (existsSync(outPath)) unlinkSync(outPath);
821
+
822
+ await editly({
823
+ outPath,
824
+ width: 1280,
825
+ height: 720,
826
+ fps: 30,
827
+ keepSourceAudio: true,
828
+ audioNorm: { enable: true, gaussSize: 5, maxGain: 25 },
829
+ clips: [
830
+ {
831
+ duration: 4,
832
+ layers: [{ type: "video", path: VIDEO_TALKING }],
833
+ },
834
+ ],
835
+ });
836
+
837
+ expect(existsSync(outPath)).toBe(true);
838
+ });
839
+
840
+ test("audioTracks with cutFrom/cutTo/start", async () => {
841
+ const outPath = "output/editly-test-audio-tracks-advanced.mp4";
842
+ if (existsSync(outPath)) unlinkSync(outPath);
843
+
844
+ await editly({
845
+ outPath,
846
+ width: 640,
847
+ height: 480,
848
+ fps: 30,
849
+ audioTracks: [
850
+ {
851
+ path: "media/kirill-voice.mp3",
852
+ cutFrom: 0,
853
+ cutTo: 2,
854
+ start: 1,
855
+ mixVolume: 0.8,
856
+ },
857
+ ],
858
+ clips: [
859
+ {
860
+ duration: 5,
861
+ layers: [
862
+ { type: "fill-color", color: "#1a1a2e" },
863
+ { type: "title", text: "Audio starts at 1s" },
864
+ ],
865
+ },
866
+ ],
867
+ });
868
+
869
+ expect(existsSync(outPath)).toBe(true);
870
+ });
871
+
872
+ test("layer start/stop timing", async () => {
873
+ const outPath = "output/editly-test-layer-timing.mp4";
874
+ if (existsSync(outPath)) unlinkSync(outPath);
875
+
876
+ await editly({
877
+ outPath,
878
+ width: 1280,
879
+ height: 720,
880
+ fps: 30,
881
+ clips: [
882
+ {
883
+ duration: 6,
884
+ layers: [
885
+ { type: "fill-color", color: "#1a1a2e" },
886
+ { type: "title", text: "Always visible", position: "top" },
887
+ {
888
+ type: "subtitle",
889
+ text: "Appears at 1s, disappears at 4s",
890
+ start: 1,
891
+ stop: 4,
892
+ },
893
+ {
894
+ type: "news-title",
895
+ text: "NEWS: Visible 2s-5s",
896
+ start: 2,
897
+ stop: 5,
898
+ },
899
+ ],
900
+ },
901
+ ],
902
+ });
903
+
904
+ expect(existsSync(outPath)).toBe(true);
905
+ });
906
+
907
+ test("contain-blur resize mode for video", async () => {
908
+ const outPath = "output/editly-test-contain-blur-video.mp4";
909
+ if (existsSync(outPath)) unlinkSync(outPath);
910
+
911
+ await editly({
912
+ outPath,
913
+ width: 1080,
914
+ height: 1920,
915
+ fps: 30,
916
+ clips: [
917
+ {
918
+ duration: 3,
919
+ layers: [
920
+ { type: "video", path: VIDEO_1, resizeMode: "contain-blur" },
921
+ ],
922
+ },
923
+ ],
924
+ });
925
+
926
+ expect(existsSync(outPath)).toBe(true);
927
+ });
928
+
929
+ test("contain-blur resize mode for image", async () => {
930
+ const outPath = "output/editly-test-contain-blur-image.mp4";
931
+ if (existsSync(outPath)) unlinkSync(outPath);
932
+
933
+ await editly({
934
+ outPath,
935
+ width: 1920,
936
+ height: 1080,
937
+ fps: 30,
938
+ clips: [
939
+ {
940
+ duration: 3,
941
+ layers: [
942
+ {
943
+ type: "image",
944
+ path: IMAGE_SQUARE,
945
+ resizeMode: "contain-blur",
946
+ zoomDirection: null,
947
+ },
948
+ ],
949
+ },
950
+ ],
951
+ });
952
+
953
+ expect(existsSync(outPath)).toBe(true);
954
+ });
955
+
956
+ test("defaults.layer and defaults.layerType", async () => {
957
+ const outPath = "output/editly-test-defaults.mp4";
958
+ if (existsSync(outPath)) unlinkSync(outPath);
959
+
960
+ await editly({
961
+ outPath,
962
+ width: 1280,
963
+ height: 720,
964
+ fps: 30,
965
+ defaults: {
966
+ layer: {
967
+ fontPath: "/System/Library/Fonts/Helvetica.ttc",
968
+ },
969
+ layerType: {
970
+ title: {
971
+ textColor: "yellow",
972
+ },
973
+ subtitle: {
974
+ textColor: "cyan",
975
+ backgroundColor: "black@0.9",
976
+ },
977
+ },
978
+ },
979
+ clips: [
980
+ {
981
+ duration: 3,
982
+ layers: [
983
+ { type: "fill-color", color: "#1a1a2e" },
984
+ { type: "title", text: "Yellow from defaults" },
985
+ { type: "subtitle", text: "Cyan from defaults" },
986
+ ],
987
+ },
988
+ ],
989
+ });
990
+
991
+ expect(existsSync(outPath)).toBe(true);
992
+ });
993
+
994
+ test("audio crossfade during transitions", async () => {
995
+ const outPath = "output/editly-test-audio-crossfade.mp4";
996
+ if (existsSync(outPath)) unlinkSync(outPath);
997
+
998
+ await editly({
999
+ outPath,
1000
+ width: 1280,
1001
+ height: 720,
1002
+ fps: 30,
1003
+ keepSourceAudio: true,
1004
+ clips: [
1005
+ {
1006
+ duration: 4,
1007
+ layers: [{ type: "video", path: VIDEO_1 }],
1008
+ transition: { name: "fade", duration: 1 },
1009
+ },
1010
+ {
1011
+ duration: 4,
1012
+ layers: [{ type: "video", path: "output/duet-mixed.mp4" }],
1013
+ },
1014
+ ],
1015
+ });
1016
+
1017
+ expect(existsSync(outPath)).toBe(true);
1018
+ });
1019
+
1020
+ test("portrait 9:16 image with zoompan - square image cover mode", async () => {
1021
+ const outPath = "output/editly-test-portrait-zoompan.mp4";
1022
+ if (existsSync(outPath)) unlinkSync(outPath);
1023
+
1024
+ await editly({
1025
+ outPath,
1026
+ width: 1080,
1027
+ height: 1920,
1028
+ fps: 30,
1029
+ clips: [
1030
+ {
1031
+ duration: 3,
1032
+ layers: [
1033
+ {
1034
+ type: "image",
1035
+ path: IMAGE_SQUARE,
1036
+ zoomDirection: "in",
1037
+ zoomAmount: 0.1,
1038
+ resizeMode: "cover",
1039
+ },
1040
+ ],
1041
+ },
1042
+ ],
1043
+ });
1044
+
1045
+ expect(existsSync(outPath)).toBe(true);
1046
+ const info = await ffprobe(outPath);
1047
+ expect(info.width).toBe(1080);
1048
+ expect(info.height).toBe(1920);
1049
+ expect(info.duration).toBeCloseTo(3, 0);
1050
+ });
1051
+
1052
+ test("portrait 9:16 native image with zoompan (onlyfans workflow)", async () => {
1053
+ const outPath = "output/editly-test-portrait-native.mp4";
1054
+ if (existsSync(outPath)) unlinkSync(outPath);
1055
+
1056
+ await editly({
1057
+ outPath,
1058
+ width: 1080,
1059
+ height: 1920,
1060
+ fps: 30,
1061
+ clips: [
1062
+ {
1063
+ duration: 3,
1064
+ layers: [
1065
+ {
1066
+ type: "image",
1067
+ path: IMAGE_PORTRAIT,
1068
+ zoomDirection: "in",
1069
+ zoomAmount: 0.1,
1070
+ },
1071
+ ],
1072
+ },
1073
+ ],
1074
+ });
1075
+ });
1076
+
1077
+ test("portrait 9:16 landscape image with zoompan cover mode", async () => {
1078
+ const outPath = "output/editly-test-portrait-landscape-cover.mp4";
1079
+ if (existsSync(outPath)) unlinkSync(outPath);
1080
+
1081
+ await editly({
1082
+ outPath,
1083
+ width: 1080,
1084
+ height: 1920,
1085
+ fps: 30,
1086
+ clips: [
1087
+ {
1088
+ duration: 3,
1089
+ layers: [
1090
+ {
1091
+ type: "image",
1092
+ path: "media/cyberpunk-street.png",
1093
+ zoomDirection: "in",
1094
+ zoomAmount: 0.1,
1095
+ resizeMode: "cover",
1096
+ },
1097
+ ],
1098
+ },
1099
+ ],
1100
+ });
1101
+
1102
+ expect(existsSync(outPath)).toBe(true);
1103
+ const info = await ffprobe(outPath);
1104
+ expect(info.width).toBe(1080);
1105
+ expect(info.height).toBe(1920);
1106
+ expect(info.duration).toBeCloseTo(3, 0);
1107
+ });
1108
+ });