vargai 0.4.0-alpha45 → 0.4.0-alpha47
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 +1 -1
- package/src/ai-sdk/file.ts +28 -17
- package/src/ai-sdk/index.ts +12 -0
- package/src/ai-sdk/providers/editly/backends/index.ts +1 -0
- package/src/ai-sdk/providers/editly/backends/local.ts +13 -4
- package/src/ai-sdk/providers/editly/backends/types.ts +14 -4
- package/src/ai-sdk/providers/editly/rendi/editly-with-rendi-backend.test.ts +20 -11
- package/src/ai-sdk/providers/editly/rendi/index.ts +25 -10
- package/src/ai-sdk/providers/editly/rendi/rendi.test.ts +9 -2
- package/src/ai-sdk/providers/fal.ts +50 -2
- package/src/ai-sdk/storage/fal.ts +11 -0
- package/src/ai-sdk/storage/index.ts +3 -0
- package/src/ai-sdk/storage/r2.ts +55 -0
- package/src/ai-sdk/storage/types.ts +3 -0
- package/src/react/renderers/cache.test.ts +4 -1
- package/src/react/renderers/captions.ts +6 -3
- package/src/react/renderers/clip.ts +34 -29
- package/src/react/renderers/context.ts +4 -3
- package/src/react/renderers/image.ts +13 -17
- package/src/react/renderers/index.ts +0 -1
- package/src/react/renderers/music.ts +26 -13
- package/src/react/renderers/packshot.ts +2 -1
- package/src/react/renderers/render.ts +17 -11
- package/src/react/renderers/slider.ts +24 -16
- package/src/react/renderers/speech.ts +4 -13
- package/src/react/renderers/split.ts +8 -4
- package/src/react/renderers/swipe.ts +29 -16
- package/src/react/renderers/video.ts +17 -22
- package/src/react/types.ts +2 -0
- package/src/studio/step-renderer.ts +13 -7
package/package.json
CHANGED
package/src/ai-sdk/file.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ImageModelV3File } from "@ai-sdk/provider";
|
|
2
|
+
import type { StorageProvider } from "./storage/types";
|
|
2
3
|
|
|
3
4
|
export class File {
|
|
4
5
|
private _data: Uint8Array | null = null;
|
|
@@ -8,13 +9,14 @@ export class File {
|
|
|
8
9
|
|
|
9
10
|
private constructor(
|
|
10
11
|
options:
|
|
11
|
-
| { data: Uint8Array; mediaType: string }
|
|
12
|
+
| { data: Uint8Array; mediaType: string; url?: string }
|
|
12
13
|
| { url: string; mediaType?: string }
|
|
13
14
|
| { loader: () => Promise<Uint8Array>; mediaType: string },
|
|
14
15
|
) {
|
|
15
16
|
if ("data" in options) {
|
|
16
17
|
this._data = options.data;
|
|
17
18
|
this._mediaType = options.mediaType;
|
|
19
|
+
this._url = options.url ?? null;
|
|
18
20
|
} else if ("url" in options) {
|
|
19
21
|
this._url = options.url;
|
|
20
22
|
this._mediaType = options.mediaType ?? inferMediaType(options.url);
|
|
@@ -46,10 +48,12 @@ export class File {
|
|
|
46
48
|
static fromGenerated(generated: {
|
|
47
49
|
uint8Array: Uint8Array;
|
|
48
50
|
mediaType: string;
|
|
51
|
+
url?: string;
|
|
49
52
|
}): File {
|
|
50
53
|
return new File({
|
|
51
54
|
data: generated.uint8Array,
|
|
52
55
|
mediaType: generated.mediaType,
|
|
56
|
+
url: generated.url,
|
|
53
57
|
});
|
|
54
58
|
}
|
|
55
59
|
|
|
@@ -119,6 +123,10 @@ export class File {
|
|
|
119
123
|
return this._mediaType.startsWith("video/");
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
get url(): string | null {
|
|
127
|
+
return this._url;
|
|
128
|
+
}
|
|
129
|
+
|
|
122
130
|
async data(): Promise<Uint8Array> {
|
|
123
131
|
if (this._data) return this._data;
|
|
124
132
|
if (this._loader) {
|
|
@@ -142,18 +150,17 @@ export class File {
|
|
|
142
150
|
return new Blob([data], { type: this._mediaType });
|
|
143
151
|
}
|
|
144
152
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
153
|
+
/**
|
|
154
|
+
* Upload file to storage and return the URL. Returns cached URL if already uploaded.
|
|
155
|
+
* @param storage - Storage provider to use for upload
|
|
156
|
+
* @returns URL of the uploaded file
|
|
157
|
+
*/
|
|
158
|
+
async upload(storage: StorageProvider): Promise<string> {
|
|
159
|
+
if (this._url) return this._url;
|
|
160
|
+
const data = await this.data();
|
|
161
|
+
const key = `varg/${Date.now()}-${Math.random().toString(36).slice(2)}${this.extensionFromMediaType()}`;
|
|
162
|
+
this._url = await storage.upload(data, key, this._mediaType);
|
|
163
|
+
return this._url;
|
|
157
164
|
}
|
|
158
165
|
|
|
159
166
|
async base64(): Promise<string> {
|
|
@@ -166,14 +173,18 @@ export class File {
|
|
|
166
173
|
}
|
|
167
174
|
|
|
168
175
|
async toInput(): Promise<ImageModelV3File> {
|
|
169
|
-
if (this._url
|
|
176
|
+
if (this._url) {
|
|
170
177
|
return { type: "url", url: this._url };
|
|
171
178
|
}
|
|
172
179
|
const data = await this.arrayBuffer();
|
|
173
180
|
return { type: "file", mediaType: this._mediaType, data };
|
|
174
181
|
}
|
|
175
182
|
|
|
176
|
-
|
|
183
|
+
/**
|
|
184
|
+
* Write file data to a temporary file and return the path.
|
|
185
|
+
* @returns Path to the temporary file
|
|
186
|
+
*/
|
|
187
|
+
async toTempFile(): Promise<string> {
|
|
177
188
|
const data = await this.data();
|
|
178
189
|
const ext = this.extensionFromMediaType();
|
|
179
190
|
const tmpDir = process.env.TMPDIR ?? "/tmp";
|
|
@@ -189,10 +200,10 @@ export class File {
|
|
|
189
200
|
| File,
|
|
190
201
|
): Promise<string> {
|
|
191
202
|
if (file instanceof File) {
|
|
192
|
-
return file.
|
|
203
|
+
return file.toTempFile();
|
|
193
204
|
}
|
|
194
205
|
const f = File.from(file);
|
|
195
|
-
return f.
|
|
206
|
+
return f.toTempFile();
|
|
196
207
|
}
|
|
197
208
|
|
|
198
209
|
private extensionFromMediaType(): string {
|
package/src/ai-sdk/index.ts
CHANGED
|
@@ -54,8 +54,14 @@ export {
|
|
|
54
54
|
type Clip as EditlyClip,
|
|
55
55
|
type EditlyConfig,
|
|
56
56
|
editly,
|
|
57
|
+
type FFmpegBackend,
|
|
57
58
|
type Layer as EditlyLayer,
|
|
59
|
+
localBackend,
|
|
58
60
|
} from "./providers/editly";
|
|
61
|
+
export {
|
|
62
|
+
createRendiBackend,
|
|
63
|
+
type RendiBackendOptions,
|
|
64
|
+
} from "./providers/editly/rendi";
|
|
59
65
|
export {
|
|
60
66
|
createElevenLabs,
|
|
61
67
|
type ElevenLabsProvider,
|
|
@@ -92,6 +98,12 @@ export {
|
|
|
92
98
|
createTogetherProvider,
|
|
93
99
|
together,
|
|
94
100
|
} from "./providers/together";
|
|
101
|
+
export {
|
|
102
|
+
falStorage,
|
|
103
|
+
type R2StorageOptions,
|
|
104
|
+
r2Storage,
|
|
105
|
+
type StorageProvider,
|
|
106
|
+
} from "./storage";
|
|
95
107
|
export type {
|
|
96
108
|
VideoModelV3,
|
|
97
109
|
VideoModelV3CallOptions,
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { $ } from "bun";
|
|
2
|
+
import { File } from "../../../file";
|
|
2
3
|
import type {
|
|
3
4
|
FFmpegBackend,
|
|
4
5
|
FFmpegInput,
|
|
5
6
|
FFmpegRunOptions,
|
|
6
7
|
FFmpegRunResult,
|
|
8
|
+
FilePath,
|
|
7
9
|
VideoInfo,
|
|
8
10
|
} from "./types";
|
|
9
11
|
|
|
@@ -38,16 +40,23 @@ export class LocalBackend implements FFmpegBackend {
|
|
|
38
40
|
};
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
async resolvePath(path: FilePath): Promise<string> {
|
|
44
|
+
if (typeof path === "string") return path;
|
|
45
|
+
return path.url ?? (await path.toTempFile());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async buildInputArgs(inputs: FFmpegInput[]): Promise<string[]> {
|
|
42
49
|
const args: string[] = [];
|
|
43
50
|
for (const input of inputs) {
|
|
44
|
-
if (
|
|
51
|
+
if (input instanceof File) {
|
|
52
|
+
args.push("-i", await this.resolvePath(input));
|
|
53
|
+
} else if (typeof input === "string") {
|
|
45
54
|
args.push("-i", input);
|
|
46
55
|
} else if ("raw" in input) {
|
|
47
56
|
args.push(...input.raw);
|
|
48
57
|
} else {
|
|
49
58
|
if (input.options) args.push(...input.options);
|
|
50
|
-
args.push("-i", input.path);
|
|
59
|
+
args.push("-i", await this.resolvePath(input.path));
|
|
51
60
|
}
|
|
52
61
|
}
|
|
53
62
|
return args;
|
|
@@ -63,7 +72,7 @@ export class LocalBackend implements FFmpegBackend {
|
|
|
63
72
|
verbose,
|
|
64
73
|
} = options;
|
|
65
74
|
|
|
66
|
-
const inputArgs = this.buildInputArgs(inputs);
|
|
75
|
+
const inputArgs = await this.buildInputArgs(inputs);
|
|
67
76
|
|
|
68
77
|
const ffmpegArgs = [
|
|
69
78
|
"-hide_banner",
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Allows switching between local ffmpeg and cloud services like Rendi
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { File } from "../../../file";
|
|
6
7
|
import type { VideoInfo } from "../types";
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -10,14 +11,16 @@ import type { VideoInfo } from "../types";
|
|
|
10
11
|
*/
|
|
11
12
|
export type { VideoInfo };
|
|
12
13
|
|
|
14
|
+
export type FilePath = File | string;
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
|
-
* Represents an input to ffmpeg - can be a simple path/URL or structured with options
|
|
17
|
+
* Represents an input to ffmpeg - can be a simple path/URL, File, or structured with options
|
|
15
18
|
*/
|
|
16
19
|
export type FFmpegInput =
|
|
17
|
-
|
|
|
20
|
+
| FilePath
|
|
18
21
|
| {
|
|
19
|
-
/** Path or
|
|
20
|
-
path:
|
|
22
|
+
/** Path, URL, or File for the input */
|
|
23
|
+
path: FilePath;
|
|
21
24
|
/** Options to apply BEFORE the -i flag (e.g. -ss 5 for seeking) */
|
|
22
25
|
options?: string[];
|
|
23
26
|
}
|
|
@@ -65,6 +68,13 @@ export interface FFmpegBackend {
|
|
|
65
68
|
*/
|
|
66
69
|
ffprobe(input: string): Promise<VideoInfo>;
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a FilePath to a string path/URL for ffmpeg
|
|
73
|
+
* Local backend: returns URL if available, otherwise writes to temp
|
|
74
|
+
* Rendi backend: uploads local files, returns URL
|
|
75
|
+
*/
|
|
76
|
+
resolvePath(path: FilePath): Promise<string>;
|
|
77
|
+
|
|
68
78
|
/**
|
|
69
79
|
* Run ffmpeg command
|
|
70
80
|
* @param options - Execution options including args, inputs, and output path
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
9
|
import { $ } from "bun";
|
|
10
|
+
import type { StorageProvider } from "../../../storage/types";
|
|
10
11
|
import { editly } from "../index";
|
|
11
12
|
import { createRendiBackend } from ".";
|
|
12
13
|
|
|
@@ -19,7 +20,15 @@ const VIDEO_TALKING =
|
|
|
19
20
|
"https://s3.varg.ai/test-media/workflow-talking-synced.mp4";
|
|
20
21
|
const IMAGE_SQUARE = "https://s3.varg.ai/test-media/replicate-forest.png";
|
|
21
22
|
|
|
22
|
-
const
|
|
23
|
+
const mockStorage: StorageProvider = {
|
|
24
|
+
async upload() {
|
|
25
|
+
throw new Error("Mock storage - upload not expected in this test");
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const rendiBackend = shouldRunRendiTests
|
|
30
|
+
? createRendiBackend({ storage: mockStorage })
|
|
31
|
+
: (null as never);
|
|
23
32
|
|
|
24
33
|
async function saveResult(
|
|
25
34
|
result: {
|
|
@@ -52,7 +61,7 @@ describe.skipIf(!shouldRunRendiTests)("editly (rendi backend)", () => {
|
|
|
52
61
|
const outPath = "output/rendi/merge.mp4";
|
|
53
62
|
const result = await editly({
|
|
54
63
|
outPath,
|
|
55
|
-
backend:
|
|
64
|
+
backend: rendiBackend,
|
|
56
65
|
width: 1280,
|
|
57
66
|
height: 720,
|
|
58
67
|
fps: 30,
|
|
@@ -74,7 +83,7 @@ describe.skipIf(!shouldRunRendiTests)("editly (rendi backend)", () => {
|
|
|
74
83
|
const outPath = "output/rendi/pip.mp4";
|
|
75
84
|
const result = await editly({
|
|
76
85
|
outPath,
|
|
77
|
-
backend:
|
|
86
|
+
backend: rendiBackend,
|
|
78
87
|
width: 1280,
|
|
79
88
|
height: 720,
|
|
80
89
|
fps: 30,
|
|
@@ -103,7 +112,7 @@ describe.skipIf(!shouldRunRendiTests)("editly (rendi backend)", () => {
|
|
|
103
112
|
const outPath = "output/rendi/ken-burns.mp4";
|
|
104
113
|
const result = await editly({
|
|
105
114
|
outPath,
|
|
106
|
-
backend:
|
|
115
|
+
backend: rendiBackend,
|
|
107
116
|
width: 1280,
|
|
108
117
|
height: 720,
|
|
109
118
|
fps: 30,
|
|
@@ -130,7 +139,7 @@ describe.skipIf(!shouldRunRendiTests)("editly (rendi backend)", () => {
|
|
|
130
139
|
const outPath = "output/rendi/subtitle.mp4";
|
|
131
140
|
const result = await editly({
|
|
132
141
|
outPath,
|
|
133
|
-
backend:
|
|
142
|
+
backend: rendiBackend,
|
|
134
143
|
width: 1280,
|
|
135
144
|
height: 720,
|
|
136
145
|
fps: 30,
|
|
@@ -167,7 +176,7 @@ describe.skipIf(!shouldRunRendiTests)("editly (rendi backend)", () => {
|
|
|
167
176
|
const outPath = "output/rendi/news-title.mp4";
|
|
168
177
|
const result = await editly({
|
|
169
178
|
outPath,
|
|
170
|
-
backend:
|
|
179
|
+
backend: rendiBackend,
|
|
171
180
|
width: 1280,
|
|
172
181
|
height: 720,
|
|
173
182
|
fps: 30,
|
|
@@ -205,7 +214,7 @@ describe.skipIf(!shouldRunRendiTests)("editly (rendi backend)", () => {
|
|
|
205
214
|
const outPath = "output/rendi/keep-audio.mp4";
|
|
206
215
|
const result = await editly({
|
|
207
216
|
outPath,
|
|
208
|
-
backend:
|
|
217
|
+
backend: rendiBackend,
|
|
209
218
|
width: 1280,
|
|
210
219
|
height: 720,
|
|
211
220
|
fps: 30,
|
|
@@ -227,7 +236,7 @@ describe.skipIf(!shouldRunRendiTests)("editly (rendi backend)", () => {
|
|
|
227
236
|
const outPath = "output/rendi/keep-audio-cut.mp4";
|
|
228
237
|
const result = await editly({
|
|
229
238
|
outPath,
|
|
230
|
-
backend:
|
|
239
|
+
backend: rendiBackend,
|
|
231
240
|
width: 1280,
|
|
232
241
|
height: 720,
|
|
233
242
|
fps: 30,
|
|
@@ -248,7 +257,7 @@ describe.skipIf(!shouldRunRendiTests)("editly (rendi backend)", () => {
|
|
|
248
257
|
const outPath = "output/rendi/contain-blur.mp4";
|
|
249
258
|
const result = await editly({
|
|
250
259
|
outPath,
|
|
251
|
-
backend:
|
|
260
|
+
backend: rendiBackend,
|
|
252
261
|
width: 1080,
|
|
253
262
|
height: 1920,
|
|
254
263
|
fps: 30,
|
|
@@ -269,7 +278,7 @@ describe.skipIf(!shouldRunRendiTests)("editly (rendi backend)", () => {
|
|
|
269
278
|
const outPath = "output/rendi/crop-position.mp4";
|
|
270
279
|
const result = await editly({
|
|
271
280
|
outPath,
|
|
272
|
-
backend:
|
|
281
|
+
backend: rendiBackend,
|
|
273
282
|
width: 1080,
|
|
274
283
|
height: 1920,
|
|
275
284
|
fps: 30,
|
|
@@ -310,7 +319,7 @@ describe.skipIf(!shouldRunRendiTests)("editly (rendi backend)", () => {
|
|
|
310
319
|
const outPath = "output/rendi/portrait-zoompan.mp4";
|
|
311
320
|
const result = await editly({
|
|
312
321
|
outPath,
|
|
313
|
-
backend:
|
|
322
|
+
backend: rendiBackend,
|
|
314
323
|
width: 1080,
|
|
315
324
|
height: 1920,
|
|
316
325
|
fps: 30,
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { File } from "../../../file";
|
|
2
|
+
import type { StorageProvider } from "../../../storage/types";
|
|
1
3
|
import type {
|
|
2
4
|
FFmpegBackend,
|
|
3
5
|
FFmpegInput,
|
|
4
6
|
FFmpegRunOptions,
|
|
5
7
|
FFmpegRunResult,
|
|
8
|
+
FilePath,
|
|
6
9
|
VideoInfo,
|
|
7
10
|
} from "../backends/types";
|
|
8
11
|
|
|
@@ -32,19 +35,26 @@ interface RendiStatusResponse {
|
|
|
32
35
|
output_files?: Record<string, RendiStoredFile>;
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
export interface RendiBackendOptions {
|
|
39
|
+
apiKey?: string;
|
|
40
|
+
storage: StorageProvider;
|
|
41
|
+
}
|
|
42
|
+
|
|
35
43
|
export class RendiBackend implements FFmpegBackend {
|
|
36
44
|
readonly name = "rendi";
|
|
37
45
|
private apiKey: string;
|
|
46
|
+
private storage: StorageProvider;
|
|
38
47
|
|
|
39
|
-
constructor(
|
|
40
|
-
this.apiKey = apiKey ?? process.env.RENDI_API_KEY ?? "";
|
|
48
|
+
constructor(options: RendiBackendOptions) {
|
|
49
|
+
this.apiKey = options.apiKey ?? process.env.RENDI_API_KEY ?? "";
|
|
50
|
+
this.storage = options.storage;
|
|
41
51
|
if (!this.apiKey) {
|
|
42
52
|
throw new Error("RENDI_API_KEY is required for Rendi backend");
|
|
43
53
|
}
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
async ffprobe(input: string): Promise<VideoInfo> {
|
|
47
|
-
const inputUrl = this.
|
|
57
|
+
const inputUrl = await this.resolvePath(input);
|
|
48
58
|
|
|
49
59
|
const submitResponse = await fetch(`${RENDI_API_BASE}/run-ffmpeg-command`, {
|
|
50
60
|
method: "POST",
|
|
@@ -104,7 +114,8 @@ export class RendiBackend implements FFmpegBackend {
|
|
|
104
114
|
throw new Error("Rendi ffprobe timed out");
|
|
105
115
|
}
|
|
106
116
|
|
|
107
|
-
private getInputPath(input: FFmpegInput):
|
|
117
|
+
private getInputPath(input: FFmpegInput): FilePath {
|
|
118
|
+
if (input instanceof File) return input;
|
|
108
119
|
if (typeof input === "string") return input;
|
|
109
120
|
if ("raw" in input) throw new Error("raw inputs not supported in Rendi");
|
|
110
121
|
return input.path;
|
|
@@ -125,10 +136,10 @@ export class RendiBackend implements FFmpegBackend {
|
|
|
125
136
|
|
|
126
137
|
for (const [i, input] of inputs.entries()) {
|
|
127
138
|
const path = this.getInputPath(input);
|
|
128
|
-
const url = this.
|
|
139
|
+
const url = await this.resolvePath(path);
|
|
129
140
|
const placeholder = `in_${i + 1}`;
|
|
130
141
|
inputFiles[placeholder] = url;
|
|
131
|
-
pathToPlaceholder.set(
|
|
142
|
+
pathToPlaceholder.set(url, `{{${placeholder}}}`);
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
const replaceWithPlaceholders = (str: string): string => {
|
|
@@ -254,11 +265,15 @@ export class RendiBackend implements FFmpegBackend {
|
|
|
254
265
|
throw new Error("Rendi command timed out");
|
|
255
266
|
}
|
|
256
267
|
|
|
257
|
-
|
|
268
|
+
async resolvePath(input: FilePath): Promise<string> {
|
|
269
|
+
if (input instanceof File) {
|
|
270
|
+
return input.upload(this.storage);
|
|
271
|
+
}
|
|
258
272
|
if (input.startsWith("http://") || input.startsWith("https://")) {
|
|
259
273
|
return input;
|
|
260
274
|
}
|
|
261
|
-
|
|
275
|
+
const file = File.fromPath(input);
|
|
276
|
+
return file.upload(this.storage);
|
|
262
277
|
}
|
|
263
278
|
|
|
264
279
|
private buildCommandString(args: string[]): string {
|
|
@@ -280,8 +295,8 @@ export class RendiBackend implements FFmpegBackend {
|
|
|
280
295
|
}
|
|
281
296
|
}
|
|
282
297
|
|
|
283
|
-
export function createRendiBackend(
|
|
284
|
-
return new RendiBackend(
|
|
298
|
+
export function createRendiBackend(options: RendiBackendOptions): RendiBackend {
|
|
299
|
+
return new RendiBackend(options);
|
|
285
300
|
}
|
|
286
301
|
|
|
287
302
|
export type { FFmpegBackend } from "../backends/types";
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { StorageProvider } from "../../../storage/types";
|
|
2
3
|
import { createRendiBackend } from ".";
|
|
3
4
|
|
|
4
5
|
const hasRendiKey = !!process.env.RENDI_API_KEY;
|
|
5
6
|
|
|
7
|
+
const mockStorage: StorageProvider = {
|
|
8
|
+
async upload() {
|
|
9
|
+
throw new Error("Mock storage - upload not expected in this test");
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
6
13
|
describe.skipIf(!hasRendiKey)("rendi backend", () => {
|
|
7
14
|
test("ffprobe remote file", async () => {
|
|
8
|
-
const backend = createRendiBackend();
|
|
15
|
+
const backend = createRendiBackend({ storage: mockStorage });
|
|
9
16
|
const info = await backend.ffprobe(
|
|
10
17
|
"https://storage.rendi.dev/sample/big_buck_bunny_720p_5sec_intro.mp4",
|
|
11
18
|
);
|
|
@@ -16,7 +23,7 @@ describe.skipIf(!hasRendiKey)("rendi backend", () => {
|
|
|
16
23
|
}, 30000);
|
|
17
24
|
|
|
18
25
|
test("run simple ffmpeg command", async () => {
|
|
19
|
-
const backend = createRendiBackend();
|
|
26
|
+
const backend = createRendiBackend({ storage: mockStorage });
|
|
20
27
|
|
|
21
28
|
const result = await backend.run({
|
|
22
29
|
inputs: [
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "@ai-sdk/provider";
|
|
13
13
|
import { fal } from "@fal-ai/client";
|
|
14
14
|
import pMap from "p-map";
|
|
15
|
+
import type { CacheStorage } from "../cache";
|
|
15
16
|
import { fileCache } from "../file-cache";
|
|
16
17
|
import type { VideoModelV3, VideoModelV3CallOptions } from "../video-model";
|
|
17
18
|
|
|
@@ -21,7 +22,54 @@ interface PendingRequest {
|
|
|
21
22
|
submitted_at: number;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
const
|
|
25
|
+
const memoryStorage = new Map<string, { value: unknown; expires: number }>();
|
|
26
|
+
|
|
27
|
+
function createMemoryCache(): CacheStorage {
|
|
28
|
+
return {
|
|
29
|
+
async get(key: string) {
|
|
30
|
+
const entry = memoryStorage.get(key);
|
|
31
|
+
if (!entry) return undefined;
|
|
32
|
+
if (entry.expires && Date.now() > entry.expires) {
|
|
33
|
+
memoryStorage.delete(key);
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return entry.value;
|
|
37
|
+
},
|
|
38
|
+
async set(key: string, value: unknown, ttl?: number) {
|
|
39
|
+
memoryStorage.set(key, { value, expires: ttl ? Date.now() + ttl : 0 });
|
|
40
|
+
},
|
|
41
|
+
async delete(key: string) {
|
|
42
|
+
memoryStorage.delete(key);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// TODO: allow passing CacheStorage via providerOptions.fal.cacheStorage for proper serverless support
|
|
48
|
+
function isFilesystemWritable(): boolean {
|
|
49
|
+
try {
|
|
50
|
+
const testPath = `.cache/.write-test-${Date.now()}`;
|
|
51
|
+
Bun.spawnSync(["mkdir", "-p", ".cache"]);
|
|
52
|
+
const result = Bun.spawnSync(["touch", testPath]);
|
|
53
|
+
if (result.exitCode === 0) {
|
|
54
|
+
Bun.spawnSync(["rm", testPath]);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const USE_FILE_CACHE = isFilesystemWritable();
|
|
64
|
+
|
|
65
|
+
function createFalCache(name: string): CacheStorage {
|
|
66
|
+
if (!USE_FILE_CACHE) {
|
|
67
|
+
return createMemoryCache();
|
|
68
|
+
}
|
|
69
|
+
return fileCache({ dir: `.cache/${name}` });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const pendingStorage = createFalCache("fal-pending");
|
|
25
73
|
|
|
26
74
|
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
27
75
|
const FAL_TIMEOUT_MS = (() => {
|
|
@@ -183,7 +231,7 @@ function detectImageType(bytes: Uint8Array): string | undefined {
|
|
|
183
231
|
return undefined;
|
|
184
232
|
}
|
|
185
233
|
|
|
186
|
-
const uploadCache =
|
|
234
|
+
const uploadCache = createFalCache("fal-uploads");
|
|
187
235
|
|
|
188
236
|
async function fileToUrl(file: ImageModelV3File): Promise<string> {
|
|
189
237
|
if (file.type === "url") return file.url;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { StorageProvider } from "./types";
|
|
2
|
+
|
|
3
|
+
export function falStorage(): StorageProvider {
|
|
4
|
+
return {
|
|
5
|
+
async upload(data: Uint8Array, _key: string, mediaType: string) {
|
|
6
|
+
const { fal } = await import("@fal-ai/client");
|
|
7
|
+
const blob = new Blob([data], { type: mediaType });
|
|
8
|
+
return fal.storage.upload(blob);
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
2
|
+
import type { StorageProvider } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface R2StorageOptions {
|
|
5
|
+
endpoint?: string;
|
|
6
|
+
accessKeyId?: string;
|
|
7
|
+
secretAccessKey?: string;
|
|
8
|
+
bucket?: string;
|
|
9
|
+
publicUrl?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function r2Storage(options?: R2StorageOptions): StorageProvider {
|
|
13
|
+
const endpoint = options?.endpoint ?? process.env.CLOUDFLARE_R2_API_URL;
|
|
14
|
+
const accessKeyId =
|
|
15
|
+
options?.accessKeyId ?? process.env.CLOUDFLARE_ACCESS_KEY_ID;
|
|
16
|
+
const secretAccessKey =
|
|
17
|
+
options?.secretAccessKey ?? process.env.CLOUDFLARE_ACCESS_SECRET;
|
|
18
|
+
const bucket = options?.bucket ?? process.env.CLOUDFLARE_R2_BUCKET ?? "m";
|
|
19
|
+
const publicUrl = options?.publicUrl ?? "https://s3.varg.ai";
|
|
20
|
+
|
|
21
|
+
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
"R2 storage requires endpoint, accessKeyId, and secretAccessKey. " +
|
|
24
|
+
"Set CLOUDFLARE_R2_API_URL, CLOUDFLARE_ACCESS_KEY_ID, CLOUDFLARE_ACCESS_SECRET env vars " +
|
|
25
|
+
"or pass options to r2Storage().",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const client = new S3Client({
|
|
30
|
+
region: "auto",
|
|
31
|
+
endpoint,
|
|
32
|
+
credentials: { accessKeyId, secretAccessKey },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const getPublicUrl = (objectKey: string): string => {
|
|
36
|
+
if (endpoint.includes("localhost")) {
|
|
37
|
+
return `${endpoint}/${objectKey}`;
|
|
38
|
+
}
|
|
39
|
+
return `${publicUrl}/${objectKey}`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
async upload(data: Uint8Array, key: string, mediaType: string) {
|
|
44
|
+
await client.send(
|
|
45
|
+
new PutObjectCommand({
|
|
46
|
+
Bucket: bucket,
|
|
47
|
+
Key: key,
|
|
48
|
+
Body: data,
|
|
49
|
+
ContentType: mediaType,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
return getPublicUrl(key);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -4,7 +4,9 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import type { ImageModelV3 } from "@ai-sdk/provider";
|
|
6
6
|
import { withCache } from "../../ai-sdk/cache";
|
|
7
|
+
import type { File } from "../../ai-sdk/file";
|
|
7
8
|
import { fileCache } from "../../ai-sdk/file-cache";
|
|
9
|
+
import { localBackend } from "../../ai-sdk/providers/editly";
|
|
8
10
|
import type { VideoModelV3 } from "../../ai-sdk/video-model";
|
|
9
11
|
import { Image, Video } from "../elements";
|
|
10
12
|
import type { RenderContext } from "./context";
|
|
@@ -99,7 +101,8 @@ function createContext(
|
|
|
99
101
|
generateImage: generateImage as unknown as RenderContext["generateImage"],
|
|
100
102
|
generateVideo: generateVideo as unknown as RenderContext["generateVideo"],
|
|
101
103
|
tempFiles: [],
|
|
102
|
-
|
|
104
|
+
pendingFiles: new Map<string, Promise<File>>(),
|
|
105
|
+
backend: localBackend,
|
|
103
106
|
};
|
|
104
107
|
}
|
|
105
108
|
|
|
@@ -243,8 +243,8 @@ export async function renderCaptions(
|
|
|
243
243
|
srtContent = await Bun.file(props.src).text();
|
|
244
244
|
srtPath = props.src;
|
|
245
245
|
} else if (props.src.type === "speech") {
|
|
246
|
-
const
|
|
247
|
-
audioPath =
|
|
246
|
+
const speechFile = await renderSpeech(props.src, ctx);
|
|
247
|
+
audioPath = await ctx.backend.resolvePath(speechFile);
|
|
248
248
|
|
|
249
249
|
const transcribeTaskId = ctx.progress
|
|
250
250
|
? addTask(ctx.progress, "transcribe", "groq-whisper")
|
|
@@ -252,7 +252,10 @@ export async function renderCaptions(
|
|
|
252
252
|
if (transcribeTaskId && ctx.progress)
|
|
253
253
|
startTask(ctx.progress, transcribeTaskId);
|
|
254
254
|
|
|
255
|
-
const audioData =
|
|
255
|
+
const audioData =
|
|
256
|
+
audioPath.startsWith("http://") || audioPath.startsWith("https://")
|
|
257
|
+
? await fetch(audioPath).then((res) => res.arrayBuffer())
|
|
258
|
+
: await Bun.file(audioPath).arrayBuffer();
|
|
256
259
|
|
|
257
260
|
const result = await transcribe({
|
|
258
261
|
model: groq.transcription("whisper-large-v3"),
|