vargai 0.4.0-alpha105 → 0.4.0-alpha107

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
@@ -28,6 +28,7 @@
28
28
  "@commitlint/config-conventional": "^20.0.0",
29
29
  "@size-limit/preset-small-lib": "^11.2.0",
30
30
  "@types/bun": "latest",
31
+ "@types/opentype.js": "^1.3.9",
31
32
  "@types/react": "^19.2.7",
32
33
  "husky": "^9.1.7",
33
34
  "lint-staged": "^16.2.7"
@@ -58,9 +59,11 @@
58
59
  "ai": "^6.0.26",
59
60
  "apify-client": "^2.20.0",
60
61
  "citty": "^0.1.6",
62
+ "fflate": "^0.8.2",
61
63
  "fluent-ffmpeg": "^2.1.3",
62
64
  "groq-sdk": "^0.36.0",
63
65
  "ink": "^6.5.1",
66
+ "opentype.js": "^1.3.4",
64
67
  "p-limit": "^6.2.0",
65
68
  "p-map": "^7.0.4",
66
69
  "react": "^19.2.0",
@@ -104,7 +107,7 @@
104
107
  "license": "Apache-2.0",
105
108
  "author": "varg.ai <hello@varg.ai> (https://varg.ai)",
106
109
  "sideEffects": false,
