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.
@@ -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 };