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,544 @@
1
+ /**
2
+ * FFmpeg provider for local video editing operations
3
+ * This is a special provider that runs locally rather than through an API
4
+ */
5
+
6
+ import { existsSync } from "node:fs";
7
+ import ffmpeg from "fluent-ffmpeg";
8
+ import type { JobStatusUpdate, ProviderConfig } from "../core/schema/types";
9
+ import { BaseProvider } from "./base";
10
+
11
+ export class FFmpegProvider extends BaseProvider {
12
+ readonly name = "ffmpeg";
13
+
14
+ async submit(
15
+ _model: string,
16
+ _inputs: Record<string, unknown>,
17
+ _config?: ProviderConfig,
18
+ ): Promise<string> {
19
+ const jobId = `ffmpeg_${Date.now()}_${Math.random().toString(36).slice(2)}`;
20
+ console.log(`[ffmpeg] starting local job: ${jobId}`);
21
+ return jobId;
22
+ }
23
+
24
+ async getStatus(_jobId: string): Promise<JobStatusUpdate> {
25
+ return { status: "completed" };
26
+ }
27
+
28
+ async getResult(_jobId: string): Promise<unknown> {
29
+ return null;
30
+ }
31
+
32
+ // ============================================================================
33
+ // Video Operations
34
+ // ============================================================================
35
+
36
+ async probe(input: string): Promise<ProbeResult> {
37
+ if (!existsSync(input)) {
38
+ throw new Error(`input file not found: ${input}`);
39
+ }
40
+
41
+ console.log(`[ffmpeg] probing ${input}...`);
42
+
43
+ return new Promise((resolve, reject) => {
44
+ ffmpeg.ffprobe(input, (err, metadata) => {
45
+ if (err) {
46
+ reject(err);
47
+ return;
48
+ }
49
+
50
+ const videoStream = metadata.streams.find(
51
+ (s) => s.codec_type === "video",
52
+ );
53
+ if (!videoStream) {
54
+ reject(new Error("no video stream found"));
55
+ return;
56
+ }
57
+
58
+ const result: ProbeResult = {
59
+ duration: metadata.format.duration || 0,
60
+ width: videoStream.width || 0,
61
+ height: videoStream.height || 0,
62
+ fps: Number(videoStream.r_frame_rate || "0") || 0,
63
+ codec: videoStream.codec_name || "",
64
+ format: metadata.format.format_name || "",
65
+ };
66
+
67
+ console.log(
68
+ `[ffmpeg] ${result.width}x${result.height} @ ${result.fps}fps, ${result.duration}s`,
69
+ );
70
+ resolve(result);
71
+ });
72
+ });
73
+ }
74
+
75
+ async getVideoDuration(input: string): Promise<number> {
76
+ const result = await this.probe(input);
77
+ return result.duration;
78
+ }
79
+
80
+ async concatVideos(options: {
81
+ inputs: string[];
82
+ output: string;
83
+ }): Promise<string> {
84
+ const { inputs, output } = options;
85
+
86
+ for (const input of inputs) {
87
+ if (!existsSync(input)) {
88
+ throw new Error(`input file not found: ${input}`);
89
+ }
90
+ }
91
+
92
+ console.log(`[ffmpeg] concatenating ${inputs.length} videos...`);
93
+
94
+ return new Promise((resolve, reject) => {
95
+ const command = ffmpeg();
96
+
97
+ for (const input of inputs) {
98
+ command.input(input);
99
+ }
100
+
101
+ const filterComplex =
102
+ inputs.map((_, i) => `[${i}:v][${i}:a]`).join("") +
103
+ `concat=n=${inputs.length}:v=1:a=1[outv][outa]`;
104
+
105
+ command
106
+ .complexFilter(filterComplex)
107
+ .outputOptions(["-map", "[outv]", "-map", "[outa]"])
108
+ .output(output)
109
+ .on("end", () => {
110
+ console.log(`[ffmpeg] saved to ${output}`);
111
+ resolve(output);
112
+ })
113
+ .on("error", reject)
114
+ .run();
115
+ });
116
+ }
117
+
118
+ async addAudio(options: {
119
+ videoPath: string;
120
+ audioPath: string;
121
+ output: string;
122
+ }): Promise<string> {
123
+ const { videoPath, audioPath, output } = options;
124
+
125
+ console.log(`[ffmpeg] adding audio to video...`);
126
+
127
+ return new Promise((resolve, reject) => {
128
+ ffmpeg()
129
+ .input(videoPath)
130
+ .input(audioPath)
131
+ .outputOptions([
132
+ "-c:v",
133
+ "copy",
134
+ "-c:a",
135
+ "aac",
136
+ "-map",
137
+ "0:v:0",
138
+ "-map",
139
+ "1:a:0",
140
+ ])
141
+ .output(output)
142
+ .on("end", () => {
143
+ console.log(`[ffmpeg] saved to ${output}`);
144
+ resolve(output);
145
+ })
146
+ .on("error", reject)
147
+ .run();
148
+ });
149
+ }
150
+
151
+ async resizeVideo(options: {
152
+ input: string;
153
+ output: string;
154
+ width?: number;
155
+ height?: number;
156
+ aspectRatio?: string;
157
+ }): Promise<string> {
158
+ const { input, output, width, height, aspectRatio } = options;
159
+
160
+ console.log(`[ffmpeg] resizing video...`);
161
+
162
+ return new Promise((resolve, reject) => {
163
+ const command = ffmpeg(input);
164
+
165
+ if (width && height) {
166
+ command.size(`${width}x${height}`);
167
+ } else if (aspectRatio) {
168
+ command.aspect(aspectRatio);
169
+ }
170
+
171
+ command
172
+ .output(output)
173
+ .on("end", () => {
174
+ console.log(`[ffmpeg] saved to ${output}`);
175
+ resolve(output);
176
+ })
177
+ .on("error", reject)
178
+ .run();
179
+ });
180
+ }
181
+
182
+ async trimVideo(options: {
183
+ input: string;
184
+ output: string;
185
+ start: number;
186
+ duration?: number;
187
+ }): Promise<string> {
188
+ const { input, output, start, duration } = options;
189
+
190
+ console.log(`[ffmpeg] trimming video...`);
191
+
192
+ return new Promise((resolve, reject) => {
193
+ const command = ffmpeg(input).setStartTime(start);
194
+
195
+ if (duration) {
196
+ command.setDuration(duration);
197
+ }
198
+
199
+ command
200
+ .output(output)
201
+ .on("end", () => {
202
+ console.log(`[ffmpeg] saved to ${output}`);
203
+ resolve(output);
204
+ })
205
+ .on("error", reject)
206
+ .run();
207
+ });
208
+ }
209
+
210
+ async convertFormat(options: {
211
+ input: string;
212
+ output: string;
213
+ format?: string;
214
+ }): Promise<string> {
215
+ const { input, output, format } = options;
216
+
217
+ console.log(`[ffmpeg] converting format...`);
218
+
219
+ return new Promise((resolve, reject) => {
220
+ const command = ffmpeg(input);
221
+
222
+ if (format) {
223
+ command.format(format);
224
+ }
225
+
226
+ command
227
+ .output(output)
228
+ .on("end", () => {
229
+ console.log(`[ffmpeg] saved to ${output}`);
230
+ resolve(output);
231
+ })
232
+ .on("error", reject)
233
+ .run();
234
+ });
235
+ }
236
+
237
+ async extractAudio(input: string, output: string): Promise<string> {
238
+ console.log(`[ffmpeg] extracting audio...`);
239
+
240
+ return new Promise((resolve, reject) => {
241
+ ffmpeg(input)
242
+ .outputOptions(["-vn", "-acodec", "copy"])
243
+ .output(output)
244
+ .on("end", () => {
245
+ console.log(`[ffmpeg] saved to ${output}`);
246
+ resolve(output);
247
+ })
248
+ .on("error", reject)
249
+ .run();
250
+ });
251
+ }
252
+
253
+ async fadeVideo(options: {
254
+ input: string;
255
+ output: string;
256
+ type: "in" | "out" | "both";
257
+ duration: number;
258
+ }): Promise<string> {
259
+ const { input, output, type, duration } = options;
260
+
261
+ if (!existsSync(input)) {
262
+ throw new Error(`input file not found: ${input}`);
263
+ }
264
+
265
+ console.log(`[ffmpeg] applying fade ${type} effect...`);
266
+
267
+ const videoDuration = await this.getVideoDuration(input);
268
+ const videoFilters: string[] = [];
269
+ const audioFilters: string[] = [];
270
+
271
+ if (type === "in" || type === "both") {
272
+ videoFilters.push(`fade=t=in:st=0:d=${duration}`);
273
+ audioFilters.push(`afade=t=in:st=0:d=${duration}`);
274
+ }
275
+
276
+ if (type === "out" || type === "both") {
277
+ const fadeOutStart = videoDuration - duration;
278
+ videoFilters.push(`fade=t=out:st=${fadeOutStart}:d=${duration}`);
279
+ audioFilters.push(`afade=t=out:st=${fadeOutStart}:d=${duration}`);
280
+ }
281
+
282
+ return new Promise((resolve, reject) => {
283
+ const command = ffmpeg(input);
284
+
285
+ if (videoFilters.length > 0) {
286
+ command.videoFilters(videoFilters);
287
+ }
288
+ if (audioFilters.length > 0) {
289
+ command.audioFilters(audioFilters);
290
+ }
291
+
292
+ command
293
+ .output(output)
294
+ .on("end", () => {
295
+ console.log(`[ffmpeg] saved to ${output}`);
296
+ resolve(output);
297
+ })
298
+ .on("error", reject)
299
+ .run();
300
+ });
301
+ }
302
+
303
+ async xfadeVideos(options: {
304
+ input1: string;
305
+ input2: string;
306
+ output: string;
307
+ transition:
308
+ | "crossfade"
309
+ | "dissolve"
310
+ | "wipeleft"
311
+ | "wiperight"
312
+ | "slideup"
313
+ | "slidedown";
314
+ duration: number;
315
+ fit?: "pad" | "crop" | "blur" | "stretch";
316
+ }): Promise<string> {
317
+ const {
318
+ input1,
319
+ input2,
320
+ output,
321
+ transition,
322
+ duration,
323
+ fit = "pad",
324
+ } = options;
325
+
326
+ if (!existsSync(input1) || !existsSync(input2)) {
327
+ throw new Error("input file not found");
328
+ }
329
+
330
+ console.log(`[ffmpeg] applying ${transition} transition...`);
331
+
332
+ const [info1, info2] = await Promise.all([
333
+ this.probe(input1),
334
+ this.probe(input2),
335
+ ]);
336
+
337
+ const video1Duration = info1.duration;
338
+ const offset = video1Duration - duration;
339
+ const needsScale =
340
+ info1.width !== info2.width || info1.height !== info2.height;
341
+
342
+ const transitionMap: Record<string, string> = {
343
+ crossfade: "fade",
344
+ dissolve: "dissolve",
345
+ wipeleft: "wipeleft",
346
+ wiperight: "wiperight",
347
+ slideup: "slideup",
348
+ slidedown: "slidedown",
349
+ };
350
+
351
+ const xfadeTransition = transitionMap[transition] || "fade";
352
+ const filters: string[] = [];
353
+
354
+ if (needsScale) {
355
+ filters.push(
356
+ buildScaleFilter(fit, info1.width, info1.height, "1:v", "v1scaled"),
357
+ );
358
+ filters.push(
359
+ `[0:v][v1scaled]xfade=transition=${xfadeTransition}:duration=${duration}:offset=${offset}[vout]`,
360
+ );
361
+ } else {
362
+ filters.push(
363
+ `[0:v][1:v]xfade=transition=${xfadeTransition}:duration=${duration}:offset=${offset}[vout]`,
364
+ );
365
+ }
366
+
367
+ const hasAudio1 = await this.hasAudioTrack(input1);
368
+ const hasAudio2 = await this.hasAudioTrack(input2);
369
+ const hasAudio = hasAudio1 && hasAudio2;
370
+
371
+ return new Promise((resolve, reject) => {
372
+ const command = ffmpeg().input(input1).input(input2);
373
+
374
+ const codecOptions = [
375
+ "-c:v",
376
+ "libx264",
377
+ "-preset",
378
+ "fast",
379
+ "-crf",
380
+ "22",
381
+ "-pix_fmt",
382
+ "yuv420p",
383
+ ];
384
+
385
+ if (hasAudio) {
386
+ filters.push(`[0:a][1:a]acrossfade=d=${duration}[aout]`);
387
+ command
388
+ .complexFilter(filters)
389
+ .outputOptions([
390
+ "-map",
391
+ "[vout]",
392
+ "-map",
393
+ "[aout]",
394
+ ...codecOptions,
395
+ "-c:a",
396
+ "aac",
397
+ "-b:a",
398
+ "192k",
399
+ ]);
400
+ } else {
401
+ command
402
+ .complexFilter(filters)
403
+ .outputOptions(["-map", "[vout]", ...codecOptions]);
404
+ }
405
+
406
+ command
407
+ .output(output)
408
+ .on("end", () => {
409
+ console.log(`[ffmpeg] saved to ${output}`);
410
+ resolve(output);
411
+ })
412
+ .on("error", reject)
413
+ .run();
414
+ });
415
+ }
416
+
417
+ async splitAtTimestamps(options: {
418
+ input: string;
419
+ timestamps: number[];
420
+ outputPrefix: string;
421
+ }): Promise<string[]> {
422
+ const { input, timestamps, outputPrefix } = options;
423
+
424
+ if (!existsSync(input)) {
425
+ throw new Error(`input file not found: ${input}`);
426
+ }
427
+
428
+ console.log(
429
+ `[ffmpeg] splitting video at ${timestamps.length} timestamps...`,
430
+ );
431
+
432
+ const videoDuration = await this.getVideoDuration(input);
433
+ const sortedTimestamps = [0, ...timestamps.sort((a, b) => a - b)];
434
+
435
+ const lastTimestamp = sortedTimestamps[sortedTimestamps.length - 1];
436
+ if (lastTimestamp !== undefined && lastTimestamp < videoDuration) {
437
+ sortedTimestamps.push(videoDuration);
438
+ }
439
+
440
+ const outputs: string[] = [];
441
+
442
+ for (let i = 0; i < sortedTimestamps.length - 1; i++) {
443
+ const start = sortedTimestamps[i];
444
+ const end = sortedTimestamps[i + 1];
445
+ if (start === undefined || end === undefined) continue;
446
+
447
+ const duration = end - start;
448
+ const partNumber = String(i + 1).padStart(3, "0");
449
+ const outputPath = `${outputPrefix}_${partNumber}.mp4`;
450
+
451
+ console.log(`[ffmpeg] extracting part ${i + 1}: ${start}s - ${end}s`);
452
+
453
+ await this.trimVideo({
454
+ input,
455
+ output: outputPath,
456
+ start,
457
+ duration,
458
+ });
459
+
460
+ outputs.push(outputPath);
461
+ }
462
+
463
+ console.log(`[ffmpeg] created ${outputs.length} parts`);
464
+ return outputs;
465
+ }
466
+
467
+ private async hasAudioTrack(input: string): Promise<boolean> {
468
+ return new Promise((resolve) => {
469
+ ffmpeg.ffprobe(input, (err, metadata) => {
470
+ if (err) {
471
+ resolve(false);
472
+ return;
473
+ }
474
+ const audioStream = metadata.streams.find(
475
+ (s) => s.codec_type === "audio",
476
+ );
477
+ resolve(!!audioStream);
478
+ });
479
+ });
480
+ }
481
+ }
482
+
483
+ // Types
484
+ export interface ProbeResult {
485
+ duration: number;
486
+ width: number;
487
+ height: number;
488
+ fps: number;
489
+ codec: string;
490
+ format: string;
491
+ }
492
+
493
+ // Helper function for scale filter
494
+ function buildScaleFilter(
495
+ fit: "pad" | "crop" | "blur" | "stretch",
496
+ targetW: number,
497
+ targetH: number,
498
+ inputLabel: string,
499
+ outputLabel: string,
500
+ ): string {
501
+ switch (fit) {
502
+ case "crop":
503
+ return `[${inputLabel}]scale=${targetW}:${targetH}:force_original_aspect_ratio=increase,crop=${targetW}:${targetH}[${outputLabel}]`;
504
+ case "stretch":
505
+ return `[${inputLabel}]scale=${targetW}:${targetH}[${outputLabel}]`;
506
+ case "blur":
507
+ return `[${inputLabel}]split[bg][fg];[bg]scale=${targetW}:${targetH},boxblur=20:20[bgblur];[fg]scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease[fgscaled];[bgblur][fgscaled]overlay=(W-w)/2:(H-h)/2[${outputLabel}]`;
508
+ default:
509
+ return `[${inputLabel}]scale=${targetW}:${targetH}:force_original_aspect_ratio=decrease,pad=${targetW}:${targetH}:(ow-iw)/2:(oh-ih)/2[${outputLabel}]`;
510
+ }
511
+ }
512
+
513
+ // Export singleton instance
514
+ export const ffmpegProvider = new FFmpegProvider();
515
+
516
+ // Re-export convenience functions for backward compatibility
517
+ export const probe = (input: string) => ffmpegProvider.probe(input);
518
+ export const getVideoDuration = (input: string) =>
519
+ ffmpegProvider.getVideoDuration(input);
520
+ export const concatVideos = (
521
+ options: Parameters<FFmpegProvider["concatVideos"]>[0],
522
+ ) => ffmpegProvider.concatVideos(options);
523
+ export const addAudio = (options: Parameters<FFmpegProvider["addAudio"]>[0]) =>
524
+ ffmpegProvider.addAudio(options);
525
+ export const resizeVideo = (
526
+ options: Parameters<FFmpegProvider["resizeVideo"]>[0],
527
+ ) => ffmpegProvider.resizeVideo(options);
528
+ export const trimVideo = (
529
+ options: Parameters<FFmpegProvider["trimVideo"]>[0],
530
+ ) => ffmpegProvider.trimVideo(options);
531
+ export const convertFormat = (
532
+ options: Parameters<FFmpegProvider["convertFormat"]>[0],
533
+ ) => ffmpegProvider.convertFormat(options);
534
+ export const extractAudio = (input: string, output: string) =>
535
+ ffmpegProvider.extractAudio(input, output);
536
+ export const fadeVideo = (
537
+ options: Parameters<FFmpegProvider["fadeVideo"]>[0],
538
+ ) => ffmpegProvider.fadeVideo(options);
539
+ export const xfadeVideos = (
540
+ options: Parameters<FFmpegProvider["xfadeVideos"]>[0],
541
+ ) => ffmpegProvider.xfadeVideos(options);
542
+ export const splitAtTimestamps = (
543
+ options: Parameters<FFmpegProvider["splitAtTimestamps"]>[0],
544
+ ) => ffmpegProvider.splitAtTimestamps(options);