107
- "version": "0.4.0-alpha105",
110
+ "version": "0.4.0-alpha107",
108
111
  "exports": {
109
112
  ".": "./src/index.ts",
110
113
  "./ai": "./src/ai-sdk/index.ts",
@@ -47,6 +47,11 @@ export interface FFmpegRunOptions {
47
47
  verbose?: boolean;
48
48
  /** Max execution time in seconds (used by cloud backends like Rendi, ignored by local) */
49
49
  timeoutSeconds?: number;
50
+ /** Extra files (e.g. fonts, ASS subtitles) to include alongside inputs.
51
+ * When present, cloud backends like Rendi use compressed folder mode
52
+ * (input_compressed_folder) to bundle all files together.
53
+ * Each entry provides either a `url` to download or raw `data` bytes. */
54
+ auxiliaryFiles?: { url?: string; data?: Uint8Array; fileName: string }[];
50
55
  }
51
56
 
52
57
  export type FFmpegOutput =
@@ -1,3 +1,4 @@
1
+ import { zipSync } from "fflate";
1
2
  import sharp from "sharp";
2
3
  import { File } from "../../../file";
3
4
  import type { StorageProvider } from "../../../storage/types";
@@ -128,6 +129,11 @@ export class RendiBackend implements FFmpegBackend {
128
129
  }
129
130
 
130
131
  async run(options: FFmpegRunOptions): Promise<FFmpegRunResult> {
132
+ // When auxiliary files (e.g. fonts) are present, use compressed folder mode
133
+ if (options.auxiliaryFiles && options.auxiliaryFiles.length > 0) {
134
+ return this.runWithCompressedFolder(options);
135
+ }
136
+
131
137
  let {
132
138
  inputs,
133
139
  filterComplex,
@@ -287,6 +293,204 @@ export class RendiBackend implements FFmpegBackend {
287
293
  throw new Error("Rendi command timed out");
288
294
  }
289
295
 
296
+ /**
297
+ * Run an FFmpeg command using Rendi's input_compressed_folder mode.
298
+ *
299
+ * Used when auxiliary files (e.g. fonts for subtitle rendering) need to be
300
+ * bundled alongside regular inputs. Creates a ZIP containing all input files
301
+ * and auxiliary files, uploads it to storage, and submits to Rendi with
302
+ * `input_compressed_folder` instead of `input_files`.
303
+ *
304
+ * Inside the ZIP, all files are at the root level. The ffmpeg command
305
+ * references files by their bare filenames (not placeholders).
306
+ */
307
+ private async runWithCompressedFolder(
308
+ options: FFmpegRunOptions,
309
+ ): Promise<FFmpegRunResult> {
310
+ const {
311
+ inputs,
312
+ videoFilter,
313
+ filterComplex,
314
+ outputArgs = [],
315
+ outputPath,
316
+ verbose,
317
+ auxiliaryFiles = [],
318
+ } = options;
319
+
320
+ // 1. Resolve all input files to URLs
321
+ const inputEntries: { fileName: string; url: string }[] = [];
322
+ for (const input of inputs ?? []) {
323
+ const path = this.getInputPath(input);
324
+ const url = await this.resolvePath(path);
325
+ // Extract filename from URL or path
326
+ const fileName =
327
+ url.split("/").pop()?.split("?")[0] ?? `input_${inputEntries.length}`;
328
+ inputEntries.push({ fileName, url });
329
+ }
330
+
331
+ // 2. Download all files (inputs + auxiliary) into memory
332
+ const zipContents: Record<string, Uint8Array> = {};
333
+
334
+ const downloadTasks = [
335
+ ...inputEntries.map(async (entry) => {
336
+ const res = await fetch(entry.url);
337
+ if (!res.ok)
338
+ throw new Error(
339
+ `Failed to download input ${entry.fileName}: ${res.status}`,
340
+ );
341
+ zipContents[entry.fileName] = new Uint8Array(await res.arrayBuffer());
342
+ }),
343
+ ...auxiliaryFiles.map(async (file) => {
344
+ if (file.data) {
345
+ // Inline data — no download needed
346
+ zipContents[file.fileName] = file.data;
347
+ return;
348
+ }
349
+ if (!file.url) {
350
+ throw new Error(
351
+ `Auxiliary file ${file.fileName} has neither url nor data`,
352
+ );
353
+ }
354
+ const res = await fetch(file.url);
355
+ if (!res.ok)
356
+ throw new Error(
357
+ `Failed to download auxiliary file ${file.fileName}: ${res.status}`,
358
+ );
359
+ zipContents[file.fileName] = new Uint8Array(await res.arrayBuffer());
360
+ }),
361
+ ];
362
+
363
+ await Promise.all(downloadTasks);
364
+
365
+ if (verbose) {
366
+ const totalSize = Object.values(zipContents).reduce(
367
+ (sum, buf) => sum + buf.length,
368
+ 0,
369
+ );
370
+ console.log(
371
+ `[rendi] creating ZIP with ${Object.keys(zipContents).length} files (${(totalSize / 1024 / 1024).toFixed(1)} MB)`,
372
+ );
373
+ }
374
+
375
+ // 3. Create ZIP
376
+ const zipData = zipSync(zipContents, { level: 1 }); // fast compression
377
+
378
+ // 4. Upload ZIP to storage
379
+ const zipKey = `internal/rendi-compressed-${Date.now()}.zip`;
380
+ const zipUrl = await this.storage.upload(
381
+ zipData,
382
+ zipKey,
383
+ "application/zip",
384
+ );
385
+
386
+ if (verbose) {
387
+ console.log(
388
+ `[rendi] uploaded ZIP (${(zipData.length / 1024 / 1024).toFixed(1)} MB) -> ${zipUrl}`,
389
+ );
390
+ }
391
+
392
+ // 5. Build ffmpeg command using bare filenames (not {{in_X}} placeholders)
393
+ const inputArgs: string[] = [];
394
+ for (const [i, input] of (inputs ?? []).entries()) {
395
+ if (typeof input !== "string" && "options" in input && input.options) {
396
+ inputArgs.push(...input.options);
397
+ }
398
+ inputArgs.push("-i", inputEntries[i]!.fileName);
399
+ }
400
+
401
+ const filterArgs: string[] = [];
402
+ if (filterComplex) {
403
+ filterArgs.push("-filter_complex", filterComplex);
404
+ }
405
+ if (videoFilter) {
406
+ // For compressed folder mode, the video filter references files by
407
+ // their bare filenames (already resolved in the working directory)
408
+ filterArgs.push("-vf", videoFilter);
409
+ }
410
+
411
+ const processedOutputArgs = outputArgs.filter((arg) => arg !== "-y");
412
+
413
+ const commandParts = [
414
+ ...inputArgs,
415
+ ...filterArgs,
416
+ ...processedOutputArgs,
417
+ "{{out_1}}",
418
+ ];
419
+ const ffmpegCommand = this.buildCommandString(commandParts);
420
+ const outputFilename = outputPath?.split("/").pop() ?? "output.mp4";
421
+
422
+ if (verbose) {
423
+ console.log("[rendi] input_compressed_folder:", zipUrl);
424
+ console.log("[rendi] ffmpeg_command:", ffmpegCommand);
425
+ }
426
+
427
+ // 6. Submit to Rendi with input_compressed_folder
428
+ const submitResponse = await fetch(`${RENDI_API_BASE}/run-ffmpeg-command`, {
429
+ method: "POST",
430
+ headers: {
431
+ "X-API-KEY": this.apiKey,
432
+ "Content-Type": "application/json",
433
+ },
434
+ body: JSON.stringify({
435
+ input_compressed_folder: zipUrl,
436
+ output_files: { out_1: outputFilename },
437
+ ffmpeg_command: ffmpegCommand,
438
+ max_command_run_seconds:
439
+ options.timeoutSeconds ?? this.maxCommandRunSeconds,
440
+ }),
441
+ });
442
+
443
+ if (!submitResponse.ok) {
444
+ const errorText = await submitResponse.text();
445
+ throw new Error(
446
+ `Rendi submit failed: ${submitResponse.status} - ${errorText}`,
447
+ );
448
+ }
449
+
450
+ const { command_id } =
451
+ (await submitResponse.json()) as RendiCommandResponse;
452
+
453
+ if (verbose) {
454
+ console.log("[rendi] command_id:", command_id);
455
+ }
456
+
457
+ // 7. Poll for completion (same as standard run)
458
+ let attempts = 0;
459
+ while (attempts < MAX_POLL_ATTEMPTS) {
460
+ const statusResponse = await fetch(
461
+ `${RENDI_API_BASE}/commands/${command_id}`,
462
+ {
463
+ headers: { "X-API-KEY": this.apiKey },
464
+ },
465
+ );
466
+
467
+ if (!statusResponse.ok) {
468
+ throw new Error(`Rendi poll failed: ${statusResponse.status}`);
469
+ }
470
+
471
+ const status = (await statusResponse.json()) as RendiStatusResponse;
472
+
473
+ if (status.status === "SUCCESS") {
474
+ const outputFile = status.output_files?.out_1;
475
+ if (!outputFile?.storage_url) {
476
+ throw new Error("Rendi completed but no output URL");
477
+ }
478
+ return { output: { type: "url", url: outputFile.storage_url } };
479
+ }
480
+
481
+ if (status.status === "FAILED") {
482
+ throw new Error(
483
+ `Rendi command failed: ${status.error_message ?? "unknown error"}`,
484
+ );
485
+ }
486
+
487
+ await this.sleep(POLL_INTERVAL_MS);
488
+ attempts++;
489
+ }
490
+
491
+ throw new Error("Rendi command timed out");
492
+ }
493
+
290
494
  async resolvePath(input: FilePath): Promise<string> {
291
495
  if (input instanceof File) {
292
496
  return input.upload(this.storage);
@@ -1,8 +1,11 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
2
  import { localBackend } from "@/ai-sdk/providers/editly";
2
3
  import type {
3
4
  FFmpegBackend,
5
+ FFmpegInput,
4
6
  FFmpegOutput,
5
7
  } from "../../ai-sdk/providers/editly/backends/types";
8
+ import { type EmojiOverlay, buildEmojiFilterComplex } from "./emoji";
6
9
 
7
10
  /**
8
11
  * Resolves an FFmpegOutput to a string path/URL via the backend.
@@ -16,12 +19,85 @@ async function resolveInputPathMaybeUpload(
16
19
  return backend.resolvePath(input.path);
17
20
  }
18
21
 
22
+ /** Font file descriptor for caption rendering. */
23
+ export interface CaptionFontFile {
24
+ /** Public URL of the font file (e.g. https://s3.varg.ai/fonts/Montserrat-Bold.ttf) */
25
+ url: string;
26
+ /** Filename (e.g. "Montserrat-Bold.ttf") */
27
+ fileName: string;
28
+ }
29
+
19
30
  export interface CaptionOverlayOptions {
20
31
  video: FFmpegOutput;
21
32
  assPath: string;
22
33
  outputPath: string;
23
34
  backend?: FFmpegBackend;
24
35
  verbose?: boolean;
36
+ /** Font files to include for subtitle rendering. When provided, fontsdir is set. */
37
+ fontFiles?: CaptionFontFile[];
38
+ /** Emoji overlay data for color emoji rendering via image overlay. */
39
+ emojiOverlays?: EmojiOverlay[];
40
+ }
41
+
42
+ /** Local cache directory for fonts and emoji PNGs. */
43
+ const LOCAL_FONTS_DIR = "/tmp/varg-caption-fonts";
44
+ const LOCAL_EMOJI_DIR = "/tmp/varg-caption-fonts/emoji";
45
+
46
+ /**
47
+ * Download font files to a local directory for the local FFmpeg backend.
48
+ * Fonts are cached by filename — only downloaded once per process lifetime.
49
+ * Returns the directory path where fonts were saved.
50
+ */
51
+ export async function ensureLocalFonts(fontFiles: CaptionFontFile[]): Promise<string> {
52
+ if (!existsSync(LOCAL_FONTS_DIR)) {
53
+ mkdirSync(LOCAL_FONTS_DIR, { recursive: true });
54
+ }
55
+ await Promise.all(
56
+ fontFiles.map(async (font) => {
57
+ const localPath = `${LOCAL_FONTS_DIR}/${font.fileName}`;
58
+ if (existsSync(localPath)) return;
59
+ const res = await fetch(font.url);
60
+ if (!res.ok)
61
+ throw new Error(
62
+ `Failed to download font ${font.fileName}: ${res.status}`,
63
+ );
64
+ writeFileSync(localPath, new Uint8Array(await res.arrayBuffer()));
65
+ }),
66
+ );
67
+ return LOCAL_FONTS_DIR;
68
+ }
69
+
70
+ /**
71
+ * Download emoji PNG files to a local directory for the local FFmpeg backend.
72
+ * Returns an array of local file paths in the same order as the input overlays.
73
+ */
74
+ async function ensureLocalEmoji(
75
+ overlays: EmojiOverlay[],
76
+ ): Promise<string[]> {
77
+ if (!existsSync(LOCAL_EMOJI_DIR)) {
78
+ mkdirSync(LOCAL_EMOJI_DIR, { recursive: true });
79
+ }
80
+
81
+ // Deduplicate by fileName (same emoji may appear multiple times)
82
+ const unique = new Map<string, EmojiOverlay>();
83
+ for (const o of overlays) {
84
+ if (!unique.has(o.fileName)) unique.set(o.fileName, o);
85
+ }
86
+
87
+ await Promise.all(
88
+ Array.from(unique.values()).map(async (overlay) => {
89
+ const localPath = `${LOCAL_EMOJI_DIR}/${overlay.fileName}`;
90
+ if (existsSync(localPath)) return;
91
+ const res = await fetch(overlay.url);
92
+ if (!res.ok)
93
+ throw new Error(
94
+ `Failed to download emoji ${overlay.fileName}: ${res.status}`,
95
+ );
96
+ writeFileSync(localPath, new Uint8Array(await res.arrayBuffer()));
97
+ }),
98
+ );
99
+
100
+ return overlays.map((o) => `${LOCAL_EMOJI_DIR}/${o.fileName}`);
25
101
  }
26
102
 
27
103
  /**
@@ -32,51 +108,169 @@ export interface CaptionOverlayOptions {
32
108
  * FFmpeg backends - when using a cloud backend, input files are automatically
33
109
  * uploaded to storage.
34
110
  *
35
- * @param options - Configuration for the caption burn operation
36
- * @param options.video - Source video as {@link FFmpegOutput} (file path or URL)
37
- * @param options.assPath - Path to the ASS subtitle file to burn
38
- * @param options.outputPath - Destination path for the output video (defaults to "output.mp4")
39
- * @param options.backend - Optional {@link FFmpegBackend} for cloud processing; uses local FFmpeg if omitted
40
- * @param options.verbose - Enable verbose FFmpeg logging
41
- *
42
- * @returns Promise resolving to {@link FFmpegOutput} containing the path or URL of the captioned video
111
+ * When `fontFiles` are provided:
112
+ * - **Local backend**: fonts are downloaded to a temp directory, and `fontsdir=`
113
+ * is appended to the subtitles filter so FFmpeg/libass can find them.
114
+ * - **Cloud backend (Rendi)**: font files are passed as `auxiliaryFiles` in the
115
+ * run options. The backend bundles them into a compressed input folder and
116
+ * uses `fontsdir=.` so FFmpeg finds them in the working directory.
43
117
  *
44
- * @throws May throw if FFmpeg execution fails, input files are missing, or upload fails for cloud backends
118
+ * When `emojiOverlays` are provided, the function switches from a simple `-vf`
119
+ * filter to a `filter_complex` that first burns subtitles, then overlays each
120
+ * emoji PNG at the calculated position with timing. Emoji PNGs are added as
121
+ * additional FFmpeg inputs referenced by stream index in the filter graph.
45
122
  *
46
- * @example
47
- * ```ts
48
- * const result = await burnCaptions({
49
- * video: { type: "file", path: "input.mp4" },
50
- * assPath: "captions.ass",
51
- * outputPath: "output-with-captions.mp4",
52
- * });
53
- * ```
123
+ * @param options - Configuration for the caption burn operation
124
+ * @returns Promise resolving to {@link FFmpegOutput} containing the path or URL of the captioned video
54
125
  */
55
126
  export async function burnCaptions(
56
127
  options: CaptionOverlayOptions,
57
128
  ): Promise<FFmpegOutput> {
58
- const { video, assPath, outputPath = "output.mp4", verbose } = options;
59
- const captions: FFmpegOutput = { type: "file", path: assPath };
60
-
129
+ const {
130
+ video,
131
+ assPath,
132
+ outputPath = "output.mp4",
133
+ verbose,
134
+ fontFiles,
135
+ emojiOverlays,
136
+ } = options;
61
137
  const backend = options.backend ?? localBackend;
62
138
 
139
+ const isCloud = backend.name !== "local";
140
+ const hasFonts = fontFiles && fontFiles.length > 0;
141
+ const hasEmoji = emojiOverlays && emojiOverlays.length > 0;
142
+
63
143
  const videoInput = await resolveInputPathMaybeUpload(video, backend);
64
- const assInput = await resolveInputPathMaybeUpload(captions, backend);
65
144
 
66
- // For cloud backends (Rendi): pass raw URL so replaceWithPlaceholders() can match
67
- // and replace with {{in_X}} placeholder. Rendi downloads inputs and provides local paths.
68
- // For local backend: escape for FFmpeg filter syntax (backslashes and colons)
69
- const isCloud = backend.name !== "local";
70
- const subtitlesPath = isCloud
71
- ? assInput
72
- : assInput.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
145
+ // For local backends, resolve the ASS path via the backend (may return
146
+ // a local path or uploaded URL). For cloud backends, the ASS file is
147
+ // bundled into the compressed folder as raw bytes no upload needed.
148
+ // (fal.storage rejects .ass files as "unsupported file format")
149
+ const assInput = isCloud
150
+ ? assPath
151
+ : await resolveInputPathMaybeUpload({ type: "file", path: assPath }, backend);
152
+
153
+ // Build the subtitles filter string (used in both simple and complex modes)
154
+ //
155
+ // Cloud backends (Rendi) always use compressed folder mode for captions.
156
+ // The subtitles= filter needs the ASS file accessible by filename in the
157
+ // working directory — Rendi's standard input_files mode uses {{in_N}}
158
+ // placeholders which libass can't resolve as file paths. By always passing
159
+ // the ASS file as an auxiliaryFile, we force runWithCompressedFolder() which
160
+ // extracts all files into the working directory.
161
+ let subtitlesFilter: string;
162
+ // For cloud backends, derive the bare ASS filename for the compressed folder.
163
+ const assFileName = isCloud
164
+ ? (assPath.split("/").pop() ?? "captions.ass")
165
+ : undefined;
166
+
167
+ if (isCloud) {
168
+ // Cloud: bare ASS filename, fontsdir=. if fonts are present
169
+ subtitlesFilter = hasFonts
170
+ ? `subtitles=${assFileName}:fontsdir=.`
171
+ : `subtitles=${assFileName}`;
172
+ } else if (hasFonts) {
173
+ // Local with fonts: download fonts, escape paths for FFmpeg filter syntax
174
+ const fontsDir = await ensureLocalFonts(fontFiles);
175
+ const escapedFontsDir = fontsDir
176
+ .replace(/\\/g, "\\\\")
177
+ .replace(/:/g, "\\:");
178
+ const escapedAssPath = assInput
179
+ .replace(/\\/g, "\\\\")
180
+ .replace(/:/g, "\\:");
181
+ subtitlesFilter = `subtitles=${escapedAssPath}:fontsdir=${escapedFontsDir}`;
182
+ } else {
183
+ // Local without fonts: just the ASS path
184
+ const escapedAssPath = assInput
185
+ .replace(/\\/g, "\\\\")
186
+ .replace(/:/g, "\\:");
187
+ subtitlesFilter = `subtitles=${escapedAssPath}`;
188
+ }
189
+
190
+ // Build auxiliary files for cloud backends.
191
+ // Always include the ASS file so we force compressed folder mode (the ASS
192
+ // file must be in the working directory for the subtitles= filter to find it).
193
+ // The ASS file is passed as raw bytes (data) to avoid uploading to fal storage
194
+ // which rejects .ass files as "unsupported file format".
195
+ // Font files are passed as URLs (they're on S3 and download fine).
196
+ const auxiliaryFiles = isCloud
197
+ ? [
198
+ // ASS file — read from disk as raw bytes, no upload needed
199
+ { data: new Uint8Array(readFileSync(assPath)), fileName: assFileName! },
200
+ ...(hasFonts
201
+ ? fontFiles.map((f) => ({ url: f.url, fileName: f.fileName }))
202
+ : []),
203
+ ]
204
+ : undefined;
205
+
206
+ if (hasEmoji) {
207
+ // ---- Emoji overlay mode: use filter_complex ----
208
+ //
209
+ // Cloud backends: ASS file is in the compressed folder (via auxiliaryFiles),
210
+ // NOT as an -i input. The subtitles= filter reads it by filename from the
211
+ // working directory. Emoji PNGs are added as inputs starting at [1].
212
+ //
213
+ // Local backend: ASS is an -i input at [1] (needed for some ffmpeg builds
214
+ // that require it). Emoji PNGs start at [2].
215
+ const inputs: FFmpegInput[] = isCloud ? [videoInput] : [videoInput, assInput];
216
+ const emojiInputOffset = isCloud ? 1 : 2;
217
+
218
+ if (isCloud) {
219
+ // For Rendi: emoji PNGs are added as inputs (they'll be in the ZIP)
220
+ for (const overlay of emojiOverlays) {
221
+ inputs.push(overlay.url);
222
+ }
223
+ } else {
224
+ // For local: download emoji PNGs and use local paths
225
+ const localPaths = await ensureLocalEmoji(emojiOverlays);
226
+ for (const localPath of localPaths) {
227
+ inputs.push(localPath);
228
+ }
229
+ }
230
+
231
+ const filterComplex = buildEmojiFilterComplex(
232
+ subtitlesFilter,
233
+ emojiOverlays,
234
+ emojiInputOffset,
235
+ );
236
+
237
+ if (verbose) {
238
+ console.log(
239
+ `[burn-captions] emoji overlay mode: ${emojiOverlays.length} emoji, filter_complex length=${filterComplex.length}`,
240
+ );
241
+ }
242
+
243
+ const result = await backend.run({
244
+ inputs,
245
+ filterComplex,
246
+ outputArgs: [
247
+ "-map", "[vout]",
248
+ "-map", "0:a?",
249
+ "-crf", "18",
250
+ "-preset", "fast",
251
+ ],
252
+ outputPath,
253
+ verbose,
254
+ auxiliaryFiles,
255
+ });
256
+
257
+ return result.output;
258
+ }
73
259
 
260
+ // ---- Simple mode: no emoji, just subtitles filter ----
261
+ //
262
+ // Cloud backends: only the video is an -i input. The ASS file is in the
263
+ // compressed folder (via auxiliaryFiles) and referenced by the subtitles=
264
+ // filter by bare filename.
265
+ //
266
+ // Local backend: both video and ASS are -i inputs.
74
267
  const result = await backend.run({
75
- inputs: [videoInput, assInput],
76
- videoFilter: `subtitles=${subtitlesPath}`,
268
+ inputs: isCloud ? [videoInput] : [videoInput, assInput],
269
+ videoFilter: subtitlesFilter,
77
270
  outputArgs: ["-crf", "18", "-preset", "fast", "-c:a", "copy"],
78
271
  outputPath,
79
272
  verbose,
273
+ auxiliaryFiles,
80
274
  });
81
275
 
82
276
  return result.output;