vargai 0.4.0-alpha4 → 0.4.0-alpha40

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 (114) hide show
  1. package/.env.example +6 -0
  2. package/README.md +483 -61
  3. package/assets/fonts/TikTokSans-Bold.ttf +0 -0
  4. package/examples/grok-imagine-test.tsx +155 -0
  5. package/launch-videos/06-kawaii-fruits.tsx +93 -0
  6. package/launch-videos/07-ugc-weight-loss.tsx +132 -0
  7. package/launch-videos/08-talking-head-varg.tsx +107 -0
  8. package/launch-videos/09-girl.tsx +160 -0
  9. package/launch-videos/README.md +42 -0
  10. package/package.json +10 -4
  11. package/pipeline/cookbooks/round-video-character.md +1 -1
  12. package/skills/varg-video-generation/SKILL.md +224 -0
  13. package/skills/varg-video-generation/references/templates.md +380 -0
  14. package/skills/varg-video-generation/scripts/setup.ts +265 -0
  15. package/src/ai-sdk/cache.ts +1 -3
  16. package/src/ai-sdk/examples/google-image.ts +62 -0
  17. package/src/ai-sdk/index.ts +10 -0
  18. package/src/ai-sdk/middleware/wrap-image-model.ts +4 -21
  19. package/src/ai-sdk/middleware/wrap-music-model.ts +4 -16
  20. package/src/ai-sdk/middleware/wrap-video-model.ts +5 -17
  21. package/src/ai-sdk/providers/CONTRIBUTING.md +457 -0
  22. package/src/ai-sdk/providers/editly/backends/index.ts +8 -0
  23. package/src/ai-sdk/providers/editly/backends/local.ts +94 -0
  24. package/src/ai-sdk/providers/editly/backends/types.ts +74 -0
  25. package/src/ai-sdk/providers/editly/editly.test.ts +49 -1
  26. package/src/ai-sdk/providers/editly/index.ts +164 -80
  27. package/src/ai-sdk/providers/editly/layers.ts +58 -6
  28. package/src/ai-sdk/providers/editly/rendi/editly-with-rendi-backend.test.ts +335 -0
  29. package/src/ai-sdk/providers/editly/rendi/index.ts +289 -0
  30. package/src/ai-sdk/providers/editly/rendi/rendi.test.ts +35 -0
  31. package/src/ai-sdk/providers/editly/types.ts +30 -0
  32. package/src/ai-sdk/providers/elevenlabs.ts +10 -2
  33. package/src/ai-sdk/providers/fal.test.ts +214 -0
  34. package/src/ai-sdk/providers/fal.ts +435 -40
  35. package/src/ai-sdk/providers/google.ts +423 -0
  36. package/src/ai-sdk/providers/together.ts +191 -0
  37. package/src/cli/commands/find.tsx +1 -0
  38. package/src/cli/commands/frame.tsx +616 -0
  39. package/src/cli/commands/hello.ts +85 -0
  40. package/src/cli/commands/help.tsx +18 -30
  41. package/src/cli/commands/index.ts +11 -2
  42. package/src/cli/commands/init.tsx +570 -0
  43. package/src/cli/commands/list.tsx +1 -0
  44. package/src/cli/commands/render.tsx +322 -76
  45. package/src/cli/commands/run.tsx +1 -0
  46. package/src/cli/commands/storyboard.tsx +1714 -0
  47. package/src/cli/commands/which.tsx +1 -0
  48. package/src/cli/index.ts +23 -4
  49. package/src/cli/ui/components/Badge.tsx +1 -0
  50. package/src/cli/ui/components/DataTable.tsx +1 -0
  51. package/src/cli/ui/components/Header.tsx +1 -0
  52. package/src/cli/ui/components/HelpBlock.tsx +1 -0
  53. package/src/cli/ui/components/KeyValue.tsx +1 -0
  54. package/src/cli/ui/components/OptionRow.tsx +1 -0
  55. package/src/cli/ui/components/Separator.tsx +1 -0
  56. package/src/cli/ui/components/StatusBox.tsx +1 -0
  57. package/src/cli/ui/components/VargBox.tsx +1 -0
  58. package/src/cli/ui/components/VargProgress.tsx +1 -0
  59. package/src/cli/ui/components/VargSpinner.tsx +1 -0
  60. package/src/cli/ui/components/VargText.tsx +1 -0
  61. package/src/definitions/actions/grok-edit.ts +133 -0
  62. package/src/definitions/actions/index.ts +16 -0
  63. package/src/definitions/actions/qwen-angles.ts +218 -0
  64. package/src/index.ts +1 -0
  65. package/src/providers/fal.ts +196 -0
  66. package/src/react/assets.ts +9 -0
  67. package/src/react/elements.ts +0 -5
  68. package/src/react/examples/branching.tsx +6 -4
  69. package/src/react/examples/character-video.tsx +13 -10
  70. package/src/react/examples/local-files-test.tsx +19 -0
  71. package/src/react/examples/ltx2-test.tsx +25 -0
  72. package/src/react/examples/madi.tsx +13 -10
  73. package/src/react/examples/mcmeows.tsx +40 -0
  74. package/src/react/examples/music-defaults.tsx +24 -0
  75. package/src/react/examples/quickstart-test.tsx +101 -0
  76. package/src/react/examples/qwen-angles-test.tsx +72 -0
  77. package/src/react/index.ts +3 -3
  78. package/src/react/layouts/grid.tsx +1 -1
  79. package/src/react/layouts/index.ts +2 -1
  80. package/src/react/layouts/slot.tsx +85 -0
  81. package/src/react/layouts/split.tsx +18 -0
  82. package/src/react/react.test.ts +60 -11
  83. package/src/react/renderers/burn-captions.ts +95 -0
  84. package/src/react/renderers/cache.test.ts +182 -0
  85. package/src/react/renderers/captions.ts +25 -6
  86. package/src/react/renderers/clip.ts +56 -25
  87. package/src/react/renderers/context.ts +5 -2
  88. package/src/react/renderers/image.ts +5 -2
  89. package/src/react/renderers/index.ts +0 -1
  90. package/src/react/renderers/music.ts +8 -3
  91. package/src/react/renderers/packshot/blinking-button.ts +413 -0
  92. package/src/react/renderers/packshot.ts +170 -8
  93. package/src/react/renderers/progress.ts +4 -3
  94. package/src/react/renderers/render.ts +127 -71
  95. package/src/react/renderers/speech.ts +2 -2
  96. package/src/react/renderers/split.ts +34 -13
  97. package/src/react/renderers/utils.test.ts +80 -0
  98. package/src/react/renderers/utils.ts +37 -1
  99. package/src/react/renderers/video.ts +47 -9
  100. package/src/react/types.ts +70 -17
  101. package/src/studio/stages.ts +40 -39
  102. package/src/studio/step-renderer.ts +14 -24
  103. package/src/studio/ui/index.html +2 -2
  104. package/src/tests/all.test.ts +4 -4
  105. package/src/tests/index.ts +1 -1
  106. package/test-slot-grid.tsx +19 -0
  107. package/test-slot-userland.tsx +30 -0
  108. package/test-sync-v2.ts +30 -0
  109. package/test-sync-v2.tsx +29 -0
  110. package/tsconfig.json +1 -1
  111. package/video.tsx +7 -0
  112. package/src/ai-sdk/providers/editly/ffmpeg.ts +0 -60
  113. package/src/react/renderers/animate.ts +0 -59
  114. /package/src/cli/commands/{studio.tsx → studio.ts} +0 -0
