vargai 0.4.0-alpha31 → 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 +1 -1
- package/src/ai-sdk/providers/editly/backends/index.ts +7 -0
- package/src/ai-sdk/providers/editly/backends/local.ts +65 -0
- package/src/ai-sdk/providers/editly/backends/types.ts +54 -0
- package/src/ai-sdk/providers/editly/editly.test.ts +3 -1
- package/src/ai-sdk/providers/editly/index.ts +37 -14
- package/src/ai-sdk/providers/editly/rendi/editly-with-rendi-backend.test.ts +335 -0
- package/src/ai-sdk/providers/editly/rendi/index.ts +277 -0
- package/src/ai-sdk/providers/editly/rendi/rendi.test.ts +46 -0
- package/src/ai-sdk/providers/editly/types.ts +12 -0
- package/src/react/renderers/burn-captions.ts +107 -0
- package/src/react/renderers/render.ts +31 -6
- package/src/react/types.ts +2 -0
- package/src/ai-sdk/providers/editly/ffmpeg.ts +0 -60
package/package.json
CHANGED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
import type {
|
|
3
|
+
FFmpegBackend,
|
|
4
|
+
FFmpegRunOptions,
|
|
5
|
+
FFmpegRunResult,
|
|
6
|
+
VideoInfo,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
const FFMPEG_COMMON_ARGS = ["-hide_banner", "-loglevel", "error"];
|
|
10
|
+
|
|
11
|
+
export class LocalBackend implements FFmpegBackend {
|
|
12
|
+
readonly name = "local";
|
|
13
|
+
|
|
14
|
+
async ffprobe(input: string): Promise<VideoInfo> {
|
|
15
|
+
const result =
|
|
16
|
+
await $`ffprobe -v error -show_entries stream=width,height,r_frame_rate,codec_type -show_entries format=duration -of json ${input}`.json();
|
|
17
|
+
|
|
18
|
+
const videoStream = result.streams?.find(
|
|
19
|
+
(s: { codec_type: string }) => s.codec_type === "video",
|
|
20
|
+
);
|
|
21
|
+
const parsedDuration = parseFloat(result.format?.duration ?? "0");
|
|
22
|
+
const duration = Number.isFinite(parsedDuration) ? parsedDuration : 0;
|
|
23
|
+
|
|
24
|
+
let fps: number | undefined;
|
|
25
|
+
const framerateStr: string | undefined = videoStream?.r_frame_rate;
|
|
26
|
+
if (framerateStr) {
|
|
27
|
+
const parts = framerateStr.split("/").map(Number);
|
|
28
|
+
const num = parts[0];
|
|
29
|
+
const den = parts[1];
|
|
30
|
+
if (den && den > 0 && num) fps = num / den;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
duration,
|
|
35
|
+
width: videoStream?.width,
|
|
36
|
+
height: videoStream?.height,
|
|
37
|
+
fps,
|
|
38
|
+
framerateStr,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async run(options: FFmpegRunOptions): Promise<FFmpegRunResult> {
|
|
43
|
+
const { args, outputPath, verbose } = options;
|
|
44
|
+
|
|
45
|
+
const ffmpegArgs = [
|
|
46
|
+
...FFMPEG_COMMON_ARGS.slice(0, 2),
|
|
47
|
+
verbose ? "info" : "error",
|
|
48
|
+
...args,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
if (verbose) {
|
|
52
|
+
console.log("ffmpeg", ffmpegArgs.join(" "));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const result = await $`ffmpeg ${ffmpegArgs}`.quiet();
|
|
56
|
+
|
|
57
|
+
if (result.exitCode !== 0) {
|
|
58
|
+
throw new Error(`ffmpeg failed with exit code ${result.exitCode}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { output: { type: "file", path: outputPath } };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const localBackend = new LocalBackend();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFmpeg backend abstraction for dependency injection
|
|
3
|
+
* Allows switching between local ffmpeg and cloud services like Rendi
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { VideoInfo } from "../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Represents the result of running ffprobe
|
|
10
|
+
*/
|
|
11
|
+
export type { VideoInfo };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* FFmpeg execution options
|
|
15
|
+
*/
|
|
16
|
+
export interface FFmpegRunOptions {
|
|
17
|
+
/** ffmpeg arguments (without the 'ffmpeg' command itself) */
|
|
18
|
+
args: string[];
|
|
19
|
+
/** List of input file paths (local or URLs) */
|
|
20
|
+
inputs: string[];
|
|
21
|
+
/** Output file path */
|
|
22
|
+
outputPath: string;
|
|
23
|
+
/** Enable verbose logging */
|
|
24
|
+
verbose?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type FFmpegOutput =
|
|
28
|
+
| { type: "file"; path: string }
|
|
29
|
+
| { type: "url"; url: string };
|
|
30
|
+
|
|
31
|
+
export interface FFmpegRunResult {
|
|
32
|
+
output: FFmpegOutput;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Backend interface for ffmpeg/ffprobe execution
|
|
37
|
+
*/
|
|
38
|
+
export interface FFmpegBackend {
|
|
39
|
+
/** Backend name for identification */
|
|
40
|
+
readonly name: string;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run ffprobe to get media file info
|
|
44
|
+
* @param input - File path (local) or URL
|
|
45
|
+
*/
|
|
46
|
+
ffprobe(input: string): Promise<VideoInfo>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run ffmpeg command
|
|
50
|
+
* @param options - Execution options including args, inputs, and output path
|
|
51
|
+
* @returns Result with optional URL for cloud backends
|
|
52
|
+
*/
|
|
53
|
+
run(options: FFmpegRunOptions): Promise<FFmpegRunResult>;
|
|
54
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { existsSync, unlinkSync } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { localBackend } from "./backends/local";
|
|
4
4
|
import { editly } from "./index";
|
|
5
5
|
|
|
6
6
|
const VIDEO_1 = "output/sora-landscape.mp4";
|
|
@@ -9,6 +9,8 @@ const VIDEO_TALKING = "output/workflow-talking-synced.mp4";
|
|
|
9
9
|
const IMAGE_SQUARE = "media/replicate-forest.png";
|
|
10
10
|
const IMAGE_PORTRAIT = "media/madi-portrait.png";
|
|
11
11
|
|
|
12
|
+
const ffprobe = localBackend.ffprobe;
|
|
13
|
+
|
|
12
14
|
describe("editly", () => {
|
|
13
15
|
test("requires outPath", async () => {
|
|
14
16
|
await expect(
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { ffprobe, multipleOf2 } from "./ffmpeg";
|
|
1
|
+
import { type FFmpegBackend, localBackend } from "./backends";
|
|
3
2
|
import {
|
|
4
3
|
getImageOverlayFilter,
|
|
5
4
|
getImageOverlayPositionFilter,
|
|
@@ -18,6 +17,7 @@ import type {
|
|
|
18
17
|
Clip,
|
|
19
18
|
DetachedAudioLayer,
|
|
20
19
|
EditlyConfig,
|
|
20
|
+
EditlyResult,
|
|
21
21
|
ImageOverlayLayer,
|
|
22
22
|
Layer,
|
|
23
23
|
NewsTitleLayer,
|
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
VideoLayer,
|
|
29
29
|
} from "./types";
|
|
30
30
|
|
|
31
|
+
export * from "./backends";
|
|
31
32
|
export * from "./types";
|
|
32
33
|
|
|
33
34
|
const DEFAULT_DURATION = 4;
|
|
@@ -36,12 +37,22 @@ const DEFAULT_FPS = 30;
|
|
|
36
37
|
const DEFAULT_WIDTH = 1280;
|
|
37
38
|
const DEFAULT_HEIGHT = 720;
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
function multipleOf2(n: number): number {
|
|
41
|
+
return Math.round(n / 2) * 2;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function getVideoDuration(
|
|
45
|
+
path: string,
|
|
46
|
+
backend: FFmpegBackend,
|
|
47
|
+
): Promise<number> {
|
|
48
|
+
const info = await backend.ffprobe(path);
|
|
41
49
|
return info.duration;
|
|
42
50
|
}
|
|
43
51
|
|
|
44
|
-
async function getFirstVideoInfo(
|
|
52
|
+
async function getFirstVideoInfo(
|
|
53
|
+
clips: Clip[],
|
|
54
|
+
backend: FFmpegBackend,
|
|
55
|
+
): Promise<{
|
|
45
56
|
width?: number;
|
|
46
57
|
height?: number;
|
|
47
58
|
fps?: number;
|
|
@@ -49,7 +60,7 @@ async function getFirstVideoInfo(clips: Clip[]): Promise<{
|
|
|
49
60
|
for (const clip of clips) {
|
|
50
61
|
for (const layer of clip.layers) {
|
|
51
62
|
if (layer.type === "video") {
|
|
52
|
-
const info = await ffprobe((layer as VideoLayer).path);
|
|
63
|
+
const info = await backend.ffprobe((layer as VideoLayer).path);
|
|
53
64
|
return { width: info.width, height: info.height, fps: info.fps };
|
|
54
65
|
}
|
|
55
66
|
}
|
|
@@ -72,6 +83,7 @@ function applyLayerDefaults(
|
|
|
72
83
|
async function processClips(
|
|
73
84
|
clips: Clip[],
|
|
74
85
|
defaults: EditlyConfig["defaults"],
|
|
86
|
+
backend: FFmpegBackend,
|
|
75
87
|
): Promise<ProcessedClip[]> {
|
|
76
88
|
const processed: ProcessedClip[] = [];
|
|
77
89
|
const defaultDuration = defaults?.duration ?? DEFAULT_DURATION;
|
|
@@ -86,7 +98,7 @@ async function processClips(
|
|
|
86
98
|
for (const layer of layers) {
|
|
87
99
|
if (layer.type === "video" && !clip.duration) {
|
|
88
100
|
const videoLayer = layer as VideoLayer;
|
|
89
|
-
const videoDuration = await getVideoDuration(videoLayer.path);
|
|
101
|
+
const videoDuration = await getVideoDuration(videoLayer.path, backend);
|
|
90
102
|
const cutFrom = videoLayer.cutFrom ?? 0;
|
|
91
103
|
const cutTo = videoLayer.cutTo ?? videoDuration;
|
|
92
104
|
duration = cutTo - cutFrom;
|
|
@@ -522,7 +534,7 @@ function buildAudioFilter(
|
|
|
522
534
|
};
|
|
523
535
|
}
|
|
524
536
|
|
|
525
|
-
export async function editly(config: EditlyConfig): Promise<
|
|
537
|
+
export async function editly(config: EditlyConfig): Promise<EditlyResult> {
|
|
526
538
|
const {
|
|
527
539
|
outPath,
|
|
528
540
|
clips: clipsIn,
|
|
@@ -539,11 +551,17 @@ export async function editly(config: EditlyConfig): Promise<void> {
|
|
|
539
551
|
fast,
|
|
540
552
|
} = config;
|
|
541
553
|
|
|
554
|
+
const backend: FFmpegBackend = config.backend ?? localBackend;
|
|
555
|
+
|
|
556
|
+
if (verbose) {
|
|
557
|
+
console.log(`[editly] using backend: ${backend.name}`);
|
|
558
|
+
}
|
|
559
|
+
|
|
542
560
|
if (!clipsIn || clipsIn.length === 0) {
|
|
543
561
|
throw new Error("At least one clip is required");
|
|
544
562
|
}
|
|
545
563
|
|
|
546
|
-
const firstVideoInfo = await getFirstVideoInfo(clipsIn);
|
|
564
|
+
const firstVideoInfo = await getFirstVideoInfo(clipsIn, backend);
|
|
547
565
|
let width = config.width ?? firstVideoInfo.width ?? DEFAULT_WIDTH;
|
|
548
566
|
let height = config.height ?? firstVideoInfo.height ?? DEFAULT_HEIGHT;
|
|
549
567
|
const fps = config.fps ?? firstVideoInfo.fps ?? DEFAULT_FPS;
|
|
@@ -561,7 +579,7 @@ export async function editly(config: EditlyConfig): Promise<void> {
|
|
|
561
579
|
console.log(`Output: ${width}x${height} @ ${fps}fps`);
|
|
562
580
|
}
|
|
563
581
|
|
|
564
|
-
const clips = await processClips(clipsIn, defaults);
|
|
582
|
+
const clips = await processClips(clipsIn, defaults, backend);
|
|
565
583
|
|
|
566
584
|
const continuousVideoOverlays = collectContinuousVideoOverlays(clips);
|
|
567
585
|
const imageOverlays = collectImageOverlays(clips);
|
|
@@ -862,13 +880,18 @@ export async function editly(config: EditlyConfig): Promise<void> {
|
|
|
862
880
|
console.log("\nFilter complex:\n", filterComplex.split(";").join(";\n"));
|
|
863
881
|
}
|
|
864
882
|
|
|
865
|
-
const result = await
|
|
883
|
+
const result = await backend.run({
|
|
884
|
+
args: ffmpegArgs,
|
|
885
|
+
inputs: allInputs,
|
|
886
|
+
outputPath: outPath,
|
|
887
|
+
verbose,
|
|
888
|
+
});
|
|
866
889
|
|
|
867
|
-
if (result.
|
|
868
|
-
|
|
890
|
+
if (result.output.type === "file" && verbose) {
|
|
891
|
+
console.log(`Output: ${result.output.path}`);
|
|
869
892
|
}
|
|
870
893
|
|
|
871
|
-
|
|
894
|
+
return { output: result.output };
|
|
872
895
|
}
|
|
873
896
|
|
|
874
897
|
export default editly;
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendi backend tests - same as editly.test.ts but uses cloud ffmpeg
|
|
3
|
+
*
|
|
4
|
+
* NOTE: Free tier has 4 commands/min rate limit. Run tests individually:
|
|
5
|
+
* bun test src/ai-sdk/providers/editly/rendi/editly-with-rendi-backend.test.ts -t "merges two"
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { $ } from "bun";
|
|
10
|
+
import { editly } from "../index";
|
|
11
|
+
import { createRendiBackend } from ".";
|
|
12
|
+
|
|
13
|
+
const shouldRunRendiTests =
|
|
14
|
+
!!process.env.RENDI_INTEGRATION_TESTS && !!process.env.RENDI_API_KEY;
|
|
15
|
+
|
|
16
|
+
const VIDEO_1 = "https://s3.varg.ai/test-media/sora-landscape.mp4";
|
|
17
|
+
const VIDEO_2 = "https://s3.varg.ai/test-media/simpsons-scene.mp4";
|
|
18
|
+
const VIDEO_TALKING =
|
|
19
|
+
"https://s3.varg.ai/test-media/workflow-talking-synced.mp4";
|
|
20
|
+
const IMAGE_SQUARE = "https://s3.varg.ai/test-media/replicate-forest.png";
|
|
21
|
+
|
|
22
|
+
const rendi = shouldRunRendiTests ? createRendiBackend() : (null as never);
|
|
23
|
+
|
|
24
|
+
async function saveResult(
|
|
25
|
+
result: {
|
|
26
|
+
output: { type: "url"; url: string } | { type: "file"; path: string };
|
|
27
|
+
},
|
|
28
|
+
outPath: string,
|
|
29
|
+
) {
|
|
30
|
+
expect(result.output.type).toBe("url");
|
|
31
|
+
if (result.output.type === "url") {
|
|
32
|
+
expect(result.output.url).toMatch(/^https:\/\//);
|
|
33
|
+
const res = await fetch(result.output.url);
|
|
34
|
+
if (!res.ok) throw new Error(`Failed to download: ${res.status}`);
|
|
35
|
+
|
|
36
|
+
const dir = outPath.split("/").slice(0, -1).join("/");
|
|
37
|
+
await $`mkdir -p ${dir}`.quiet();
|
|
38
|
+
|
|
39
|
+
const bytes = await res.arrayBuffer();
|
|
40
|
+
await Bun.write(outPath, bytes);
|
|
41
|
+
|
|
42
|
+
const written = Bun.file(outPath);
|
|
43
|
+
if (!(await written.exists()) || written.size === 0) {
|
|
44
|
+
throw new Error(`Failed to write output file: ${outPath}`);
|
|
45
|
+
}
|
|
46
|
+
console.log(`Output: ${outPath}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe.skipIf(!shouldRunRendiTests)("editly (rendi backend)", () => {
|
|
51
|
+
test("merges two videos with fade transition", async () => {
|
|
52
|
+
const outPath = "output/rendi/merge.mp4";
|
|
53
|
+
const result = await editly({
|
|
54
|
+
outPath,
|
|
55
|
+
backend: rendi,
|
|
56
|
+
width: 1280,
|
|
57
|
+
height: 720,
|
|
58
|
+
fps: 30,
|
|
59
|
+
clips: [
|
|
60
|
+
{
|
|
61
|
+
layers: [{ type: "video", path: VIDEO_1 }],
|
|
62
|
+
transition: { name: "fade", duration: 0.5 },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
layers: [{ type: "video", path: VIDEO_2 }],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await saveResult(result, outPath);
|
|
71
|
+
}, 120000);
|
|
72
|
+
|
|
73
|
+
test("picture-in-picture (pip)", async () => {
|
|
74
|
+
const outPath = "output/rendi/pip.mp4";
|
|
75
|
+
const result = await editly({
|
|
76
|
+
outPath,
|
|
77
|
+
backend: rendi,
|
|
78
|
+
width: 1280,
|
|
79
|
+
height: 720,
|
|
80
|
+
fps: 30,
|
|
81
|
+
clips: [
|
|
82
|
+
{
|
|
83
|
+
duration: 3,
|
|
84
|
+
layers: [
|
|
85
|
+
{ type: "video", path: VIDEO_1 },
|
|
86
|
+
{
|
|
87
|
+
type: "video",
|
|
88
|
+
path: VIDEO_2,
|
|
89
|
+
width: "30%",
|
|
90
|
+
height: "30%",
|
|
91
|
+
left: "68%",
|
|
92
|
+
top: "2%",
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await saveResult(result, outPath);
|
|
100
|
+
}, 120000);
|
|
101
|
+
|
|
102
|
+
test("image ken burns preserves aspect ratio", async () => {
|
|
103
|
+
const outPath = "output/rendi/ken-burns.mp4";
|
|
104
|
+
const result = await editly({
|
|
105
|
+
outPath,
|
|
106
|
+
backend: rendi,
|
|
107
|
+
width: 1280,
|
|
108
|
+
height: 720,
|
|
109
|
+
fps: 30,
|
|
110
|
+
clips: [
|
|
111
|
+
{
|
|
112
|
+
duration: 3,
|
|
113
|
+
layers: [
|
|
114
|
+
{
|
|
115
|
+
type: "image",
|
|
116
|
+
path: IMAGE_SQUARE,
|
|
117
|
+
zoomDirection: "in",
|
|
118
|
+
zoomAmount: 0.1,
|
|
119
|
+
resizeMode: "contain",
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await saveResult(result, outPath);
|
|
127
|
+
}, 120000);
|
|
128
|
+
|
|
129
|
+
test("subtitle layer", async () => {
|
|
130
|
+
const outPath = "output/rendi/subtitle.mp4";
|
|
131
|
+
const result = await editly({
|
|
132
|
+
outPath,
|
|
133
|
+
backend: rendi,
|
|
134
|
+
width: 1280,
|
|
135
|
+
height: 720,
|
|
136
|
+
fps: 30,
|
|
137
|
+
clips: [
|
|
138
|
+
{
|
|
139
|
+
duration: 3,
|
|
140
|
+
layers: [
|
|
141
|
+
{ type: "video", path: VIDEO_1 },
|
|
142
|
+
{
|
|
143
|
+
type: "subtitle",
|
|
144
|
+
text: "This is a subtitle at the bottom",
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
duration: 3,
|
|
150
|
+
layers: [
|
|
151
|
+
{ type: "video", path: VIDEO_2 },
|
|
152
|
+
{
|
|
153
|
+
type: "subtitle",
|
|
154
|
+
text: "Another subtitle with custom colors",
|
|
155
|
+
textColor: "yellow",
|
|
156
|
+
backgroundColor: "blue@0.8",
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await saveResult(result, outPath);
|
|
164
|
+
}, 120000);
|
|
165
|
+
|
|
166
|
+
test("news-title layer", async () => {
|
|
167
|
+
const outPath = "output/rendi/news-title.mp4";
|
|
168
|
+
const result = await editly({
|
|
169
|
+
outPath,
|
|
170
|
+
backend: rendi,
|
|
171
|
+
width: 1280,
|
|
172
|
+
height: 720,
|
|
173
|
+
fps: 30,
|
|
174
|
+
clips: [
|
|
175
|
+
{
|
|
176
|
+
duration: 3,
|
|
177
|
+
layers: [
|
|
178
|
+
{ type: "video", path: VIDEO_1 },
|
|
179
|
+
{
|
|
180
|
+
type: "news-title",
|
|
181
|
+
text: "BREAKING NEWS: Something important happened",
|
|
182
|
+
backgroundColor: "red",
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
duration: 3,
|
|
188
|
+
layers: [
|
|
189
|
+
{ type: "video", path: VIDEO_2 },
|
|
190
|
+
{
|
|
191
|
+
type: "news-title",
|
|
192
|
+
text: "TOP STORY",
|
|
193
|
+
backgroundColor: "blue",
|
|
194
|
+
position: "top",
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await saveResult(result, outPath);
|
|
202
|
+
}, 120000);
|
|
203
|
+
|
|
204
|
+
test("keepSourceAudio preserves original video audio", async () => {
|
|
205
|
+
const outPath = "output/rendi/keep-audio.mp4";
|
|
206
|
+
const result = await editly({
|
|
207
|
+
outPath,
|
|
208
|
+
backend: rendi,
|
|
209
|
+
width: 1280,
|
|
210
|
+
height: 720,
|
|
211
|
+
fps: 30,
|
|
212
|
+
keepSourceAudio: true,
|
|
213
|
+
clips: [
|
|
214
|
+
{
|
|
215
|
+
layers: [
|
|
216
|
+
{ type: "video", path: VIDEO_TALKING },
|
|
217
|
+
{ type: "subtitle", text: "Original audio should play" },
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await saveResult(result, outPath);
|
|
224
|
+
}, 120000);
|
|
225
|
+
|
|
226
|
+
test("keepSourceAudio with cutFrom stays in sync", async () => {
|
|
227
|
+
const outPath = "output/rendi/keep-audio-cut.mp4";
|
|
228
|
+
const result = await editly({
|
|
229
|
+
outPath,
|
|
230
|
+
backend: rendi,
|
|
231
|
+
width: 1280,
|
|
232
|
+
height: 720,
|
|
233
|
+
fps: 30,
|
|
234
|
+
keepSourceAudio: true,
|
|
235
|
+
clips: [
|
|
236
|
+
{
|
|
237
|
+
layers: [
|
|
238
|
+
{ type: "video", path: VIDEO_TALKING, cutFrom: 2, cutTo: 6 },
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await saveResult(result, outPath);
|
|
245
|
+
}, 120000);
|
|
246
|
+
|
|
247
|
+
test("contain-blur resize mode for video", async () => {
|
|
248
|
+
const outPath = "output/rendi/contain-blur.mp4";
|
|
249
|
+
const result = await editly({
|
|
250
|
+
outPath,
|
|
251
|
+
backend: rendi,
|
|
252
|
+
width: 1080,
|
|
253
|
+
height: 1920,
|
|
254
|
+
fps: 30,
|
|
255
|
+
clips: [
|
|
256
|
+
{
|
|
257
|
+
duration: 3,
|
|
258
|
+
layers: [
|
|
259
|
+
{ type: "video", path: VIDEO_1, resizeMode: "contain-blur" },
|
|
260
|
+
],
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await saveResult(result, outPath);
|
|
266
|
+
}, 120000);
|
|
267
|
+
|
|
268
|
+
test("video overlay with cropPosition", async () => {
|
|
269
|
+
const outPath = "output/rendi/crop-position.mp4";
|
|
270
|
+
const result = await editly({
|
|
271
|
+
outPath,
|
|
272
|
+
backend: rendi,
|
|
273
|
+
width: 1080,
|
|
274
|
+
height: 1920,
|
|
275
|
+
fps: 30,
|
|
276
|
+
clips: [
|
|
277
|
+
{
|
|
278
|
+
duration: 3,
|
|
279
|
+
layers: [
|
|
280
|
+
{ type: "fill-color", color: "#000000" },
|
|
281
|
+
{
|
|
282
|
+
type: "video",
|
|
283
|
+
path: VIDEO_1,
|
|
284
|
+
width: 1080,
|
|
285
|
+
height: 960,
|
|
286
|
+
left: 0,
|
|
287
|
+
top: 0,
|
|
288
|
+
resizeMode: "cover",
|
|
289
|
+
cropPosition: "top",
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
type: "video",
|
|
293
|
+
path: VIDEO_2,
|
|
294
|
+
width: 1080,
|
|
295
|
+
height: 960,
|
|
296
|
+
left: 0,
|
|
297
|
+
top: 960,
|
|
298
|
+
resizeMode: "cover",
|
|
299
|
+
cropPosition: "bottom",
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await saveResult(result, outPath);
|
|
307
|
+
}, 120000);
|
|
308
|
+
|
|
309
|
+
test("portrait 9:16 image with zoompan cover mode", async () => {
|
|
310
|
+
const outPath = "output/rendi/portrait-zoompan.mp4";
|
|
311
|
+
const result = await editly({
|
|
312
|
+
outPath,
|
|
313
|
+
backend: rendi,
|
|
314
|
+
width: 1080,
|
|
315
|
+
height: 1920,
|
|
316
|
+
fps: 30,
|
|
317
|
+
clips: [
|
|
318
|
+
{
|
|
319
|
+
duration: 3,
|
|
320
|
+
layers: [
|
|
321
|
+
{
|
|
322
|
+
type: "image",
|
|
323
|
+
path: IMAGE_SQUARE,
|
|
324
|
+
zoomDirection: "in",
|
|
325
|
+
zoomAmount: 0.1,
|
|
326
|
+
resizeMode: "cover",
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await saveResult(result, outPath);
|
|
334
|
+
}, 120000);
|
|
335
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FFmpegBackend,
|
|
3
|
+
FFmpegRunOptions,
|
|
4
|
+
FFmpegRunResult,
|
|
5
|
+
VideoInfo,
|
|
6
|
+
} from "../backends/types";
|
|
7
|
+
|
|
8
|
+
const RENDI_API_BASE = "https://api.rendi.dev/v1";
|
|
9
|
+
const POLL_INTERVAL_MS = 2000;
|
|
10
|
+
const MAX_POLL_ATTEMPTS = 300;
|
|
11
|
+
const DEFAULT_MAX_COMMAND_SECONDS = 60;
|
|
12
|
+
|
|
13
|
+
interface RendiCommandResponse {
|
|
14
|
+
command_id: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RendiStoredFile {
|
|
18
|
+
file_id: string;
|
|
19
|
+
storage_url: string | null;
|
|
20
|
+
status: string;
|
|
21
|
+
duration?: number;
|
|
22
|
+
width?: number;
|
|
23
|
+
height?: number;
|
|
24
|
+
frame_rate?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RendiStatusResponse {
|
|
28
|
+
command_id: string;
|
|
29
|
+
status: "QUEUED" | "PROCESSING" | "SUCCESS" | "FAILED";
|
|
30
|
+
error_message?: string;
|
|
31
|
+
output_files?: Record<string, RendiStoredFile>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class RendiBackend implements FFmpegBackend {
|
|
35
|
+
readonly name = "rendi";
|
|
36
|
+
private apiKey: string;
|
|
37
|
+
|
|
38
|
+
constructor(apiKey?: string) {
|
|
39
|
+
this.apiKey = apiKey ?? process.env.RENDI_API_KEY ?? "";
|
|
40
|
+
if (!this.apiKey) {
|
|
41
|
+
throw new Error("RENDI_API_KEY is required for Rendi backend");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async ffprobe(input: string): Promise<VideoInfo> {
|
|
46
|
+
const inputUrl = this.ensureUrl(input);
|
|
47
|
+
|
|
48
|
+
const submitResponse = await fetch(`${RENDI_API_BASE}/run-ffmpeg-command`, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: {
|
|
51
|
+
"X-API-KEY": this.apiKey,
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
input_files: { in_1: inputUrl },
|
|
56
|
+
output_files: { out_1: "probe.mp4" },
|
|
57
|
+
ffmpeg_command: "-i {{in_1}} -c copy {{out_1}}",
|
|
58
|
+
max_command_run_seconds: DEFAULT_MAX_COMMAND_SECONDS,
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!submitResponse.ok) {
|
|
63
|
+
throw new Error(`Rendi ffprobe failed: ${submitResponse.status}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { command_id } =
|
|
67
|
+
(await submitResponse.json()) as RendiCommandResponse;
|
|
68
|
+
|
|
69
|
+
let attempts = 0;
|
|
70
|
+
while (attempts < MAX_POLL_ATTEMPTS) {
|
|
71
|
+
const statusResponse = await fetch(
|
|
72
|
+
`${RENDI_API_BASE}/commands/${command_id}`,
|
|
73
|
+
{ headers: { "X-API-KEY": this.apiKey } },
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (!statusResponse.ok) {
|
|
77
|
+
throw new Error(`Rendi ffprobe poll failed: ${statusResponse.status}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const status = (await statusResponse.json()) as RendiStatusResponse;
|
|
81
|
+
|
|
82
|
+
if (status.status === "SUCCESS") {
|
|
83
|
+
const output = status.output_files?.out_1;
|
|
84
|
+
if (!output) {
|
|
85
|
+
throw new Error("rendi ffprobe completed but no output metadata");
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
duration: output.duration ?? 0,
|
|
89
|
+
width: output.width,
|
|
90
|
+
height: output.height,
|
|
91
|
+
fps: output.frame_rate,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (status.status === "FAILED") {
|
|
96
|
+
throw new Error(`Rendi ffprobe failed: ${status.error_message}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await this.sleep(POLL_INTERVAL_MS);
|
|
100
|
+
attempts++;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
throw new Error("Rendi ffprobe timed out");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async run(options: FFmpegRunOptions): Promise<FFmpegRunResult> {
|
|
107
|
+
const { args, inputs, outputPath, verbose } = options;
|
|
108
|
+
|
|
109
|
+
const uniqueInputs = [...new Set(inputs)];
|
|
110
|
+
const inputUrls = uniqueInputs.map((input) => this.ensureUrl(input));
|
|
111
|
+
|
|
112
|
+
const inputFiles: Record<string, string> = {};
|
|
113
|
+
const pathToPlaceholder = new Map<string, string>();
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < uniqueInputs.length; i++) {
|
|
116
|
+
const placeholder = `in_${i + 1}`;
|
|
117
|
+
inputFiles[placeholder] = inputUrls[i]!;
|
|
118
|
+
pathToPlaceholder.set(uniqueInputs[i]!, `{{${placeholder}}}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const commandArgs = args.map((arg) => {
|
|
122
|
+
if (arg === outputPath) {
|
|
123
|
+
return "{{out_1}}";
|
|
124
|
+
}
|
|
125
|
+
const placeholder = pathToPlaceholder.get(arg);
|
|
126
|
+
if (placeholder) {
|
|
127
|
+
return placeholder;
|
|
128
|
+
}
|
|
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;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const filteredArgs = this.stripInternalFlags(commandArgs);
|
|
139
|
+
const ffmpegCommand = this.buildCommandString(filteredArgs);
|
|
140
|
+
|
|
141
|
+
const outputFilename = outputPath?.split("/").pop() ?? "output.mp4";
|
|
142
|
+
const finalCommand = ffmpegCommand.includes("{{out_1}}")
|
|
143
|
+
? ffmpegCommand
|
|
144
|
+
: ffmpegCommand.replace(/[^\s]+\.\w+$/, "{{out_1}}");
|
|
145
|
+
|
|
146
|
+
if (verbose) {
|
|
147
|
+
console.log("[rendi] input_files:", inputFiles);
|
|
148
|
+
console.log("[rendi] ffmpeg_command:", finalCommand);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const submitResponse = await fetch(`${RENDI_API_BASE}/run-ffmpeg-command`, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: {
|
|
154
|
+
"X-API-KEY": this.apiKey,
|
|
155
|
+
"Content-Type": "application/json",
|
|
156
|
+
},
|
|
157
|
+
body: JSON.stringify({
|
|
158
|
+
input_files: inputFiles,
|
|
159
|
+
output_files: { out_1: outputFilename },
|
|
160
|
+
ffmpeg_command: finalCommand,
|
|
161
|
+
max_command_run_seconds: DEFAULT_MAX_COMMAND_SECONDS,
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (!submitResponse.ok) {
|
|
166
|
+
const errorText = await submitResponse.text();
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Rendi submit failed: ${submitResponse.status} - ${errorText}`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { command_id } =
|
|
173
|
+
(await submitResponse.json()) as RendiCommandResponse;
|
|
174
|
+
|
|
175
|
+
if (verbose) {
|
|
176
|
+
console.log("[rendi] command_id:", command_id);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let attempts = 0;
|
|
180
|
+
while (attempts < MAX_POLL_ATTEMPTS) {
|
|
181
|
+
const statusResponse = await fetch(
|
|
182
|
+
`${RENDI_API_BASE}/commands/${command_id}`,
|
|
183
|
+
{
|
|
184
|
+
headers: { "X-API-KEY": this.apiKey },
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
if (!statusResponse.ok) {
|
|
189
|
+
throw new Error(`Rendi poll failed: ${statusResponse.status}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const status = (await statusResponse.json()) as RendiStatusResponse;
|
|
193
|
+
|
|
194
|
+
if (verbose && attempts % 5 === 0) {
|
|
195
|
+
console.log("[rendi] status:", status.status);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (status.status === "SUCCESS") {
|
|
199
|
+
const outputFile = status.output_files?.out_1;
|
|
200
|
+
if (!outputFile?.storage_url) {
|
|
201
|
+
throw new Error("Rendi completed but no output URL found");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (verbose) {
|
|
205
|
+
console.log("[rendi] output url:", outputFile.storage_url);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { output: { type: "url", url: outputFile.storage_url } };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (status.status === "FAILED") {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`Rendi command failed: ${status.error_message ?? "Unknown error"}`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await this.sleep(POLL_INTERVAL_MS);
|
|
218
|
+
attempts++;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
throw new Error("Rendi command timed out");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private ensureUrl(input: string): string {
|
|
225
|
+
if (input.startsWith("http://") || input.startsWith("https://")) {
|
|
226
|
+
return input;
|
|
227
|
+
}
|
|
228
|
+
throw new Error(`Rendi backend requires URLs, got local path: ${input}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private stripInternalFlags(args: string[]): string[] {
|
|
232
|
+
const filtered: string[] = [];
|
|
233
|
+
let skipNext = false;
|
|
234
|
+
|
|
235
|
+
for (const arg of args) {
|
|
236
|
+
if (skipNext) {
|
|
237
|
+
skipNext = false;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (arg === "-hide_banner") continue;
|
|
242
|
+
if (arg === "-y") continue;
|
|
243
|
+
if (arg === "-loglevel") {
|
|
244
|
+
skipNext = true;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
filtered.push(arg);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return filtered;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private buildCommandString(args: string[]): string {
|
|
255
|
+
return args
|
|
256
|
+
.map((arg) => {
|
|
257
|
+
if (arg.startsWith("-") || arg.startsWith("{{")) {
|
|
258
|
+
return arg;
|
|
259
|
+
}
|
|
260
|
+
if (arg.includes(" ") || arg.includes(":") || arg.includes("'")) {
|
|
261
|
+
return `"${arg.replace(/"/g, '\\"')}"`;
|
|
262
|
+
}
|
|
263
|
+
return arg;
|
|
264
|
+
})
|
|
265
|
+
.join(" ");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private sleep(ms: number): Promise<void> {
|
|
269
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function createRendiBackend(apiKey?: string): RendiBackend {
|
|
274
|
+
return new RendiBackend(apiKey);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export type { FFmpegBackend } from "../backends/types";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createRendiBackend } from ".";
|
|
3
|
+
|
|
4
|
+
const hasRendiKey = !!process.env.RENDI_API_KEY;
|
|
5
|
+
|
|
6
|
+
describe.skipIf(!hasRendiKey)("rendi backend", () => {
|
|
7
|
+
test("ffprobe remote file", async () => {
|
|
8
|
+
const backend = createRendiBackend();
|
|
9
|
+
const info = await backend.ffprobe(
|
|
10
|
+
"https://storage.rendi.dev/sample/big_buck_bunny_720p_5sec_intro.mp4",
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
expect(info.duration).toBeGreaterThan(0);
|
|
14
|
+
expect(info.width).toBe(1280);
|
|
15
|
+
expect(info.height).toBe(720);
|
|
16
|
+
}, 30000);
|
|
17
|
+
|
|
18
|
+
test("run simple ffmpeg command", async () => {
|
|
19
|
+
const backend = createRendiBackend();
|
|
20
|
+
|
|
21
|
+
const result = await backend.run({
|
|
22
|
+
args: [
|
|
23
|
+
"-i",
|
|
24
|
+
"https://storage.rendi.dev/sample/big_buck_bunny_720p_5sec_intro.mp4",
|
|
25
|
+
"-t",
|
|
26
|
+
"2",
|
|
27
|
+
"-c:v",
|
|
28
|
+
"libx264",
|
|
29
|
+
"-preset",
|
|
30
|
+
"ultrafast",
|
|
31
|
+
"-y",
|
|
32
|
+
"output.mp4",
|
|
33
|
+
],
|
|
34
|
+
inputs: [
|
|
35
|
+
"https://storage.rendi.dev/sample/big_buck_bunny_720p_5sec_intro.mp4",
|
|
36
|
+
],
|
|
37
|
+
outputPath: "output.mp4",
|
|
38
|
+
verbose: true,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(result.output.type).toBe("url");
|
|
42
|
+
if (result.output.type === "url") {
|
|
43
|
+
expect(result.output.url).toMatch(/^https:\/\//);
|
|
44
|
+
}
|
|
45
|
+
}, 120000);
|
|
46
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Types from original editly (https://github.com/mifi/editly)
|
|
2
2
|
// Adapted for pure ffmpeg implementation (no fabric/canvas/gl dependencies)
|
|
3
3
|
|
|
4
|
+
import type { FFmpegBackend } from "./backends";
|
|
5
|
+
|
|
4
6
|
export type OriginX = "left" | "center" | "right";
|
|
5
7
|
export type OriginY = "top" | "center" | "bottom";
|
|
6
8
|
export type SizeValue = number | `${number}%` | `${number}px`;
|
|
@@ -327,6 +329,16 @@ export interface EditlyConfig {
|
|
|
327
329
|
enableFfmpegLog?: boolean;
|
|
328
330
|
/** End output when shortest stream ends (video or audio) */
|
|
329
331
|
shortest?: boolean;
|
|
332
|
+
/** FFmpeg backend for execution (defaults to local ffmpeg) */
|
|
333
|
+
backend?: FFmpegBackend;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export type EditlyOutput =
|
|
337
|
+
| { type: "file"; path: string }
|
|
338
|
+
| { type: "url"; url: string };
|
|
339
|
+
|
|
340
|
+
export interface EditlyResult {
|
|
341
|
+
output: EditlyOutput;
|
|
330
342
|
}
|
|
331
343
|
|
|
332
344
|
// Internal types used by our implementation
|
|
@@ -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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
305
|
-
return new Uint8Array(result);
|
|
330
|
+
return new Uint8Array(finalBuffer);
|
|
306
331
|
}
|
package/src/react/types.ts
CHANGED
|
@@ -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 {
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { $ } from "bun";
|
|
2
|
-
import type { VideoInfo } from "./types";
|
|
3
|
-
|
|
4
|
-
const FFMPEG_COMMON_ARGS = ["-hide_banner", "-loglevel", "error"];
|
|
5
|
-
|
|
6
|
-
export async function ffmpeg(
|
|
7
|
-
args: string[],
|
|
8
|
-
options?: { stdin?: "pipe" | "ignore"; stdout?: "pipe" | "inherit" },
|
|
9
|
-
): Promise<{ stdout: Buffer; exitCode: number }> {
|
|
10
|
-
const proc = Bun.spawn(["ffmpeg", ...FFMPEG_COMMON_ARGS, ...args], {
|
|
11
|
-
stdin: options?.stdin ?? "ignore",
|
|
12
|
-
stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
|
|
13
|
-
stderr: "inherit",
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
const stdout =
|
|
17
|
-
options?.stdout === "inherit"
|
|
18
|
-
? Buffer.alloc(0)
|
|
19
|
-
: Buffer.from(await new Response(proc.stdout).arrayBuffer());
|
|
20
|
-
const exitCode = await proc.exited;
|
|
21
|
-
|
|
22
|
-
return { stdout, exitCode };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export async function ffprobe(path: string): Promise<VideoInfo> {
|
|
26
|
-
const result =
|
|
27
|
-
await $`ffprobe -v error -show_entries stream=width,height,r_frame_rate,codec_type -show_entries format=duration -of json ${path}`.json();
|
|
28
|
-
|
|
29
|
-
const videoStream = result.streams?.find(
|
|
30
|
-
(s: { codec_type: string }) => s.codec_type === "video",
|
|
31
|
-
);
|
|
32
|
-
const duration = parseFloat(result.format?.duration ?? "0");
|
|
33
|
-
|
|
34
|
-
let fps: number | undefined;
|
|
35
|
-
const framerateStr: string | undefined = videoStream?.r_frame_rate;
|
|
36
|
-
if (framerateStr) {
|
|
37
|
-
const parts = framerateStr.split("/").map(Number);
|
|
38
|
-
const num = parts[0];
|
|
39
|
-
const den = parts[1];
|
|
40
|
-
if (den && den > 0 && num) fps = num / den;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return {
|
|
44
|
-
duration,
|
|
45
|
-
width: videoStream?.width,
|
|
46
|
-
height: videoStream?.height,
|
|
47
|
-
fps,
|
|
48
|
-
framerateStr,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export async function readDuration(path: string): Promise<number> {
|
|
53
|
-
const result =
|
|
54
|
-
await $`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 ${path}`.text();
|
|
55
|
-
return parseFloat(result.trim());
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function multipleOf2(n: number): number {
|
|
59
|
-
return Math.round(n / 2) * 2;
|
|
60
|
-
}
|