vargai 0.4.0-alpha31 → 0.4.0-alpha33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -69,7 +69,7 @@
69
69
  "vargai": "^0.4.0-alpha11",
70
70
  "zod": "^4.2.1"
71
71
  },
72
- "version": "0.4.0-alpha31",
72
+ "version": "0.4.0-alpha33",
73
73
  "exports": {
74
74
  ".": "./src/index.ts",
75
75
  "./ai": "./src/ai-sdk/index.ts",
@@ -0,0 +1,7 @@
1
+ export { LocalBackend, localBackend } from "./local";
2
+ export type {
3
+ FFmpegBackend,
4
+ FFmpegRunOptions,
5
+ FFmpegRunResult,
6
+ VideoInfo,
7
+ } from "./types";
@@ -0,0 +1,65 @@
1
+ import { $ } from "bun";
2
+ import type {
3
+ FFmpegBackend,
4
+ FFmpegRunOptions,
5
+ FFmpegRunResult,
6
+ VideoInfo,
7
+ } from "./types";
8
+
9
+ const FFMPEG_COMMON_ARGS = ["-hide_banner", "-loglevel", "error"];
10
+
11
+ export class LocalBackend implements FFmpegBackend {
12
+ readonly name = "local";
13
+
14
+ async ffprobe(input: string): Promise<VideoInfo> {
15
+ const result =
16
+ await $`ffprobe -v error -show_entries stream=width,height,r_frame_rate,codec_type -show_entries format=duration -of json ${input}`.json();
17
+
18
+ const videoStream = result.streams?.find(
19
+ (s: { codec_type: string }) => s.codec_type === "video",
20
+ );
21
+ const parsedDuration = parseFloat(result.format?.duration ?? "0");
22
+ const duration = Number.isFinite(parsedDuration) ? parsedDuration : 0;
23
+
24
+ let fps: number | undefined;
25
+ const framerateStr: string | undefined = videoStream?.r_frame_rate;
26
+ if (framerateStr) {
27
+ const parts = framerateStr.split("/").map(Number);
28
+ const num = parts[0];
29
+ const den = parts[1];
30
+ if (den && den > 0 && num) fps = num / den;
31
+ }
32
+
33
+ return {
34
+ duration,
35
+ width: videoStream?.width,
36
+ height: videoStream?.height,
37
+ fps,
38
+ framerateStr,
39
+ };
40
+ }
41
+
42
+ async run(options: FFmpegRunOptions): Promise<FFmpegRunResult> {
43
+ const { args, outputPath, verbose } = options;
44
+
45
+ const ffmpegArgs = [
46
+ ...FFMPEG_COMMON_ARGS.slice(0, 2),
47
+ verbose ? "info" : "error",
48
+ ...args,
49
+ ];
50
+
51
+ if (verbose) {
52
+ console.log("ffmpeg", ffmpegArgs.join(" "));
53
+ }
54
+
55
+ const result = await $`ffmpeg ${ffmpegArgs}`.quiet();
56
+
57
+ if (result.exitCode !== 0) {
58
+ throw new Error(`ffmpeg failed with exit code ${result.exitCode}`);
59
+ }
60
+
61
+ return { output: { type: "file", path: outputPath } };
62
+ }
63
+ }
64
+
65
+ export const localBackend = new LocalBackend();
@@ -0,0 +1,54 @@
1
+ /**
2
+ * FFmpeg backend abstraction for dependency injection
3
+ * Allows switching between local ffmpeg and cloud services like Rendi
4
+ */
5
+
6
+ import type { VideoInfo } from "../types";
7
+
8
+ /**
9
+ * Represents the result of running ffprobe
10
+ */
11
+ export type { VideoInfo };
12
+
13
+ /**
14
+ * FFmpeg execution options
15
+ */
16
+ export interface FFmpegRunOptions {
17
+ /** ffmpeg arguments (without the 'ffmpeg' command itself) */
18
+ args: string[];
19
+ /** List of input file paths (local or URLs) */
20
+ inputs: string[];
21
+ /** Output file path */
22
+ outputPath: string;
23
+ /** Enable verbose logging */
24
+ verbose?: boolean;
25
+ }
26
+
27
+ export type FFmpegOutput =
28
+ | { type: "file"; path: string }
29
+ | { type: "url"; url: string };
30
+
31
+ export interface FFmpegRunResult {
32
+ output: FFmpegOutput;
33
+ }
34
+
35
+ /**
36
+ * Backend interface for ffmpeg/ffprobe execution
37
+ */
38
+ export interface FFmpegBackend {
39
+ /** Backend name for identification */
40
+ readonly name: string;
41
+
42
+ /**
43
+ * Run ffprobe to get media file info
44
+ * @param input - File path (local) or URL
45
+ */
46
+ ffprobe(input: string): Promise<VideoInfo>;
47
+
48
+ /**
49
+ * Run ffmpeg command
50
+ * @param options - Execution options including args, inputs, and output path
51
+ * @returns Result with optional URL for cloud backends
52
+ */
53
+ run(options: FFmpegRunOptions): Promise<FFmpegRunResult>;
54
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { existsSync, unlinkSync } from "node:fs";
3
- import { ffprobe } from "./ffmpeg";
3
+ import { localBackend } from "./backends/local";
4
4
  import { editly } from "./index";
5
5
 
6
6
  const VIDEO_1 = "output/sora-landscape.mp4";
@@ -9,6 +9,8 @@ const VIDEO_TALKING = "output/workflow-talking-synced.mp4";
9
9
  const IMAGE_SQUARE = "media/replicate-forest.png";
10
10
  const IMAGE_PORTRAIT = "media/madi-portrait.png";
11
11
 
12
+ const ffprobe = localBackend.ffprobe;
13
+
12
14
  describe("editly", () => {
13
15
  test("requires outPath", async () => {
14
16
  await expect(
@@ -1,5 +1,4 @@
1
- import { $ } from "bun";
2
- import { ffprobe, multipleOf2 } from "./ffmpeg";
1
+ import { type FFmpegBackend, localBackend } from "./backends";
3
2
  import {
4
3
  getImageOverlayFilter,
5
4
  getImageOverlayPositionFilter,
@@ -18,6 +17,7 @@ import type {
18
17
  Clip,
19
18
  DetachedAudioLayer,
20
19
  EditlyConfig,
20
+ EditlyResult,
21
21
  ImageOverlayLayer,
22
22
  Layer,
23
23
  NewsTitleLayer,
@@ -28,6 +28,7 @@ import type {
28
28
  VideoLayer,
29
29
  } from "./types";
30
30
 
31
+ export * from "./backends";
31
32
  export * from "./types";
32
33
 
33
34
  const DEFAULT_DURATION = 4;
@@ -36,12 +37,22 @@ const DEFAULT_FPS = 30;
36
37
  const DEFAULT_WIDTH = 1280;
37
38
  const DEFAULT_HEIGHT = 720;
38
39
 
39
- async function getVideoDuration(path: string): Promise<number> {
40
- const info = await ffprobe(path);
40
+ function multipleOf2(n: number): number {
41
+ return Math.round(n / 2) * 2;
42
+ }
43
+
44
+ async function getVideoDuration(
45
+ path: string,
46
+ backend: FFmpegBackend,
47
+ ): Promise<number> {
48
+ const info = await backend.ffprobe(path);
41
49
  return info.duration;
42
50
  }
43
51
 
44
- async function getFirstVideoInfo(clips: Clip[]): Promise<{
52
+ async function getFirstVideoInfo(
53
+ clips: Clip[],
54
+ backend: FFmpegBackend,
55
+ ): Promise<{
45
56
  width?: number;
46
57
  height?: number;
47
58
  fps?: number;
@@ -49,7 +60,7 @@ async function getFirstVideoInfo(clips: Clip[]): Promise<{
49
60
  for (const clip of clips) {
50
61
  for (const layer of clip.layers) {
51
62
  if (layer.type === "video") {
52
- const info = await ffprobe((layer as VideoLayer).path);
63
+ const info = await backend.ffprobe((layer as VideoLayer).path);
53
64
  return { width: info.width, height: info.height, fps: info.fps };
54
65
  }
55
66
  }
@@ -72,6 +83,7 @@ function applyLayerDefaults(
72
83
  async function processClips(
73
84
  clips: Clip[],
74
85
  defaults: EditlyConfig["defaults"],
86
+ backend: FFmpegBackend,
75
87
  ): Promise<ProcessedClip[]> {
76
88
  const processed: ProcessedClip[] = [];
77
89
  const defaultDuration = defaults?.duration ?? DEFAULT_DURATION;
@@ -86,7 +98,7 @@ async function processClips(
86
98
  for (const layer of layers) {
87
99
  if (layer.type === "video" && !clip.duration) {
88
100
  const videoLayer = layer as VideoLayer;
89
- const videoDuration = await getVideoDuration(videoLayer.path);
101
+ const videoDuration = await getVideoDuration(videoLayer.path, backend);
90
102
  const cutFrom = videoLayer.cutFrom ?? 0;
91
103
  const cutTo = videoLayer.cutTo ?? videoDuration;
92
104
  duration = cutTo - cutFrom;
@@ -522,7 +534,7 @@ function buildAudioFilter(
522
534
  };
523
535
  }
524
536
 
525
- export async function editly(config: EditlyConfig): Promise<void> {
537
+ export async function editly(config: EditlyConfig): Promise<EditlyResult> {
526
538
  const {
527
539
  outPath,
528
540
  clips: clipsIn,
@@ -539,11 +551,17 @@ export async function editly(config: EditlyConfig): Promise<void> {
539
551
  fast,
540
552
  } = config;
541
553
 
554
+ const backend: FFmpegBackend = config.backend ?? localBackend;
555
+
556
+ if (verbose) {
557
+ console.log(`[editly] using backend: ${backend.name}`);
558
+ }
559
+
542
560
  if (!clipsIn || clipsIn.length === 0) {
543
561
  throw new Error("At least one clip is required");
544
562
  }
545
563
 
546
- const firstVideoInfo = await getFirstVideoInfo(clipsIn);
564
+ const firstVideoInfo = await getFirstVideoInfo(clipsIn, backend);
547
565
  let width = config.width ?? firstVideoInfo.width ?? DEFAULT_WIDTH;
548
566
  let height = config.height ?? firstVideoInfo.height ?? DEFAULT_HEIGHT;
549
567
  const fps = config.fps ?? firstVideoInfo.fps ?? DEFAULT_FPS;
@@ -561,7 +579,7 @@ export async function editly(config: EditlyConfig): Promise<void> {
561
579
  console.log(`Output: ${width}x${height} @ ${fps}fps`);
562
580
  }
563
581
 
564
- const clips = await processClips(clipsIn, defaults);
582
+ const clips = await processClips(clipsIn, defaults, backend);
565
583
 
566
584
  const continuousVideoOverlays = collectContinuousVideoOverlays(clips);
567
585
  const imageOverlays = collectImageOverlays(clips);
@@ -862,13 +880,18 @@ export async function editly(config: EditlyConfig): Promise<void> {
862
880
  console.log("\nFilter complex:\n", filterComplex.split(";").join(";\n"));
863
881
  }
864
882
 
865
- const result = await $`ffmpeg ${ffmpegArgs}`.quiet();
883
+ const result = await backend.run({
884
+ args: ffmpegArgs,
885
+ inputs: allInputs,
886
+ outputPath: outPath,
887
+ verbose,
888
+ });
866
889
 
867
- if (result.exitCode !== 0) {
868
- throw new Error(`ffmpeg failed with exit code ${result.exitCode}`);
890
+ if (result.output.type === "file" && verbose) {
891
+ console.log(`Output: ${result.output.path}`);
869
892
  }
870
893
 
871
- console.log(`Output: ${outPath}`);
894
+ return { output: result.output };
872
895
  }
873
896
 
874
897
  export default editly;
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Rendi backend tests - same as editly.test.ts but uses cloud ffmpeg
3
+ *
4
+ * NOTE: Free tier has 4 commands/min rate limit. Run tests individually:
5
+ * bun test src/ai-sdk/providers/editly/rendi/editly-with-rendi-backend.test.ts -t "merges two"
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { $ } from "bun";
10
+ import { editly } from "../index";
11
+ import { createRendiBackend } from ".";
12
+
13
+ const shouldRunRendiTests =
14
+ !!process.env.RENDI_INTEGRATION_TESTS && !!process.env.RENDI_API_KEY;
15
+
16
+ const VIDEO_1 = "https://s3.varg.ai/test-media/sora-landscape.mp4";
17
+ const VIDEO_2 = "https://s3.varg.ai/test-media/simpsons-scene.mp4";
18
+ const VIDEO_TALKING =
19
+ "https://s3.varg.ai/test-media/workflow-talking-synced.mp4";
20
+ const IMAGE_SQUARE = "https://s3.varg.ai/test-media/replicate-forest.png";
21
+
22
+ const rendi = shouldRunRendiTests ? createRendiBackend() : (null as never);
23
+
24
+ async function saveResult(
25
+ result: {
26
+ output: { type: "url"; url: string } | { type: "file"; path: string };
27
+ },
28
+ outPath: string,
29
+ ) {
30
+ expect(result.output.type).toBe("url");
31
+ if (result.output.type === "url") {
32
+ expect(result.output.url).toMatch(/^https:\/\//);
33
+ const res = await fetch(result.output.url);
34
+ if (!res.ok) throw new Error(`Failed to download: ${res.status}`);
35
+
36
+ const dir = outPath.split("/").slice(0, -1).join("/");
37
+ await $`mkdir -p ${dir}`.quiet();
38
+
39
+ const bytes = await res.arrayBuffer();
40
+ await Bun.write(outPath, bytes);
41
+
42
+ const written = Bun.file(outPath);
43
+ if (!(await written.exists()) || written.size === 0) {
44
+ throw new Error(`Failed to write output file: ${outPath}`);
45
+ }
46
+ console.log(`Output: ${outPath}`);
47
+ }
48
+ }
49
+
50
+ describe.skipIf(!shouldRunRendiTests)("editly (rendi backend)", () => {
51
+ test("merges two videos with fade transition", async () => {
52
+ const outPath = "output/rendi/merge.mp4";
53
+ const result = await editly({
54
+ outPath,
55
+ backend: rendi,
56
+ width: 1280,
57
+ height: 720,
58
+ fps: 30,
59
+ clips: [
60
+ {
61
+ layers: [{ type: "video", path: VIDEO_1 }],
62
+ transition: { name: "fade", duration: 0.5 },
63
+ },
64
+ {
65
+ layers: [{ type: "video", path: VIDEO_2 }],
66
+ },
67
+ ],
68
+ });
69
+
70
+ await saveResult(result, outPath);
71
+ }, 120000);
72
+
73
+ test("picture-in-picture (pip)", async () => {
74
+ const outPath = "output/rendi/pip.mp4";
75
+ const result = await editly({
76
+ outPath,
77
+ backend: rendi,
78
+ width: 1280,
79
+ height: 720,
80
+ fps: 30,
81
+ clips: [
82
+ {
83
+ duration: 3,
84
+ layers: [
85
+ { type: "video", path: VIDEO_1 },
86
+ {
87
+ type: "video",
88
+ path: VIDEO_2,
89
+ width: "30%",
90
+ height: "30%",
91
+ left: "68%",
92
+ top: "2%",
93
+ },
94
+ ],
95
+ },
96
+ ],
97
+ });
98
+
99
+ await saveResult(result, outPath);
100
+ }, 120000);
101
+
102
+ test("image ken burns preserves aspect ratio", async () => {
103
+ const outPath = "output/rendi/ken-burns.mp4";
104
+ const result = await editly({
105
+ outPath,
106
+ backend: rendi,
107
+ width: 1280,
108
+ height: 720,
109
+ fps: 30,
110
+ clips: [
111
+ {
112
+ duration: 3,
113
+ layers: [
114
+ {
115
+ type: "image",
116
+ path: IMAGE_SQUARE,
117
+ zoomDirection: "in",
118
+ zoomAmount: 0.1,
119
+ resizeMode: "contain",
120
+ },
121
+ ],
122
+ },
123
+ ],
124
+ });
125
+
126
+ await saveResult(result, outPath);
127
+ }, 120000);
128
+
129
+ test("subtitle layer", async () => {
130
+ const outPath = "output/rendi/subtitle.mp4";
131
+ const result = await editly({
132
+ outPath,
133
+ backend: rendi,
134
+ width: 1280,
135
+ height: 720,
136
+ fps: 30,
137
+ clips: [
138
+ {
139
+ duration: 3,
140
+ layers: [
141
+ { type: "video", path: VIDEO_1 },
142
+ {
143
+ type: "subtitle",
144
+ text: "This is a subtitle at the bottom",
145
+ },
146
+ ],
147
+ },
148
+ {
149
+ duration: 3,
150
+ layers: [
151
+ { type: "video", path: VIDEO_2 },
152
+ {
153
+ type: "subtitle",
154
+ text: "Another subtitle with custom colors",
155
+ textColor: "yellow",
156
+ backgroundColor: "blue@0.8",
157
+ },
158
+ ],
159
+ },
160
+ ],
161
+ });
162
+
163
+ await saveResult(result, outPath);
164
+ }, 120000);
165
+
166
+ test("news-title layer", async () => {
167
+ const outPath = "output/rendi/news-title.mp4";
168
+ const result = await editly({
169
+ outPath,
170
+ backend: rendi,
171
+ width: 1280,
172
+ height: 720,
173
+ fps: 30,
174
+ clips: [
175
+ {
176
+ duration: 3,
177
+ layers: [
178
+ { type: "video", path: VIDEO_1 },
179
+ {
180
+ type: "news-title",
181
+ text: "BREAKING NEWS: Something important happened",
182
+ backgroundColor: "red",
183
+ },
184
+ ],
185
+ },
186
+ {
187
+ duration: 3,
188
+ layers: [
189
+ { type: "video", path: VIDEO_2 },
190
+ {
191
+ type: "news-title",
192
+ text: "TOP STORY",
193
+ backgroundColor: "blue",
194
+ position: "top",
195
+ },
196
+ ],
197
+ },
198
+ ],
199
+ });
200
+
201
+ await saveResult(result, outPath);
202
+ }, 120000);
203
+
204
+ test("keepSourceAudio preserves original video audio", async () => {
205
+ const outPath = "output/rendi/keep-audio.mp4";
206
+ const result = await editly({
207
+ outPath,
208
+ backend: rendi,
209
+ width: 1280,
210
+ height: 720,
211
+ fps: 30,
212
+ keepSourceAudio: true,
213
+ clips: [
214
+ {
215
+ layers: [
216
+ { type: "video", path: VIDEO_TALKING },
217
+ { type: "subtitle", text: "Original audio should play" },
218
+ ],
219
+ },
220
+ ],
221
+ });
222
+
223
+ await saveResult(result, outPath);
224
+ }, 120000);
225
+
226
+ test("keepSourceAudio with cutFrom stays in sync", async () => {
227
+ const outPath = "output/rendi/keep-audio-cut.mp4";
228
+ const result = await editly({
229
+ outPath,
230
+ backend: rendi,
231
+ width: 1280,
232
+ height: 720,
233
+ fps: 30,
234
+ keepSourceAudio: true,
235
+ clips: [
236
+ {
237
+ layers: [
238
+ { type: "video", path: VIDEO_TALKING, cutFrom: 2, cutTo: 6 },
239
+ ],
240
+ },
241
+ ],
242
+ });
243
+
244
+ await saveResult(result, outPath);
245
+ }, 120000);
246
+
247
+ test("contain-blur resize mode for video", async () => {
248
+ const outPath = "output/rendi/contain-blur.mp4";
249
+ const result = await editly({
250
+ outPath,
251
+ backend: rendi,
252
+ width: 1080,
253
+ height: 1920,
254
+ fps: 30,
255
+ clips: [
256
+ {
257
+ duration: 3,
258
+ layers: [
259
+ { type: "video", path: VIDEO_1, resizeMode: "contain-blur" },
260
+ ],
261
+ },
262
+ ],
263
+ });
264
+
265
+ await saveResult(result, outPath);
266
+ }, 120000);
267
+
268
+ test("video overlay with cropPosition", async () => {
269
+ const outPath = "output/rendi/crop-position.mp4";
270
+ const result = await editly({
271
+ outPath,
272
+ backend: rendi,
273
+ width: 1080,
274
+ height: 1920,
275
+ fps: 30,
276
+ clips: [
277
+ {
278
+ duration: 3,
279
+ layers: [
280
+ { type: "fill-color", color: "#000000" },
281
+ {
282
+ type: "video",
283
+ path: VIDEO_1,
284
+ width: 1080,
285
+ height: 960,
286
+ left: 0,
287
+ top: 0,
288
+ resizeMode: "cover",
289
+ cropPosition: "top",
290
+ },
291
+ {
292
+ type: "video",
293
+ path: VIDEO_2,
294
+ width: 1080,
295
+ height: 960,
296
+ left: 0,
297
+ top: 960,
298
+ resizeMode: "cover",
299
+ cropPosition: "bottom",
300
+ },
301
+ ],
302
+ },
303
+ ],
304
+ });
305
+
306
+ await saveResult(result, outPath);
307
+ }, 120000);
308
+
309
+ test("portrait 9:16 image with zoompan cover mode", async () => {
310
+ const outPath = "output/rendi/portrait-zoompan.mp4";
311
+ const result = await editly({
312
+ outPath,
313
+ backend: rendi,
314
+ width: 1080,
315
+ height: 1920,
316
+ fps: 30,
317
+ clips: [
318
+ {
319
+ duration: 3,
320
+ layers: [
321
+ {
322
+ type: "image",
323
+ path: IMAGE_SQUARE,
324
+ zoomDirection: "in",
325
+ zoomAmount: 0.1,
326
+ resizeMode: "cover",
327
+ },
328
+ ],
329
+ },
330
+ ],
331
+ });
332
+
333
+ await saveResult(result, outPath);
334
+ }, 120000);
335
+ });
@@ -0,0 +1,277 @@
1
+ import type {
2
+ FFmpegBackend,
3
+ FFmpegRunOptions,
4
+ FFmpegRunResult,
5
+ VideoInfo,
6
+ } from "../backends/types";
7
+
8
+ const RENDI_API_BASE = "https://api.rendi.dev/v1";
9
+ const POLL_INTERVAL_MS = 2000;
10
+ const MAX_POLL_ATTEMPTS = 300;
11
+ const DEFAULT_MAX_COMMAND_SECONDS = 60;
12
+
13
+ interface RendiCommandResponse {
14
+ command_id: string;
15
+ }
16
+
17
+ interface RendiStoredFile {
18
+ file_id: string;
19
+ storage_url: string | null;
20
+ status: string;
21
+ duration?: number;
22
+ width?: number;
23
+ height?: number;
24
+ frame_rate?: number;
25
+ }
26
+
27
+ interface RendiStatusResponse {
28
+ command_id: string;
29
+ status: "QUEUED" | "PROCESSING" | "SUCCESS" | "FAILED";
30
+ error_message?: string;
31
+ output_files?: Record<string, RendiStoredFile>;
32
+ }
33
+
34
+ export class RendiBackend implements FFmpegBackend {
35
+ readonly name = "rendi";
36
+ private apiKey: string;
37
+
38
+ constructor(apiKey?: string) {
39
+ this.apiKey = apiKey ?? process.env.RENDI_API_KEY ?? "";
40
+ if (!this.apiKey) {
41
+ throw new Error("RENDI_API_KEY is required for Rendi backend");
42
+ }
43
+ }
44
+
45
+ async ffprobe(input: string): Promise<VideoInfo> {
46
+ const inputUrl = this.ensureUrl(input);
47
+
48
+ const submitResponse = await fetch(`${RENDI_API_BASE}/run-ffmpeg-command`, {
49
+ method: "POST",
50
+ headers: {
51
+ "X-API-KEY": this.apiKey,
52
+ "Content-Type": "application/json",
53
+ },
54
+ body: JSON.stringify({
55
+ input_files: { in_1: inputUrl },
56
+ output_files: { out_1: "probe.mp4" },
57
+ ffmpeg_command: "-i {{in_1}} -c copy {{out_1}}",
58
+ max_command_run_seconds: DEFAULT_MAX_COMMAND_SECONDS,
59
+ }),
60
+ });
61
+
62
+ if (!submitResponse.ok) {
63
+ throw new Error(`Rendi ffprobe failed: ${submitResponse.status}`);
64
+ }
65
+
66
+ const { command_id } =
67
+ (await submitResponse.json()) as RendiCommandResponse;
68
+
69
+ let attempts = 0;
70
+ while (attempts < MAX_POLL_ATTEMPTS) {
71
+ const statusResponse = await fetch(
72
+ `${RENDI_API_BASE}/commands/${command_id}`,
73
+ { headers: { "X-API-KEY": this.apiKey } },
74
+ );
75
+
76
+ if (!statusResponse.ok) {
77
+ throw new Error(`Rendi ffprobe poll failed: ${statusResponse.status}`);
78
+ }
79
+
80
+ const status = (await statusResponse.json()) as RendiStatusResponse;
81
+
82
+ if (status.status === "SUCCESS") {
83
+ const output = status.output_files?.out_1;
84
+ if (!output) {
85
+ throw new Error("rendi ffprobe completed but no output metadata");
86
+ }
87
+ return {
88
+ duration: output.duration ?? 0,
89
+ width: output.width,
90
+ height: output.height,
91
+ fps: output.frame_rate,
92
+ };
93
+ }
94
+
95
+ if (status.status === "FAILED") {
96
+ throw new Error(`Rendi ffprobe failed: ${status.error_message}`);
97
+ }
98
+
99
+ await this.sleep(POLL_INTERVAL_MS);
100
+ attempts++;
101
+ }
102
+
103
+ throw new Error("Rendi ffprobe timed out");
104
+ }
105
+
106
+ async run(options: FFmpegRunOptions): Promise<FFmpegRunResult> {
107
+ const { args, inputs, outputPath, verbose } = options;
108
+
109
+ const uniqueInputs = [...new Set(inputs)];
110
+ const inputUrls = uniqueInputs.map((input) => this.ensureUrl(input));
111
+
112
+ const inputFiles: Record<string, string> = {};
113
+ const pathToPlaceholder = new Map<string, string>();
114
+
115
+ for (let i = 0; i < uniqueInputs.length; i++) {
116
+ const placeholder = `in_${i + 1}`;
117
+ inputFiles[placeholder] = inputUrls[i]!;
118
+ pathToPlaceholder.set(uniqueInputs[i]!, `{{${placeholder}}}`);
119
+ }
120
+
121
+ const commandArgs = args.map((arg) => {
122
+ if (arg === outputPath) {
123
+ return "{{out_1}}";
124
+ }
125
+ const placeholder = pathToPlaceholder.get(arg);
126
+ if (placeholder) {
127
+ return placeholder;
128
+ }
129
+ let result = arg;
130
+ for (const [url, ph] of pathToPlaceholder) {
131
+ if (result.includes(url)) {
132
+ result = result.replaceAll(url, ph);
133
+ }
134
+ }
135
+ return result;
136
+ });
137
+
138
+ const filteredArgs = this.stripInternalFlags(commandArgs);
139
+ const ffmpegCommand = this.buildCommandString(filteredArgs);
140
+
141
+ const outputFilename = outputPath?.split("/").pop() ?? "output.mp4";
142
+ const finalCommand = ffmpegCommand.includes("{{out_1}}")
143
+ ? ffmpegCommand
144
+ : ffmpegCommand.replace(/[^\s]+\.\w+$/, "{{out_1}}");
145
+
146
+ if (verbose) {
147
+ console.log("[rendi] input_files:", inputFiles);
148
+ console.log("[rendi] ffmpeg_command:", finalCommand);
149
+ }
150
+
151
+ const submitResponse = await fetch(`${RENDI_API_BASE}/run-ffmpeg-command`, {
152
+ method: "POST",
153
+ headers: {
154
+ "X-API-KEY": this.apiKey,
155
+ "Content-Type": "application/json",
156
+ },
157
+ body: JSON.stringify({
158
+ input_files: inputFiles,
159
+ output_files: { out_1: outputFilename },
160
+ ffmpeg_command: finalCommand,
161
+ max_command_run_seconds: DEFAULT_MAX_COMMAND_SECONDS,
162
+ }),
163
+ });
164
+
165
+ if (!submitResponse.ok) {
166
+ const errorText = await submitResponse.text();
167
+ throw new Error(
168
+ `Rendi submit failed: ${submitResponse.status} - ${errorText}`,
169
+ );
170
+ }
171
+
172
+ const { command_id } =
173
+ (await submitResponse.json()) as RendiCommandResponse;
174
+
175
+ if (verbose) {
176
+ console.log("[rendi] command_id:", command_id);
177
+ }
178
+
179
+ let attempts = 0;
180
+ while (attempts < MAX_POLL_ATTEMPTS) {
181
+ const statusResponse = await fetch(
182
+ `${RENDI_API_BASE}/commands/${command_id}`,
183
+ {
184
+ headers: { "X-API-KEY": this.apiKey },
185
+ },
186
+ );
187
+
188
+ if (!statusResponse.ok) {
189
+ throw new Error(`Rendi poll failed: ${statusResponse.status}`);
190
+ }
191
+
192
+ const status = (await statusResponse.json()) as RendiStatusResponse;
193
+
194
+ if (verbose && attempts % 5 === 0) {
195
+ console.log("[rendi] status:", status.status);
196
+ }
197
+
198
+ if (status.status === "SUCCESS") {
199
+ const outputFile = status.output_files?.out_1;
200
+ if (!outputFile?.storage_url) {
201
+ throw new Error("Rendi completed but no output URL found");
202
+ }
203
+
204
+ if (verbose) {
205
+ console.log("[rendi] output url:", outputFile.storage_url);
206
+ }
207
+
208
+ return { output: { type: "url", url: outputFile.storage_url } };
209
+ }
210
+
211
+ if (status.status === "FAILED") {
212
+ throw new Error(
213
+ `Rendi command failed: ${status.error_message ?? "Unknown error"}`,
214
+ );
215
+ }
216
+
217
+ await this.sleep(POLL_INTERVAL_MS);
218
+ attempts++;
219
+ }
220
+
221
+ throw new Error("Rendi command timed out");
222
+ }
223
+
224
+ private ensureUrl(input: string): string {
225
+ if (input.startsWith("http://") || input.startsWith("https://")) {
226
+ return input;
227
+ }
228
+ throw new Error(`Rendi backend requires URLs, got local path: ${input}`);
229
+ }
230
+
231
+ private stripInternalFlags(args: string[]): string[] {
232
+ const filtered: string[] = [];
233
+ let skipNext = false;
234
+
235
+ for (const arg of args) {
236
+ if (skipNext) {
237
+ skipNext = false;
238
+ continue;
239
+ }
240
+
241
+ if (arg === "-hide_banner") continue;
242
+ if (arg === "-y") continue;
243
+ if (arg === "-loglevel") {
244
+ skipNext = true;
245
+ continue;
246
+ }
247
+
248
+ filtered.push(arg);
249
+ }
250
+
251
+ return filtered;
252
+ }
253
+
254
+ private buildCommandString(args: string[]): string {
255
+ return args
256
+ .map((arg) => {
257
+ if (arg.startsWith("-") || arg.startsWith("{{")) {
258
+ return arg;
259
+ }
260
+ if (arg.includes(" ") || arg.includes(":") || arg.includes("'")) {
261
+ return `"${arg.replace(/"/g, '\\"')}"`;
262
+ }
263
+ return arg;
264
+ })
265
+ .join(" ");
266
+ }
267
+
268
+ private sleep(ms: number): Promise<void> {
269
+ return new Promise((resolve) => setTimeout(resolve, ms));
270
+ }
271
+ }
272
+
273
+ export function createRendiBackend(apiKey?: string): RendiBackend {
274
+ return new RendiBackend(apiKey);
275
+ }
276
+
277
+ export type { FFmpegBackend } from "../backends/types";
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createRendiBackend } from ".";
3
+
4
+ const hasRendiKey = !!process.env.RENDI_API_KEY;
5
+
6
+ describe.skipIf(!hasRendiKey)("rendi backend", () => {
7
+ test("ffprobe remote file", async () => {
8
+ const backend = createRendiBackend();
9
+ const info = await backend.ffprobe(
10
+ "https://storage.rendi.dev/sample/big_buck_bunny_720p_5sec_intro.mp4",
11
+ );
12
+
13
+ expect(info.duration).toBeGreaterThan(0);
14
+ expect(info.width).toBe(1280);
15
+ expect(info.height).toBe(720);
16
+ }, 30000);
17
+
18
+ test("run simple ffmpeg command", async () => {
19
+ const backend = createRendiBackend();
20
+
21
+ const result = await backend.run({
22
+ args: [
23
+ "-i",
24
+ "https://storage.rendi.dev/sample/big_buck_bunny_720p_5sec_intro.mp4",
25
+ "-t",
26
+ "2",
27
+ "-c:v",
28
+ "libx264",
29
+ "-preset",
30
+ "ultrafast",
31
+ "-y",
32
+ "output.mp4",
33
+ ],
34
+ inputs: [
35
+ "https://storage.rendi.dev/sample/big_buck_bunny_720p_5sec_intro.mp4",
36
+ ],
37
+ outputPath: "output.mp4",
38
+ verbose: true,
39
+ });
40
+
41
+ expect(result.output.type).toBe("url");
42
+ if (result.output.type === "url") {
43
+ expect(result.output.url).toMatch(/^https:\/\//);
44
+ }
45
+ }, 120000);
46
+ });
@@ -1,6 +1,8 @@
1
1
  // Types from original editly (https://github.com/mifi/editly)
2
2
  // Adapted for pure ffmpeg implementation (no fabric/canvas/gl dependencies)
3
3
 
4
+ import type { FFmpegBackend } from "./backends";
5
+
4
6
  export type OriginX = "left" | "center" | "right";
5
7
  export type OriginY = "top" | "center" | "bottom";
6
8
  export type SizeValue = number | `${number}%` | `${number}px`;
@@ -327,6 +329,16 @@ export interface EditlyConfig {
327
329
  enableFfmpegLog?: boolean;
328
330
  /** End output when shortest stream ends (video or audio) */
329
331
  shortest?: boolean;
332
+ /** FFmpeg backend for execution (defaults to local ffmpeg) */
333
+ backend?: FFmpegBackend;
334
+ }
335
+
336
+ export type EditlyOutput =
337
+ | { type: "file"; path: string }
338
+ | { type: "url"; url: string };
339
+
340
+ export interface EditlyResult {
341
+ output: EditlyOutput;
330
342
  }
331
343
 
332
344
  // Internal types used by our implementation
@@ -0,0 +1,107 @@
1
+ import { localBackend } from "@/ai-sdk/providers/editly";
2
+ import type {
3
+ FFmpegBackend,
4
+ FFmpegOutput,
5
+ } from "../../ai-sdk/providers/editly/backends/types";
6
+ import { uploadBuffer } from "../../providers/storage";
7
+
8
+ /**
9
+ * Resolves an FFmpegOutput to a string path/URL, uploading local files if needed.
10
+ *
11
+ * - URL input → returns URL as-is
12
+ * - File input + shouldUpload=false → returns local path
13
+ * - File input + shouldUpload=true → uploads to storage, returns URL
14
+ */
15
+ async function resolveInputPathMaybeUpload(
16
+ input: FFmpegOutput,
17
+ options: { shouldUpload: boolean },
18
+ ): Promise<string> {
19
+ if (input.type === "url") return input.url;
20
+ if (!options.shouldUpload) return input.path;
21
+
22
+ const buffer = await Bun.file(input.path).arrayBuffer();
23
+ return uploadBuffer(
24
+ buffer,
25
+ `tmp/${Date.now()}-${input.path.split("/").pop()}`,
26
+ "application/octet-stream",
27
+ );
28
+ }
29
+
30
+ export interface CaptionOverlayOptions {
31
+ video: FFmpegOutput;
32
+ assPath: string;
33
+ outputPath: string;
34
+ backend?: FFmpegBackend;
35
+ verbose?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Burns ASS subtitle captions onto a video using FFmpeg.
40
+ *
41
+ * {@link burnCaptions} composites the subtitle file directly into the video frames,
42
+ * producing a new video with hardcoded captions. Supports both local and cloud
43
+ * FFmpeg backends - when using a cloud backend, input files are automatically
44
+ * uploaded to storage.
45
+ *
46
+ * @param options - Configuration for the caption burn operation
47
+ * @param options.video - Source video as {@link FFmpegOutput} (file path or URL)
48
+ * @param options.assPath - Path to the ASS subtitle file to burn
49
+ * @param options.outputPath - Destination path for the output video (defaults to "output.mp4")
50
+ * @param options.backend - Optional {@link FFmpegBackend} for cloud processing; uses local FFmpeg if omitted
51
+ * @param options.verbose - Enable verbose FFmpeg logging
52
+ *
53
+ * @returns Promise resolving to {@link FFmpegOutput} containing the path or URL of the captioned video
54
+ *
55
+ * @throws May throw if FFmpeg execution fails, input files are missing, or upload fails for cloud backends
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * const result = await burnCaptions({
60
+ * video: { type: "file", path: "input.mp4" },
61
+ * assPath: "captions.ass",
62
+ * outputPath: "output-with-captions.mp4",
63
+ * });
64
+ * ```
65
+ */
66
+ export async function burnCaptions(
67
+ options: CaptionOverlayOptions,
68
+ ): Promise<FFmpegOutput> {
69
+ const { video, assPath, outputPath = "output.mp4", verbose } = options;
70
+ const captions: FFmpegOutput = { type: "file", path: assPath };
71
+
72
+ const isCloud = options.backend !== undefined;
73
+
74
+ const videoInput = await resolveInputPathMaybeUpload(video, {
75
+ shouldUpload: isCloud,
76
+ });
77
+ const assInput = await resolveInputPathMaybeUpload(captions, {
78
+ shouldUpload: isCloud,
79
+ });
80
+
81
+ const backend = options.backend ?? localBackend;
82
+
83
+ // FFmpeg filter syntax requires escaping backslashes and colons
84
+ const escapedAssPath = assInput.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
85
+
86
+ const result = await backend.run({
87
+ args: [
88
+ "-i",
89
+ videoInput,
90
+ "-vf",
91
+ `subtitles=${escapedAssPath}`,
92
+ "-crf",
93
+ "18",
94
+ "-preset",
95
+ "fast",
96
+ "-c:a",
97
+ "copy",
98
+ "-y",
99
+ outputPath,
100
+ ],
101
+ inputs: [videoInput, assInput],
102
+ outputPath,
103
+ verbose,
104
+ });
105
+
106
+ return result.output;
107
+ }
@@ -14,6 +14,7 @@ import type {
14
14
  Layer,
15
15
  VideoLayer,
16
16
  } from "../../ai-sdk/providers/editly/types";
17
+
17
18
  import type {
18
19
  CaptionsProps,
19
20
  ClipProps,
@@ -25,6 +26,7 @@ import type {
25
26
  SpeechProps,
26
27
  VargElement,
27
28
  } from "../types";
29
+ import { burnCaptions } from "./burn-captions";
28
30
  import { renderCaptions } from "./captions";
29
31
  import { renderClip } from "./clip";
30
32
  import type { RenderContext } from "./context";
@@ -271,7 +273,7 @@ export async function renderRoot(
271
273
  const editlyTaskId = addTask(progress, "editly", "ffmpeg");
272
274
  startTask(progress, editlyTaskId);
273
275
 
274
- await editly({
276
+ const editlyResult = await editly({
275
277
  outPath: tempOutPath,
276
278
  width: ctx.width,
277
279
  height: ctx.height,
@@ -280,27 +282,50 @@ export async function renderRoot(
280
282
  audioTracks: audioTracks.length > 0 ? audioTracks : undefined,
281
283
  shortest: props.shortest,
282
284
  verbose: options.verbose,
285
+ backend: options.backend,
283
286
  });
284
287
 
285
288
  completeTask(progress, editlyTaskId);
286
289
 
290
+ let output = editlyResult.output;
291
+
287
292
  if (hasCaptions && captionsResult) {
288
293
  const captionsTaskId = addTask(progress, "captions", "ffmpeg");
289
294
  startTask(progress, captionsTaskId);
290
295
 
291
- const { $ } = await import("bun");
292
- await $`ffmpeg -y -i ${tempOutPath} -vf "ass=${captionsResult.assPath}" -crf 18 -preset slow -c:a copy ${finalOutPath}`.quiet();
296
+ output = await burnCaptions({
297
+ video: output,
298
+ assPath: captionsResult.assPath,
299
+ outputPath: finalOutPath,
300
+ backend: options.backend,
301
+ verbose: options.verbose,
302
+ });
303
+
304
+ if (!options.backend) {
305
+ ctx.tempFiles.push(tempOutPath);
306
+ }
293
307
 
294
- ctx.tempFiles.push(tempOutPath);
295
308
  completeTask(progress, captionsTaskId);
296
309
  }
297
310
 
311
+ let finalBuffer: ArrayBuffer;
312
+ if (output.type === "url") {
313
+ const res = await fetch(output.url);
314
+ if (!res.ok)
315
+ throw new Error(`Failed to download final render: ${res.status}`);
316
+ finalBuffer = await res.arrayBuffer();
317
+ if (options.output) {
318
+ await Bun.write(options.output, finalBuffer);
319
+ }
320
+ } else {
321
+ finalBuffer = await Bun.file(output.path).arrayBuffer();
322
+ }
323
+
298
324
  if (!options.quiet && mode === "preview" && placeholderCount.total > 0) {
299
325
  console.log(
300
326
  `\x1b[36mℹ preview mode: ${placeholderCount.total} placeholders used (${placeholderCount.images} images, ${placeholderCount.videos} videos)\x1b[0m`,
301
327
  );
302
328
  }
303
329
 
304
- const result = await Bun.file(finalOutPath).arrayBuffer();
305
- return new Uint8Array(result);
330
+ return new Uint8Array(finalBuffer);
306
331
  }
@@ -1,4 +1,5 @@
1
1
  import type { ImageModelV3, SpeechModelV3 } from "@ai-sdk/provider";
2
+ import type { FFmpegBackend } from "@/ai-sdk/providers/editly/backends";
2
3
  import type { MusicModelV3 } from "../ai-sdk/music-model";
3
4
  import type {
4
5
  CropPosition,
@@ -260,6 +261,7 @@ export interface RenderOptions {
260
261
  verbose?: boolean;
261
262
  mode?: RenderMode;
262
263
  defaults?: DefaultModels;
264
+ backend?: FFmpegBackend;
263
265
  }
264
266
 
265
267
  export interface ElementPropsMap {
@@ -1,60 +0,0 @@
1
- import { $ } from "bun";
2
- import type { VideoInfo } from "./types";
3
-
4
- const FFMPEG_COMMON_ARGS = ["-hide_banner", "-loglevel", "error"];
5
-
6
- export async function ffmpeg(
7
- args: string[],
8
- options?: { stdin?: "pipe" | "ignore"; stdout?: "pipe" | "inherit" },
9
- ): Promise<{ stdout: Buffer; exitCode: number }> {
10
- const proc = Bun.spawn(["ffmpeg", ...FFMPEG_COMMON_ARGS, ...args], {
11
- stdin: options?.stdin ?? "ignore",
12
- stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
13
- stderr: "inherit",
14
- });
15
-
16
- const stdout =
17
- options?.stdout === "inherit"
18
- ? Buffer.alloc(0)
19
- : Buffer.from(await new Response(proc.stdout).arrayBuffer());
20
- const exitCode = await proc.exited;
21
-
22
- return { stdout, exitCode };
23
- }
24
-
25
- export async function ffprobe(path: string): Promise<VideoInfo> {
26
- const result =
27
- await $`ffprobe -v error -show_entries stream=width,height,r_frame_rate,codec_type -show_entries format=duration -of json ${path}`.json();
28
-
29
- const videoStream = result.streams?.find(
30
- (s: { codec_type: string }) => s.codec_type === "video",
31
- );
32
- const duration = parseFloat(result.format?.duration ?? "0");
33
-
34
- let fps: number | undefined;
35
- const framerateStr: string | undefined = videoStream?.r_frame_rate;
36
- if (framerateStr) {
37
- const parts = framerateStr.split("/").map(Number);
38
- const num = parts[0];
39
- const den = parts[1];
40
- if (den && den > 0 && num) fps = num / den;
41
- }
42
-
43
- return {
44
- duration,
45
- width: videoStream?.width,
46
- height: videoStream?.height,
47
- fps,
48
- framerateStr,
49
- };
50
- }
51
-
52
- export async function readDuration(path: string): Promise<number> {
53
- const result =
54
- await $`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 ${path}`.text();
55
- return parseFloat(result.trim());
56
- }
57
-
58
- export function multipleOf2(n: number): number {
59
- return Math.round(n / 2) * 2;
60
- }