simple-ffmpegjs 0.1.0
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/LICENSE +21 -0
- package/README.md +420 -0
- package/index.js +1 -0
- package/package.json +37 -0
- package/src/core/constants.js +31 -0
- package/src/core/media_info.js +82 -0
- package/src/core/rotation.js +23 -0
- package/src/core/validation.js +188 -0
- package/src/ffmpeg/audio_builder.js +34 -0
- package/src/ffmpeg/bgm_builder.js +67 -0
- package/src/ffmpeg/command_builder.js +40 -0
- package/src/ffmpeg/strings.js +21 -0
- package/src/ffmpeg/text_passes.js +67 -0
- package/src/ffmpeg/text_renderer.js +363 -0
- package/src/ffmpeg/video_builder.js +130 -0
- package/src/lib/utils.js +13 -0
- package/src/loaders.js +136 -0
- package/src/simpleffmpeg.js +358 -0
- package/types/index.d.ts +128 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
|
|
3
|
+
function validateClips(clips, validationMode = "warn") {
|
|
4
|
+
const allowedTypes = new Set([
|
|
5
|
+
"video",
|
|
6
|
+
"audio",
|
|
7
|
+
"text",
|
|
8
|
+
"music",
|
|
9
|
+
"backgroundAudio",
|
|
10
|
+
"image",
|
|
11
|
+
]);
|
|
12
|
+
const errors = [];
|
|
13
|
+
const warnings = [];
|
|
14
|
+
|
|
15
|
+
clips.forEach((clip, idx) => {
|
|
16
|
+
if (!allowedTypes.has(clip.type)) {
|
|
17
|
+
errors.push(`clip[${idx}]: invalid type '${clip.type}'`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const requiresTimeline =
|
|
22
|
+
clip.type === "video" ||
|
|
23
|
+
clip.type === "audio" ||
|
|
24
|
+
clip.type === "text" ||
|
|
25
|
+
clip.type === "image";
|
|
26
|
+
if (requiresTimeline) {
|
|
27
|
+
if (typeof clip.position !== "number" || typeof clip.end !== "number") {
|
|
28
|
+
errors.push(`clip[${idx}]: 'position' and 'end' must be numbers`);
|
|
29
|
+
} else {
|
|
30
|
+
if (clip.position < 0)
|
|
31
|
+
errors.push(`clip[${idx}]: position must be >= 0`);
|
|
32
|
+
if (clip.end <= clip.position)
|
|
33
|
+
errors.push(`clip[${idx}]: end must be > position`);
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
// music/backgroundAudio: allow missing position/end (defaults later)
|
|
37
|
+
if (typeof clip.position === "number" && clip.position < 0) {
|
|
38
|
+
errors.push(`clip[${idx}]: position must be >= 0`);
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
typeof clip.end === "number" &&
|
|
42
|
+
typeof clip.position === "number" &&
|
|
43
|
+
clip.end <= clip.position
|
|
44
|
+
) {
|
|
45
|
+
errors.push(`clip[${idx}]: end must be > position`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Media clips
|
|
50
|
+
if (
|
|
51
|
+
clip.type === "video" ||
|
|
52
|
+
clip.type === "audio" ||
|
|
53
|
+
clip.type === "music" ||
|
|
54
|
+
clip.type === "backgroundAudio" ||
|
|
55
|
+
clip.type === "image"
|
|
56
|
+
) {
|
|
57
|
+
if (typeof clip.url !== "string" || clip.url.length === 0) {
|
|
58
|
+
errors.push(`clip[${idx}]: media 'url' is required`);
|
|
59
|
+
} else {
|
|
60
|
+
try {
|
|
61
|
+
if (!fs.existsSync(clip.url)) {
|
|
62
|
+
warnings.push(`clip[${idx}]: file not found at '${clip.url}'`);
|
|
63
|
+
}
|
|
64
|
+
} catch (_) {}
|
|
65
|
+
}
|
|
66
|
+
if (typeof clip.cutFrom === "number" && clip.cutFrom < 0) {
|
|
67
|
+
errors.push(`clip[${idx}]: cutFrom must be >= 0`);
|
|
68
|
+
}
|
|
69
|
+
if (
|
|
70
|
+
(clip.type === "audio" ||
|
|
71
|
+
clip.type === "music" ||
|
|
72
|
+
clip.type === "backgroundAudio") &&
|
|
73
|
+
typeof clip.volume === "number" &&
|
|
74
|
+
clip.volume < 0
|
|
75
|
+
) {
|
|
76
|
+
errors.push(`clip[${idx}]: volume must be >= 0`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (clip.type === "text") {
|
|
81
|
+
// words windows
|
|
82
|
+
if (Array.isArray(clip.words)) {
|
|
83
|
+
clip.words.forEach((w, wi) => {
|
|
84
|
+
if (
|
|
85
|
+
typeof w.start !== "number" ||
|
|
86
|
+
typeof w.end !== "number" ||
|
|
87
|
+
typeof w.text !== "string"
|
|
88
|
+
) {
|
|
89
|
+
errors.push(`clip[${idx}].words[${wi}]: invalid {text,start,end}`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (w.end <= w.start) {
|
|
93
|
+
errors.push(`clip[${idx}].words[${wi}]: end must be > start`);
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
typeof clip.position === "number" &&
|
|
97
|
+
typeof clip.end === "number"
|
|
98
|
+
) {
|
|
99
|
+
if (w.start < clip.position || w.end > clip.end) {
|
|
100
|
+
warnings.push(
|
|
101
|
+
`clip[${idx}].words[${wi}]: window outside [position,end]`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
if (Array.isArray(clip.wordTimestamps)) {
|
|
108
|
+
const ts = clip.wordTimestamps;
|
|
109
|
+
for (let i = 1; i < ts.length; i++) {
|
|
110
|
+
if (
|
|
111
|
+
!(
|
|
112
|
+
typeof ts[i] === "number" &&
|
|
113
|
+
typeof ts[i - 1] === "number" &&
|
|
114
|
+
ts[i] >= ts[i - 1]
|
|
115
|
+
)
|
|
116
|
+
) {
|
|
117
|
+
warnings.push(
|
|
118
|
+
`clip[${idx}].wordTimestamps: not non-decreasing at index ${i}`
|
|
119
|
+
);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (clip.fontFile && !fs.existsSync(clip.fontFile)) {
|
|
125
|
+
warnings.push(
|
|
126
|
+
`clip[${idx}]: fontFile '${clip.fontFile}' not found; falling back to fontFamily`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (clip.type === "image" && clip.kenBurns) {
|
|
132
|
+
const kb = clip.kenBurns;
|
|
133
|
+
const allowedKB = new Set([
|
|
134
|
+
"zoom-in",
|
|
135
|
+
"zoom-out",
|
|
136
|
+
"pan-left",
|
|
137
|
+
"pan-right",
|
|
138
|
+
"pan-up",
|
|
139
|
+
"pan-down",
|
|
140
|
+
]);
|
|
141
|
+
const type = typeof kb === "string" ? kb : kb.type;
|
|
142
|
+
if (!allowedKB.has(type))
|
|
143
|
+
errors.push(`clip[${idx}]: kenBurns '${type}' invalid`);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Visual timeline gap checks (video/image)
|
|
148
|
+
const visual = clips
|
|
149
|
+
.map((c, i) => ({ c, i }))
|
|
150
|
+
.filter(({ c }) => c.type === "video" || c.type === "image")
|
|
151
|
+
.sort((a, b) => (a.c.position || 0) - (b.c.position || 0));
|
|
152
|
+
|
|
153
|
+
if (visual.length > 0) {
|
|
154
|
+
const eps = 1e-3;
|
|
155
|
+
// Leading gap
|
|
156
|
+
if ((visual[0].c.position || 0) > eps) {
|
|
157
|
+
const start = 0;
|
|
158
|
+
const end = visual[0].c.position;
|
|
159
|
+
const msg = `visual gap [${start.toFixed(3)}, ${end.toFixed(
|
|
160
|
+
3
|
|
161
|
+
)}) — no video/image content at start`;
|
|
162
|
+
errors.push(msg);
|
|
163
|
+
}
|
|
164
|
+
// Middle gaps
|
|
165
|
+
for (let i = 1; i < visual.length; i++) {
|
|
166
|
+
const prev = visual[i - 1].c;
|
|
167
|
+
const cur = visual[i].c;
|
|
168
|
+
if ((cur.position || 0) - (prev.end || 0) > eps) {
|
|
169
|
+
const start = prev.end || 0;
|
|
170
|
+
const end = cur.position || 0;
|
|
171
|
+
const msg = `visual gap [${start.toFixed(3)}, ${end.toFixed(
|
|
172
|
+
3
|
|
173
|
+
)}) — no video/image content between clips`;
|
|
174
|
+
errors.push(msg);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (errors.length > 0) {
|
|
180
|
+
const msg = `Validation failed:\n - ` + errors.join(`\n - `);
|
|
181
|
+
throw new Error(msg);
|
|
182
|
+
}
|
|
183
|
+
if (validationMode === "warn" && warnings.length > 0) {
|
|
184
|
+
warnings.forEach((w) => console.warn(w));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { validateClips };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
function buildAudioForVideoClips(project, videoClips) {
|
|
2
|
+
let audioFilter = "";
|
|
3
|
+
const labels = [];
|
|
4
|
+
|
|
5
|
+
videoClips.forEach((clip) => {
|
|
6
|
+
if (!clip.hasAudio) return;
|
|
7
|
+
const inputIndex = project.videoOrAudioClips.indexOf(clip);
|
|
8
|
+
const requestedDuration = Math.max(
|
|
9
|
+
0,
|
|
10
|
+
(clip.end || 0) - (clip.position || 0)
|
|
11
|
+
);
|
|
12
|
+
const maxAvailable =
|
|
13
|
+
typeof clip.mediaDuration === "number" && typeof clip.cutFrom === "number"
|
|
14
|
+
? Math.max(0, clip.mediaDuration - clip.cutFrom)
|
|
15
|
+
: requestedDuration;
|
|
16
|
+
const clipDuration = Math.max(0, Math.min(requestedDuration, maxAvailable));
|
|
17
|
+
|
|
18
|
+
const adelayMs = Math.round(Math.max(0, clip.position || 0) * 1000);
|
|
19
|
+
const out = `[va${inputIndex}]`;
|
|
20
|
+
audioFilter += `[${inputIndex}:a]atrim=start=${clip.cutFrom}:duration=${clipDuration},asetpts=PTS-STARTPTS,adelay=${adelayMs}|${adelayMs}${out};`;
|
|
21
|
+
labels.push(out);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (labels.length === 0) {
|
|
25
|
+
return { filter: "", finalAudioLabel: null, hasAudio: false };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
audioFilter += `${labels.join("")}amix=inputs=${
|
|
29
|
+
labels.length
|
|
30
|
+
}:duration=longest[outa];`;
|
|
31
|
+
return { filter: audioFilter, finalAudioLabel: "[outa]", hasAudio: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { buildAudioForVideoClips };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
function buildBackgroundMusicMix(
|
|
2
|
+
project,
|
|
3
|
+
backgroundClips,
|
|
4
|
+
existingAudioLabel,
|
|
5
|
+
visualEnd
|
|
6
|
+
) {
|
|
7
|
+
if (backgroundClips.length === 0) {
|
|
8
|
+
return {
|
|
9
|
+
filter: "",
|
|
10
|
+
finalAudioLabel: existingAudioLabel,
|
|
11
|
+
hasAudio: !!existingAudioLabel,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const projectDuration =
|
|
16
|
+
project.videoOrAudioClips.filter(
|
|
17
|
+
(c) => c.type === "video" || c.type === "image"
|
|
18
|
+
).length > 0
|
|
19
|
+
? Math.max(
|
|
20
|
+
...project.videoOrAudioClips
|
|
21
|
+
.filter((c) => c.type === "video" || c.type === "image")
|
|
22
|
+
.map((c) => c.end)
|
|
23
|
+
)
|
|
24
|
+
: Math.max(
|
|
25
|
+
0,
|
|
26
|
+
...backgroundClips.map((c) => (typeof c.end === "number" ? c.end : 0))
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
let filter = "";
|
|
30
|
+
const bgLabels = [];
|
|
31
|
+
backgroundClips.forEach((clip, i) => {
|
|
32
|
+
const inputIndex = project.videoOrAudioClips.indexOf(clip);
|
|
33
|
+
const effectivePosition =
|
|
34
|
+
typeof clip.position === "number" ? clip.position : 0;
|
|
35
|
+
const effectiveEnd =
|
|
36
|
+
typeof clip.end === "number" ? clip.end : projectDuration;
|
|
37
|
+
const effectiveCutFrom =
|
|
38
|
+
typeof clip.cutFrom === "number" ? clip.cutFrom : 0;
|
|
39
|
+
const effectiveVolume = typeof clip.volume === "number" ? clip.volume : 0.2;
|
|
40
|
+
|
|
41
|
+
const adelay = effectivePosition * 1000;
|
|
42
|
+
const trimEnd = effectiveCutFrom + (effectiveEnd - effectivePosition);
|
|
43
|
+
const outLabel = `[bg${i}]`;
|
|
44
|
+
filter += `[${inputIndex}:a]volume=${effectiveVolume},atrim=start=${effectiveCutFrom}:end=${trimEnd},adelay=${adelay}|${adelay},asetpts=PTS-STARTPTS${outLabel};`;
|
|
45
|
+
bgLabels.push(outLabel);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (bgLabels.length > 0) {
|
|
49
|
+
if (existingAudioLabel) {
|
|
50
|
+
filter += `${existingAudioLabel}${bgLabels.join("")}amix=inputs=${
|
|
51
|
+
bgLabels.length + 1
|
|
52
|
+
}:duration=longest[finalaudio];`;
|
|
53
|
+
return { filter, finalAudioLabel: "[finalaudio]", hasAudio: true };
|
|
54
|
+
}
|
|
55
|
+
filter += `${bgLabels.join("")}amix=inputs=${
|
|
56
|
+
bgLabels.length
|
|
57
|
+
}:duration=longest[finalaudio];`;
|
|
58
|
+
return { filter, finalAudioLabel: "[finalaudio]", hasAudio: true };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
filter: "",
|
|
62
|
+
finalAudioLabel: existingAudioLabel,
|
|
63
|
+
hasAudio: !!existingAudioLabel,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { buildBackgroundMusicMix };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
function buildMainCommand({
|
|
2
|
+
inputs,
|
|
3
|
+
filterComplex,
|
|
4
|
+
mapVideo,
|
|
5
|
+
mapAudio,
|
|
6
|
+
hasVideo,
|
|
7
|
+
hasAudio,
|
|
8
|
+
videoCodec,
|
|
9
|
+
videoPreset,
|
|
10
|
+
videoCrf,
|
|
11
|
+
audioCodec,
|
|
12
|
+
audioBitrate,
|
|
13
|
+
shortest,
|
|
14
|
+
faststart,
|
|
15
|
+
outputPath,
|
|
16
|
+
}) {
|
|
17
|
+
let cmd = `ffmpeg -y ${inputs} -filter_complex "${filterComplex}" `;
|
|
18
|
+
if (hasVideo && mapVideo) cmd += `-map "${mapVideo}" `;
|
|
19
|
+
if (hasAudio && mapAudio) cmd += `-map "${mapAudio}" `;
|
|
20
|
+
if (hasVideo)
|
|
21
|
+
cmd += `-c:v ${videoCodec} -preset ${videoPreset} -crf ${videoCrf} `;
|
|
22
|
+
if (hasAudio) cmd += `-c:a ${audioCodec} -b:a ${audioBitrate} `;
|
|
23
|
+
if (hasVideo && hasAudio && shortest) cmd += `-shortest `;
|
|
24
|
+
if (faststart) cmd += `-movflags +faststart `;
|
|
25
|
+
cmd += `"${outputPath}"`;
|
|
26
|
+
return cmd;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function buildTextBatchCommand({
|
|
30
|
+
inputPath,
|
|
31
|
+
filterString,
|
|
32
|
+
intermediateVideoCodec,
|
|
33
|
+
intermediatePreset,
|
|
34
|
+
intermediateCrf,
|
|
35
|
+
outputPath,
|
|
36
|
+
}) {
|
|
37
|
+
return `ffmpeg -y -i "${inputPath}" -filter_complex "[0:v]null[invid];${filterString}" -map "[outVideoAndText]" -map 0:a? -c:v ${intermediateVideoCodec} -preset ${intermediatePreset} -crf ${intermediateCrf} -c:a copy -movflags +faststart "${outputPath}"`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { buildMainCommand, buildTextBatchCommand };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
function escapeSingleQuotes(text) {
|
|
2
|
+
return String(text).replace(/'/g, "\\'");
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function escapeDrawtextText(text) {
|
|
6
|
+
if (typeof text !== "string") return "";
|
|
7
|
+
return text.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/:/g, "\\:");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getClipAudioString(clip, inputIndex) {
|
|
11
|
+
const adelay = Math.round(Math.max(0, (clip.position || 0) * 1000));
|
|
12
|
+
const audioConcatInput = `[a${inputIndex}]`;
|
|
13
|
+
const audioStringPart = `[${inputIndex}:a]volume=${clip.volume},atrim=start=${
|
|
14
|
+
clip.cutFrom
|
|
15
|
+
}:end=${
|
|
16
|
+
clip.cutFrom + (clip.end - clip.position)
|
|
17
|
+
},adelay=${adelay}|${adelay},asetpts=PTS-STARTPTS${audioConcatInput};`;
|
|
18
|
+
return { audioStringPart, audioConcatInput };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { escapeSingleQuotes, escapeDrawtextText, getClipAudioString };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const { exec } = require("child_process");
|
|
3
|
+
const { buildFiltersForWindows } = require("./text_renderer");
|
|
4
|
+
const { buildTextBatchCommand } = require("./command_builder");
|
|
5
|
+
|
|
6
|
+
function runCmd(cmd) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
exec(cmd, (err, so, se) => {
|
|
9
|
+
if (err) {
|
|
10
|
+
console.error("FFmpeg text batch stderr:", se);
|
|
11
|
+
reject(err);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
resolve();
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function runTextPasses({
|
|
20
|
+
baseOutputPath,
|
|
21
|
+
textWindows,
|
|
22
|
+
canvasWidth,
|
|
23
|
+
canvasHeight,
|
|
24
|
+
intermediateVideoCodec,
|
|
25
|
+
intermediatePreset,
|
|
26
|
+
intermediateCrf,
|
|
27
|
+
batchSize = 75,
|
|
28
|
+
}) {
|
|
29
|
+
const tempOutputs = [];
|
|
30
|
+
let currentInput = baseOutputPath;
|
|
31
|
+
let passes = 0;
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < textWindows.length; i += batchSize) {
|
|
34
|
+
const batch = textWindows.slice(i, i + batchSize);
|
|
35
|
+
const { filterString } = buildFiltersForWindows(
|
|
36
|
+
batch,
|
|
37
|
+
canvasWidth,
|
|
38
|
+
canvasHeight,
|
|
39
|
+
"[invid]"
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const batchOutput = path.join(
|
|
43
|
+
path.dirname(baseOutputPath),
|
|
44
|
+
`textpass_${i}_${path.basename(baseOutputPath)}`
|
|
45
|
+
);
|
|
46
|
+
tempOutputs.push(batchOutput);
|
|
47
|
+
|
|
48
|
+
const cmd = buildTextBatchCommand({
|
|
49
|
+
inputPath: currentInput,
|
|
50
|
+
filterString,
|
|
51
|
+
intermediateVideoCodec,
|
|
52
|
+
intermediatePreset,
|
|
53
|
+
intermediateCrf,
|
|
54
|
+
outputPath: batchOutput,
|
|
55
|
+
});
|
|
56
|
+
await runCmd(cmd);
|
|
57
|
+
currentInput = batchOutput;
|
|
58
|
+
passes += 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (currentInput !== baseOutputPath) {
|
|
62
|
+
return { finalPath: currentInput, tempOutputs, passes };
|
|
63
|
+
}
|
|
64
|
+
return { finalPath: baseOutputPath, tempOutputs, passes };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { runTextPasses };
|