@@ -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,289 @@
1
+ import type {
2
+ FFmpegBackend,
3
+ FFmpegInput,
4
+ FFmpegRunOptions,
5
+ FFmpegRunResult,
6
+ VideoInfo,
7
+ } from "../backends/types";
8
+
9
+ const RENDI_API_BASE = "https://api.rendi.dev/v1";
10
+ const POLL_INTERVAL_MS = 2000;
11
+ const MAX_POLL_ATTEMPTS = 300;
12
+ const DEFAULT_MAX_COMMAND_SECONDS = 60;
13
+
14
+ interface RendiCommandResponse {
15
+ command_id: string;
16
+ }
17
+
18
+ interface RendiStoredFile {
19
+ file_id: string;
20
+ storage_url: string | null;
21
+ status: string;
22
+ duration?: number;
23
+ width?: number;
24
+ height?: number;
25
+ frame_rate?: number;
26
+ }
27
+
28
+ interface RendiStatusResponse {
29
+ command_id: string;
30
+ status: "QUEUED" | "PROCESSING" | "SUCCESS" | "FAILED";
31
+ error_message?: string;
32
+ output_files?: Record<string, RendiStoredFile>;
33
+ }
34
+
35
+ export class RendiBackend implements FFmpegBackend {
36
+ readonly name = "rendi";
37
+ private apiKey: string;
38
+
39
+ constructor(apiKey?: string) {
40
+ this.apiKey = apiKey ?? process.env.RENDI_API_KEY ?? "";
41
+ if (!this.apiKey) {
42
+ throw new Error("RENDI_API_KEY is required for Rendi backend");
43
+ }
44
+ }
45
+
46
+ async ffprobe(input: string): Promise<VideoInfo> {
47
+ const inputUrl = this.ensureUrl(input);
48
+
49
+ const submitResponse = await fetch(`${RENDI_API_BASE}/run-ffmpeg-command`, {
50
+ method: "POST",
51
+ headers: {
52
+ "X-API-KEY": this.apiKey,
53
+ "Content-Type": "application/json",
54
+ },
55
+ body: JSON.stringify({
56
+ input_files: { in_1: inputUrl },
57
+ output_files: { out_1: "probe.mp4" },
58
+ ffmpeg_command: "-i {{in_1}} -c copy {{out_1}}",
59
+ max_command_run_seconds: DEFAULT_MAX_COMMAND_SECONDS,
60
+ }),
61
+ });
62
+
63
+ if (!submitResponse.ok) {
64
+ throw new Error(`Rendi ffprobe failed: ${submitResponse.status}`);
65
+ }
66
+
67
+ const { command_id } =
68
+ (await submitResponse.json()) as RendiCommandResponse;
69
+
70
+ let attempts = 0;
71
+ while (attempts < MAX_POLL_ATTEMPTS) {
72
+ const statusResponse = await fetch(
73
+ `${RENDI_API_BASE}/commands/${command_id}`,
74
+ { headers: { "X-API-KEY": this.apiKey } },
75
+ );
76
+
77
+ if (!statusResponse.ok) {
78
+ throw new Error(`Rendi ffprobe poll failed: ${statusResponse.status}`);
79
+ }
80
+
81
+ const status = (await statusResponse.json()) as RendiStatusResponse;
82
+
83
+ if (status.status === "SUCCESS") {
84
+ const output = status.output_files?.out_1;
85
+ if (!output) {
86
+ throw new Error("rendi ffprobe completed but no output metadata");
87
+ }
88
+ return {
89
+ duration: output.duration ?? 0,
90
+ width: output.width,
91
+ height: output.height,
92
+ fps: output.frame_rate,
93
+ };
94
+ }
95
+
96
+ if (status.status === "FAILED") {
97
+ throw new Error(`Rendi ffprobe failed: ${status.error_message}`);
98
+ }
99
+
100
+ await this.sleep(POLL_INTERVAL_MS);
101
+ attempts++;
102
+ }
103
+
104
+ throw new Error("Rendi ffprobe timed out");
105
+ }
106
+
107
+ private getInputPath(input: FFmpegInput): string {
108
+ if (typeof input === "string") return input;
109
+ if ("raw" in input) throw new Error("raw inputs not supported in Rendi");
110
+ return input.path;
111
+ }
112
+
113
+ async run(options: FFmpegRunOptions): Promise<FFmpegRunResult> {
114
+ const {
115
+ inputs,
116
+ filterComplex,
117
+ videoFilter,
118
+ outputArgs = [],
119
+ outputPath,
120
+ verbose,
121
+ } = options;
122
+
123
+ const inputFiles: Record<string, string> = {};
124
+ const pathToPlaceholder = new Map<string, string>();
125
+
126
+ for (let i = 0; i < inputs.length; i++) {
127
+ const input = inputs[i]!;
128
+ const path = this.getInputPath(input);
129
+ const url = this.ensureUrl(path);
130
+ const placeholder = `in_${i + 1}`;
131
+ inputFiles[placeholder] = url;
132
+ pathToPlaceholder.set(path, `{{${placeholder}}}`);
133
+ }
134
+
135
+ const replaceWithPlaceholders = (str: string): string => {
136
+ let result = str;
137
+ const sortedEntries = [...pathToPlaceholder.entries()].sort(
138
+ (a, b) => b[0].length - a[0].length,
139
+ );
140
+ for (const [url, ph] of sortedEntries) {
141
+ if (result.includes(url)) {
142
+ result = result.replaceAll(url, ph);
143
+ }
144
+ }
145
+ return result;
146
+ };
147
+
148
+ const inputArgs: string[] = [];
149
+ for (let i = 0; i < inputs.length; i++) {
150
+ const input = inputs[i]!;
151
+ if (typeof input !== "string" && "options" in input && input.options) {
152
+ inputArgs.push(...input.options);
153
+ }
154
+ inputArgs.push("-i", `{{in_${i + 1}}}`);
155
+ }
156
+
157
+ const filterArgs: string[] = [];
158
+ if (filterComplex) {
159
+ filterArgs.push(
160
+ "-filter_complex",
161
+ replaceWithPlaceholders(filterComplex),
162
+ );
163
+ }
164
+ if (videoFilter) {
165
+ filterArgs.push("-vf", replaceWithPlaceholders(videoFilter));
166
+ }
167
+
168
+ const processedOutputArgs = outputArgs
169
+ .filter((arg) => arg !== "-y")
170
+ .map((arg) => replaceWithPlaceholders(arg));
171
+
172
+ const commandParts = [
173
+ ...inputArgs,
174
+ ...filterArgs,
175
+ ...processedOutputArgs,
176
+ "{{out_1}}",
177
+ ];
178
+ const ffmpegCommand = this.buildCommandString(commandParts);
179
+ const outputFilename = outputPath?.split("/").pop() ?? "output.mp4";
180
+
181
+ if (verbose) {
182
+ console.log("[rendi] input_files:", inputFiles);
183
+ console.log("[rendi] ffmpeg_command:", ffmpegCommand);
184
+ }
185
+
186
+ const submitResponse = await fetch(`${RENDI_API_BASE}/run-ffmpeg-command`, {
187
+ method: "POST",
188
+ headers: {
189
+ "X-API-KEY": this.apiKey,
190
+ "Content-Type": "application/json",
191
+ },
192
+ body: JSON.stringify({
193
+ input_files: inputFiles,
194
+ output_files: { out_1: outputFilename },
195
+ ffmpeg_command: ffmpegCommand,
196
+ max_command_run_seconds: DEFAULT_MAX_COMMAND_SECONDS,
197
+ }),
198
+ });
199
+
200
+ if (!submitResponse.ok) {
201
+ const errorText = await submitResponse.text();
202
+ throw new Error(
203
+ `Rendi submit failed: ${submitResponse.status} - ${errorText}`,
204
+ );
205
+ }
206
+
207
+ const { command_id } =
208
+ (await submitResponse.json()) as RendiCommandResponse;
209
+
210
+ if (verbose) {
211
+ console.log("[rendi] command_id:", command_id);
212
+ }
213
+
214
+ let attempts = 0;
215
+ while (attempts < MAX_POLL_ATTEMPTS) {
216
+ const statusResponse = await fetch(
217
+ `${RENDI_API_BASE}/commands/${command_id}`,
218
+ {
219
+ headers: { "X-API-KEY": this.apiKey },
220
+ },
221
+ );
222
+
223
+ if (!statusResponse.ok) {
224
+ throw new Error(`Rendi poll failed: ${statusResponse.status}`);
225
+ }
226
+
227
+ const status = (await statusResponse.json()) as RendiStatusResponse;
228
+
229
+ if (verbose && attempts % 5 === 0) {
230
+ console.log("[rendi] status:", status.status);
231
+ }
232
+
233
+ if (status.status === "SUCCESS") {
234
+ const outputFile = status.output_files?.out_1;
235
+ if (!outputFile?.storage_url) {
236
+ throw new Error("Rendi completed but no output URL found");
237
+ }
238
+
239
+ if (verbose) {
240
+ console.log("[rendi] output url:", outputFile.storage_url);
241
+ }
242
+
243
+ return { output: { type: "url", url: outputFile.storage_url } };
244
+ }
245
+
246
+ if (status.status === "FAILED") {
247
+ throw new Error(
248
+ `Rendi command failed: ${status.error_message ?? "Unknown error"}`,
249
+ );
250
+ }
251
+
252
+ await this.sleep(POLL_INTERVAL_MS);
253
+ attempts++;
254
+ }
255
+
256
+ throw new Error("Rendi command timed out");
257
+ }
258
+
259
+ private ensureUrl(input: string): string {
260
+ if (input.startsWith("http://") || input.startsWith("https://")) {
261
+ return input;
262
+ }
263
+ throw new Error(`Rendi backend requires URLs, got local path: ${input}`);
264
+ }
265
+
266
+ private buildCommandString(args: string[]): string {
267
+ return args
268
+ .map((arg) => {
269
+ if (arg.startsWith("-") || arg.startsWith("{{")) {
270
+ return arg;
271
+ }
272
+ if (arg.includes(" ") || arg.includes(":") || arg.includes("'")) {
273
+ return `"${arg.replace(/"/g, '\\"')}"`;
274
+ }
275
+ return arg;
276
+ })
277
+ .join(" ");
278
+ }
279
+
280
+ private sleep(ms: number): Promise<void> {
281
+ return new Promise((resolve) => setTimeout(resolve, ms));
282
+ }
283
+ }
284
+
285
+ export function createRendiBackend(apiKey?: string): RendiBackend {
286
+ return new RendiBackend(apiKey);
287
+ }
288
+
289
+ export type { FFmpegBackend } from "../backends/types";
@@ -0,0 +1,35 @@
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
+ inputs: [
23
+ "https://storage.rendi.dev/sample/big_buck_bunny_720p_5sec_intro.mp4",
24
+ ],
25
+ outputArgs: ["-t", "2", "-c:v", "libx264", "-preset", "ultrafast"],
26
+ outputPath: "output.mp4",
27
+ verbose: true,
28
+ });
29
+
30
+ expect(result.output.type).toBe("url");
31
+ if (result.output.type === "url") {
32
+ expect(result.output.url).toMatch(/^https:\/\//);
33
+ }
34
+ }, 120000);
35
+ });
@@ -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`;
@@ -87,6 +89,21 @@ export interface TextLayer extends BaseLayer {
87
89
  fontFamily?: string;
88
90
  }
89
91
 
92
+ /**
93
+ * Crop position anchor for cover mode.
94
+ * NOTE: This is a varg extension to editly, not in the original.
95
+ */
96
+ export type CropPosition =
97
+ | "center"
98
+ | "top"
99
+ | "bottom"
100
+ | "left"
101
+ | "right"
102
+ | "top-left"
103
+ | "top-right"
104
+ | "bottom-left"
105
+ | "bottom-right";
106
+
90
107
  /**
91
108
  * For video layers, if parent `clip.duration` is specified, the video will be slowed/sped-up to match `clip.duration`.
92
109
  * If `cutFrom`/`cutTo` is set, the resulting segment (`cutTo`-`cutFrom`) will be slowed/sped-up to fit `clip.duration`.
@@ -95,6 +112,7 @@ export interface VideoLayer extends BaseLayer {
95
112
  type: "video";
96
113
  path: string;
97
114
  resizeMode?: ResizeMode;
115
+ cropPosition?: CropPosition;
98
116
  cutFrom?: number;
99
117
  cutTo?: number;
100
118
  width?: SizeValue;
@@ -309,6 +327,18 @@ export interface EditlyConfig {
309
327
  audioNorm?: AudioNormalizationOptions;
310
328
  verbose?: boolean;
311
329
  enableFfmpegLog?: boolean;
330
+ /** End output when shortest stream ends (video or audio) */
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;
312
342
  }
313
343
 
314
344
  // Internal types used by our implementation