vargai 0.4.0-alpha105 → 0.4.0-alpha106
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 +4 -1
- package/src/ai-sdk/providers/editly/backends/types.ts +5 -0
- package/src/ai-sdk/providers/editly/rendi/index.ts +204 -0
- package/src/react/renderers/burn-captions.ts +224 -30
- package/src/react/renderers/captions.ts +258 -19
- package/src/react/renderers/emoji-position-test.ts +458 -0
- package/src/react/renderers/emoji.ts +297 -0
- package/src/react/renderers/fonts.ts +509 -0
- package/src/react/renderers/render.ts +31 -0
- package/src/react/renderers/text-measure.ts +645 -0
- package/src/react/types.ts +3 -0
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-
|
|
110
|
+
"version": "0.4.0-alpha106",
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
47
|
-
*
|
|
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 {
|
|
59
|
-
|
|
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
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
?
|
|
72
|
-
:
|
|
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:
|
|
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;
|