vargai 0.4.0-alpha32 → 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-alpha32",
72
+ "version": "0.4.0-alpha33",
73
73
  "exports": {
74
74
  ".": "./src/index.ts",
75
75
  "./ai": "./src/ai-sdk/index.ts",
@@ -126,7 +126,13 @@ export class RendiBackend implements FFmpegBackend {
126
126
  if (placeholder) {
127
127
  return placeholder;
128
128
  }
129
- return arg;
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;
130
136
  });
131
137
 
132
138
  const filteredArgs = this.stripInternalFlags(commandArgs);
@@ -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 {