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 CHANGED
@@ -69,7 +69,7 @@
69
69
  "sharp": "^0.34.5",
70
70
  "zod": "^4.2.1"
71
71
  },
72
- "version": "0.4.0-alpha45",
72
+ "version": "0.4.0-alpha47",
73
73
  "exports": {
74
74
  ".": "./src/index.ts",
75
75
  "./ai": "./src/ai-sdk/index.ts",
@@ -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
- async url(uploader?: (blob: Blob) => Promise<string>): Promise<string> {
146
- if (this._url && !this._data && !this._loader) {
147
- return this._url;
148
- }
149
- const blob = await this.blob();
150
- if (uploader) return uploader(blob);
151
- try {
152
- const { fal } = await import("@fal-ai/client");
153
- return fal.storage.upload(blob);
154
- } catch {
155
- throw new Error("No uploader provided and @fal-ai/client not available.");
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 && !this._data && !this._loader) {
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
- async toTemp(): Promise<string> {
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.toTemp();
203
+ return file.toTempFile();
193
204
  }
194
205
  const f = File.from(file);
195
- return f.toTemp();
206
+ return f.toTempFile();
196
207
  }
197
208
 
198
209
  private extensionFromMediaType(): string {
@@ -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,
@@ -4,5 +4,6 @@ export type {
4
4
  FFmpegInput,
5
5
  FFmpegRunOptions,
6
6
  FFmpegRunResult,
7
+ FilePath,
7
8
  VideoInfo,
8
9
  } from "./types";
@@ -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
- private buildInputArgs(inputs: FFmpegInput[]): string[] {
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 (typeof input === "string") {
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
- | string
20
+ | FilePath
18
21
  | {
19
- /** Path or URL to the input file */
20
- path: string;
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 rendi = shouldRunRendiTests ? createRendiBackend() : (null as never);
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: rendi,
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: rendi,
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: rendi,
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: rendi,
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: rendi,
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: rendi,
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: rendi,
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: rendi,
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: rendi,
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: rendi,
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(apiKey?: string) {
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.ensureUrl(input);
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): string {
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.ensureUrl(path);
139
+ const url = await this.resolvePath(path);
129
140
  const placeholder = `in_${i + 1}`;
130
141
  inputFiles[placeholder] = url;
131
- pathToPlaceholder.set(path, `{{${placeholder}}}`);
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
- private ensureUrl(input: string): string {
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
- throw new Error(`Rendi backend requires URLs, got local path: ${input}`);
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(apiKey?: string): RendiBackend {
284
- return new RendiBackend(apiKey);
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 pendingStorage = fileCache({ dir: ".cache/fal-pending" });
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 = fileCache({ dir: ".cache/fal-uploads" });
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,3 @@
1
+ export { falStorage } from "./fal";
2
+ export { type R2StorageOptions, r2Storage } from "./r2";
3
+ export type { StorageProvider } from "./types";
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ export interface StorageProvider {
2
+ upload(data: Uint8Array, key: string, mediaType: string): Promise<string>;
3
+ }
@@ -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
- pending: new Map(),
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 speechResult = await renderSpeech(props.src, ctx);
247
- audioPath = speechResult.path;
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 = await Bun.file(speechResult.path).arrayBuffer();
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"),