simple-ffmpegjs 0.5.2 → 0.5.4
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/README.md +4 -1
- package/package.json +10 -3
- package/src/core/media_info.js +5 -5
- package/src/core/resolve.js +33 -0
- package/src/core/rotation.js +4 -4
- package/src/core/validation.js +255 -172
- package/src/ffmpeg/audio_builder.js +1 -1
- package/src/ffmpeg/bgm_builder.js +5 -5
- package/src/ffmpeg/command_builder.js +6 -6
- package/src/ffmpeg/effect_builder.js +11 -11
- package/src/ffmpeg/standalone_audio_builder.js +75 -0
- package/src/ffmpeg/strings.js +4 -17
- package/src/ffmpeg/subtitle_builder.js +6 -14
- package/src/ffmpeg/text_passes.js +33 -8
- package/src/ffmpeg/text_renderer.js +17 -17
- package/src/ffmpeg/video_builder.js +6 -4
- package/src/ffmpeg/watermark_builder.js +1 -1
- package/src/lib/utils.js +4 -4
- package/src/loaders.js +8 -8
- package/src/schema/formatter.js +15 -15
- package/src/schema/index.js +4 -4
- package/src/schema/modules/effect.js +5 -4
- package/src/schema/modules/music.js +1 -1
- package/src/schema/modules/text.js +4 -3
- package/src/simpleffmpeg.js +180 -167
- package/types/index.d.mts +33 -39
- package/types/index.d.ts +33 -39
|
@@ -16,7 +16,7 @@ function buildAudioForVideoClips(project, videoClips, transitionOffsets) {
|
|
|
16
16
|
: project.videoOrAudioClips.indexOf(clip);
|
|
17
17
|
const requestedDuration = Math.max(
|
|
18
18
|
0,
|
|
19
|
-
(clip.end || 0) - (clip.position || 0)
|
|
19
|
+
(clip.end || 0) - (clip.position || 0),
|
|
20
20
|
);
|
|
21
21
|
const maxAvailable =
|
|
22
22
|
typeof clip.mediaDuration === "number" && typeof clip.cutFrom === "number"
|
|
@@ -2,7 +2,7 @@ function buildBackgroundMusicMix(
|
|
|
2
2
|
project,
|
|
3
3
|
backgroundClips,
|
|
4
4
|
existingAudioLabel,
|
|
5
|
-
visualEnd
|
|
5
|
+
visualEnd,
|
|
6
6
|
) {
|
|
7
7
|
if (backgroundClips.length === 0) {
|
|
8
8
|
return {
|
|
@@ -18,16 +18,16 @@ function buildBackgroundMusicMix(
|
|
|
18
18
|
typeof visualEnd === "number" && visualEnd > 0
|
|
19
19
|
? visualEnd
|
|
20
20
|
: project.videoOrAudioClips.filter(
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
(c) => c.type === "video" || c.type === "image",
|
|
22
|
+
).length > 0
|
|
23
23
|
? Math.max(
|
|
24
24
|
...project.videoOrAudioClips
|
|
25
25
|
.filter((c) => c.type === "video" || c.type === "image")
|
|
26
|
-
.map((c) => c.end)
|
|
26
|
+
.map((c) => c.end),
|
|
27
27
|
)
|
|
28
28
|
: Math.max(
|
|
29
29
|
0,
|
|
30
|
-
...backgroundClips.map((c) => (typeof c.end === "number" ? c.end : 0))
|
|
30
|
+
...backgroundClips.map((c) => (typeof c.end === "number" ? c.end : 0)),
|
|
31
31
|
);
|
|
32
32
|
|
|
33
33
|
let filter = "";
|
|
@@ -186,9 +186,9 @@ function buildTextBatchCommand({
|
|
|
186
186
|
? "-profile:v main -pix_fmt yuv420p "
|
|
187
187
|
: "";
|
|
188
188
|
return `ffmpeg -y -i "${escapeFilePath(
|
|
189
|
-
inputPath
|
|
189
|
+
inputPath,
|
|
190
190
|
)}" -filter_complex "[0:v]null[invid];${filterString}" -map "[outVideoAndText]" -map 0:a? -c:v ${intermediateVideoCodec} ${compatFlags}-preset ${intermediatePreset} -crf ${intermediateCrf} -c:a copy -movflags +faststart "${escapeFilePath(
|
|
191
|
-
outputPath
|
|
191
|
+
outputPath,
|
|
192
192
|
)}"`;
|
|
193
193
|
}
|
|
194
194
|
|
|
@@ -197,7 +197,7 @@ function buildTextBatchCommand({
|
|
|
197
197
|
*/
|
|
198
198
|
function buildThumbnailCommand({ inputPath, outputPath, time, width, height }) {
|
|
199
199
|
let cmd = `ffmpeg -y -ss ${time} -i "${escapeFilePath(
|
|
200
|
-
inputPath
|
|
200
|
+
inputPath,
|
|
201
201
|
)}" -vframes 1 `;
|
|
202
202
|
|
|
203
203
|
if (width || height) {
|
|
@@ -232,7 +232,7 @@ function buildSnapshotCommand({
|
|
|
232
232
|
quality,
|
|
233
233
|
}) {
|
|
234
234
|
let cmd = `ffmpeg -y -ss ${time} -i "${escapeFilePath(
|
|
235
|
-
inputPath
|
|
235
|
+
inputPath,
|
|
236
236
|
)}" -vframes 1 `;
|
|
237
237
|
|
|
238
238
|
if (width || height) {
|
|
@@ -255,7 +255,7 @@ function buildSnapshotCommand({
|
|
|
255
255
|
function escapeMetadata(value) {
|
|
256
256
|
return String(value)
|
|
257
257
|
.replace(/\\/g, "\\\\")
|
|
258
|
-
.replace(/"/g,
|
|
258
|
+
.replace(/"/g, "\\\"")
|
|
259
259
|
.replace(/\n/g, "\\n");
|
|
260
260
|
}
|
|
261
261
|
|
|
@@ -302,7 +302,7 @@ function sanitizeFilterComplex(fc) {
|
|
|
302
302
|
throw new SimpleffmpegError(
|
|
303
303
|
`Empty filter name detected in filter_complex chain segment ${i}: "${chain}". ` +
|
|
304
304
|
`This usually means an effect or transition is not producing a valid FFmpeg filter. ` +
|
|
305
|
-
`Full filter_complex (truncated): "${sanitized.slice(0, 500)}..."
|
|
305
|
+
`Full filter_complex (truncated): "${sanitized.slice(0, 500)}..."`,
|
|
306
306
|
);
|
|
307
307
|
}
|
|
308
308
|
}
|
|
@@ -11,7 +11,7 @@ function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
|
|
|
11
11
|
const amount = clamp(
|
|
12
12
|
typeof params.amount === "number" ? params.amount : 1,
|
|
13
13
|
0,
|
|
14
|
-
1
|
|
14
|
+
1,
|
|
15
15
|
);
|
|
16
16
|
|
|
17
17
|
if (effectClip.effect === "vignette") {
|
|
@@ -29,14 +29,14 @@ function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
|
|
|
29
29
|
const strength = clamp(
|
|
30
30
|
typeof params.strength === "number" ? params.strength : 0.35,
|
|
31
31
|
0,
|
|
32
|
-
1
|
|
32
|
+
1,
|
|
33
33
|
);
|
|
34
34
|
const grainStrength = strength * 100;
|
|
35
35
|
const flags = params.temporal === false ? "u" : "t+u";
|
|
36
36
|
return {
|
|
37
37
|
filter: `${inputLabel}noise=alls=${formatNumber(
|
|
38
38
|
grainStrength,
|
|
39
|
-
3
|
|
39
|
+
3,
|
|
40
40
|
)}:allf=${flags}${outputLabel};`,
|
|
41
41
|
amount,
|
|
42
42
|
};
|
|
@@ -48,7 +48,7 @@ function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
|
|
|
48
48
|
? params.sigma
|
|
49
49
|
: (typeof params.amount === "number" ? params.amount : 0.5) * 20,
|
|
50
50
|
0,
|
|
51
|
-
100
|
|
51
|
+
100,
|
|
52
52
|
);
|
|
53
53
|
return {
|
|
54
54
|
filter: `${inputLabel}gblur=sigma=${formatNumber(sigma, 4)}${outputLabel};`,
|
|
@@ -108,7 +108,7 @@ function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
|
|
|
108
108
|
const strength = clamp(
|
|
109
109
|
typeof params.strength === "number" ? params.strength : 1.0,
|
|
110
110
|
0,
|
|
111
|
-
3
|
|
111
|
+
3,
|
|
112
112
|
);
|
|
113
113
|
return {
|
|
114
114
|
filter: `${inputLabel}unsharp=5:5:${formatNumber(strength, 4)}${outputLabel};`,
|
|
@@ -121,7 +121,7 @@ function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
|
|
|
121
121
|
const shift = clamp(
|
|
122
122
|
typeof params.shift === "number" ? params.shift : 4,
|
|
123
123
|
0,
|
|
124
|
-
20
|
|
124
|
+
20,
|
|
125
125
|
);
|
|
126
126
|
const shiftInt = Math.round(shift);
|
|
127
127
|
return {
|
|
@@ -135,7 +135,7 @@ function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
|
|
|
135
135
|
const size = clamp(
|
|
136
136
|
typeof params.size === "number" ? params.size : 0.12,
|
|
137
137
|
0,
|
|
138
|
-
0.5
|
|
138
|
+
0.5,
|
|
139
139
|
);
|
|
140
140
|
const color = typeof params.color === "string" ? params.color : "black";
|
|
141
141
|
const barExpr = `round(ih*${formatNumber(size, 4)})`;
|
|
@@ -151,7 +151,7 @@ function buildProcessedEffectFilter(effectClip, inputLabel, outputLabel) {
|
|
|
151
151
|
|
|
152
152
|
// Unknown effect — guard against silent fallthrough
|
|
153
153
|
throw new Error(
|
|
154
|
-
`Unknown effect type '${effectClip.effect}' in buildProcessedEffectFilter
|
|
154
|
+
`Unknown effect type '${effectClip.effect}' in buildProcessedEffectFilter`,
|
|
155
155
|
);
|
|
156
156
|
}
|
|
157
157
|
|
|
@@ -201,7 +201,7 @@ function buildEffectFilters(effectClips, inputLabel) {
|
|
|
201
201
|
const { filter: fxFilter, amount } = buildProcessedEffectFilter(
|
|
202
202
|
clip,
|
|
203
203
|
procSrcLabel,
|
|
204
|
-
fxLabel
|
|
204
|
+
fxLabel,
|
|
205
205
|
);
|
|
206
206
|
|
|
207
207
|
// Safeguard: verify the filter builder produced a non-empty filter name.
|
|
@@ -213,7 +213,7 @@ function buildEffectFilters(effectClips, inputLabel) {
|
|
|
213
213
|
throw new Error(
|
|
214
214
|
`Effect '${clip.effect}' produced an empty filter name. ` +
|
|
215
215
|
`This usually means the effect is not supported by the current FFmpeg version. ` +
|
|
216
|
-
`Generated filter segment: ${JSON.stringify(fxFilter)}
|
|
216
|
+
`Generated filter segment: ${JSON.stringify(fxFilter)}`,
|
|
217
217
|
);
|
|
218
218
|
}
|
|
219
219
|
|
|
@@ -232,7 +232,7 @@ function buildEffectFilters(effectClips, inputLabel) {
|
|
|
232
232
|
if (fadeOut > 0) {
|
|
233
233
|
alphaChain += `,fade=t=out:st=${formatNumber(
|
|
234
234
|
fadeOutStart,
|
|
235
|
-
4
|
|
235
|
+
4,
|
|
236
236
|
)}:d=${formatNumber(fadeOut, 4)}:alpha=1`;
|
|
237
237
|
}
|
|
238
238
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build audio filter chain for standalone audio clips (sound effects, voiceovers, etc.).
|
|
3
|
+
*
|
|
4
|
+
* @param {Object} project - The SIMPLEFFMPEG project instance
|
|
5
|
+
* @param {Array} audioClips - Array of standalone audio clip objects
|
|
6
|
+
* @param {Object} options
|
|
7
|
+
* @param {boolean} options.compensateTransitions - Whether to adjust timings for transition overlap
|
|
8
|
+
* @param {Array} options.videoClips - Video clips (needed for transition offset calculation)
|
|
9
|
+
* @param {boolean} options.hasAudio - Whether the project already has an audio stream
|
|
10
|
+
* @param {string} options.finalAudioLabel - Current final audio label (e.g. "[outa]")
|
|
11
|
+
* @returns {{ filter: string, finalAudioLabel: string|null, hasAudio: boolean }}
|
|
12
|
+
*/
|
|
13
|
+
function buildStandaloneAudioMix(
|
|
14
|
+
project,
|
|
15
|
+
audioClips,
|
|
16
|
+
{ compensateTransitions, videoClips, hasAudio, finalAudioLabel },
|
|
17
|
+
) {
|
|
18
|
+
if (audioClips.length === 0) {
|
|
19
|
+
return { filter: "", finalAudioLabel, hasAudio };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Compensate audio timings for transition overlap if enabled
|
|
23
|
+
let adjustedClips = audioClips;
|
|
24
|
+
if (compensateTransitions && videoClips.length > 1) {
|
|
25
|
+
adjustedClips = audioClips.map((clip) => {
|
|
26
|
+
const adjustedPosition = project._adjustTimestampForTransitions(
|
|
27
|
+
videoClips,
|
|
28
|
+
clip.position || 0,
|
|
29
|
+
);
|
|
30
|
+
const adjustedEnd = project._adjustTimestampForTransitions(
|
|
31
|
+
videoClips,
|
|
32
|
+
clip.end || 0,
|
|
33
|
+
);
|
|
34
|
+
return { ...clip, position: adjustedPosition, end: adjustedEnd };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let filter = "";
|
|
39
|
+
const labels = [];
|
|
40
|
+
|
|
41
|
+
adjustedClips.forEach((clip, idx) => {
|
|
42
|
+
// Use the original clip for input index lookup since
|
|
43
|
+
// _inputIndexMap keys are the original clip objects.
|
|
44
|
+
const originalClip = audioClips[idx];
|
|
45
|
+
const inputIndex = project._inputIndexMap
|
|
46
|
+
? project._inputIndexMap.get(originalClip)
|
|
47
|
+
: project.videoOrAudioClips.indexOf(originalClip);
|
|
48
|
+
|
|
49
|
+
const adelay = Math.round(Math.max(0, (clip.position || 0) * 1000));
|
|
50
|
+
const label = `[a${inputIndex}]`;
|
|
51
|
+
filter += `[${inputIndex}:a]volume=${clip.volume},atrim=start=${
|
|
52
|
+
clip.cutFrom
|
|
53
|
+
}:end=${
|
|
54
|
+
clip.cutFrom + (clip.end - clip.position)
|
|
55
|
+
},adelay=${adelay}|${adelay},asetpts=PTS-STARTPTS${label};`;
|
|
56
|
+
labels.push(label);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (labels.length === 0) {
|
|
60
|
+
return { filter: "", finalAudioLabel, hasAudio };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
filter += labels.join("");
|
|
64
|
+
if (hasAudio) {
|
|
65
|
+
filter += `${finalAudioLabel}amix=inputs=${
|
|
66
|
+
labels.length + 1
|
|
67
|
+
}:duration=longest[finalaudio];`;
|
|
68
|
+
} else {
|
|
69
|
+
filter += `amix=inputs=${labels.length}:duration=longest[finalaudio];`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { filter, finalAudioLabel: "[finalaudio]", hasAudio: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { buildStandaloneAudioMix };
|
package/src/ffmpeg/strings.js
CHANGED
|
@@ -8,7 +8,7 @@ function escapeFilePath(filePath) {
|
|
|
8
8
|
if (typeof filePath !== "string") return "";
|
|
9
9
|
// Escape backslashes first, then double quotes
|
|
10
10
|
// This makes the path safe for use inside double-quoted shell strings
|
|
11
|
-
return filePath.replace(/\\/g, "\\\\").replace(/"/g,
|
|
11
|
+
return filePath.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -22,7 +22,7 @@ function hasProblematicChars(text) {
|
|
|
22
22
|
// support \' inside single-quoted strings — ' always ends the quoted value.
|
|
23
23
|
// Non-ASCII characters are also routed to textfile to avoid parser issues
|
|
24
24
|
// with UTF-8 inside filter_complex.
|
|
25
|
-
return /[,;{}
|
|
25
|
+
return /[,;{}[\]"']/.test(text) || /[^\x20-\x7E]/.test(text);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function escapeDrawtextText(text) {
|
|
@@ -47,7 +47,7 @@ function escapeDrawtextText(text) {
|
|
|
47
47
|
// then processed by level 2 as escape sequences: \\ → \ and \: → :
|
|
48
48
|
return text
|
|
49
49
|
.replace(/\\/g, "\\\\") // Escape backslashes (level 2 decodes \\ → \)
|
|
50
|
-
.replace(/'/g, "'\\\\\\''"
|
|
50
|
+
.replace(/'/g, "'\\\\\\''") // End quote, \\' (two-level escape), re-open quote
|
|
51
51
|
.replace(/:/g, "\\:") // Escape colons (level 2 decodes \: → :)
|
|
52
52
|
.replace(/\n/g, " ") // Replace newlines with space (multiline not supported)
|
|
53
53
|
.replace(/\r/g, ""); // Remove carriage returns
|
|
@@ -64,7 +64,7 @@ function escapeTextFilePath(filePath) {
|
|
|
64
64
|
if (typeof filePath !== "string") return "";
|
|
65
65
|
return filePath
|
|
66
66
|
.replace(/\\/g, "/") // Convert backslashes to forward slashes
|
|
67
|
-
.replace(/'/g, "'\\\\\\''"
|
|
67
|
+
.replace(/'/g, "'\\\\\\''") // Two-level apostrophe escape (same as escapeDrawtextText)
|
|
68
68
|
.replace(/:/g, "\\:"); // Escape colons (for Windows drive letters)
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -82,7 +82,6 @@ function hasEmoji(text) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
const fs = require("fs");
|
|
85
|
-
const path = require("path");
|
|
86
85
|
|
|
87
86
|
const VISUAL_EMOJI_RE = /\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu;
|
|
88
87
|
|
|
@@ -147,21 +146,9 @@ function parseFontFamily(fontPath) {
|
|
|
147
146
|
return null;
|
|
148
147
|
}
|
|
149
148
|
|
|
150
|
-
function getClipAudioString(clip, inputIndex) {
|
|
151
|
-
const adelay = Math.round(Math.max(0, (clip.position || 0) * 1000));
|
|
152
|
-
const audioConcatInput = `[a${inputIndex}]`;
|
|
153
|
-
const audioStringPart = `[${inputIndex}:a]volume=${clip.volume},atrim=start=${
|
|
154
|
-
clip.cutFrom
|
|
155
|
-
}:end=${
|
|
156
|
-
clip.cutFrom + (clip.end - clip.position)
|
|
157
|
-
},adelay=${adelay}|${adelay},asetpts=PTS-STARTPTS${audioConcatInput};`;
|
|
158
|
-
return { audioStringPart, audioConcatInput };
|
|
159
|
-
}
|
|
160
|
-
|
|
161
149
|
module.exports = {
|
|
162
150
|
escapeFilePath,
|
|
163
151
|
escapeDrawtextText,
|
|
164
|
-
getClipAudioString,
|
|
165
152
|
hasProblematicChars,
|
|
166
153
|
hasEmoji,
|
|
167
154
|
stripEmoji,
|
|
@@ -220,11 +220,7 @@ function buildKaraokeASS(clip, canvasWidth, canvasHeight) {
|
|
|
220
220
|
borderColor = "#000000",
|
|
221
221
|
borderWidth = 2,
|
|
222
222
|
shadowColor,
|
|
223
|
-
shadowX = 0,
|
|
224
|
-
shadowY = 0,
|
|
225
|
-
xPercent,
|
|
226
223
|
yPercent,
|
|
227
|
-
x,
|
|
228
224
|
y,
|
|
229
225
|
opacity = 1,
|
|
230
226
|
} = clip;
|
|
@@ -447,7 +443,7 @@ function parseSRT(srtContent) {
|
|
|
447
443
|
if (!timestampLine) continue;
|
|
448
444
|
|
|
449
445
|
const match = timestampLine.match(
|
|
450
|
-
/(\d{2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})[,.](\d{3})
|
|
446
|
+
/(\d{2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})[,.](\d{3})/,
|
|
451
447
|
);
|
|
452
448
|
|
|
453
449
|
if (!match) continue;
|
|
@@ -513,13 +509,13 @@ function parseVTT(vttContent) {
|
|
|
513
509
|
|
|
514
510
|
// VTT format: 00:00:00.000 --> 00:00:00.000
|
|
515
511
|
const match = timestampLine.match(
|
|
516
|
-
/(\d{2}):(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})\.(\d{3})
|
|
512
|
+
/(\d{2}):(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2})\.(\d{3})/,
|
|
517
513
|
);
|
|
518
514
|
|
|
519
515
|
if (!match) {
|
|
520
516
|
// Try shorter format: 00:00.000
|
|
521
517
|
const shortMatch = timestampLine.match(
|
|
522
|
-
/(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{2}):(\d{2})\.(\d{3})
|
|
518
|
+
/(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(\d{2}):(\d{2})\.(\d{3})/,
|
|
523
519
|
);
|
|
524
520
|
if (shortMatch) {
|
|
525
521
|
const start =
|
|
@@ -588,7 +584,7 @@ function loadSubtitleFile(filePath, options, canvasWidth, canvasHeight) {
|
|
|
588
584
|
return content;
|
|
589
585
|
}
|
|
590
586
|
|
|
591
|
-
let dialogues
|
|
587
|
+
let dialogues;
|
|
592
588
|
|
|
593
589
|
if (ext === ".srt") {
|
|
594
590
|
dialogues = parseSRT(content);
|
|
@@ -694,7 +690,6 @@ function buildTextClipASS(clip, canvasWidth, canvasHeight, emojiFont) {
|
|
|
694
690
|
shadowX = 0,
|
|
695
691
|
shadowY = 0,
|
|
696
692
|
backgroundColor,
|
|
697
|
-
backgroundOpacity,
|
|
698
693
|
xPercent,
|
|
699
694
|
yPercent,
|
|
700
695
|
x,
|
|
@@ -709,9 +704,6 @@ function buildTextClipASS(clip, canvasWidth, canvasHeight, emojiFont) {
|
|
|
709
704
|
shadowColor ? Math.max(Math.abs(shadowX), Math.abs(shadowY), 1) : 0;
|
|
710
705
|
const borderStyle = backgroundColor ? 3 : 1;
|
|
711
706
|
const backColor = backgroundColor || shadowColor || "#000000";
|
|
712
|
-
const backOpacity = backgroundColor
|
|
713
|
-
? (typeof backgroundOpacity === "number" ? backgroundOpacity : 0.5)
|
|
714
|
-
: 0.5;
|
|
715
707
|
|
|
716
708
|
let ass = generateASSHeader(canvasWidth, canvasHeight, "Emoji Text");
|
|
717
709
|
|
|
@@ -788,7 +780,7 @@ function buildTextClipASS(clip, canvasWidth, canvasHeight, emojiFont) {
|
|
|
788
780
|
*/
|
|
789
781
|
function buildASSFilter(assFilePath, inputLabel, options) {
|
|
790
782
|
const escapeFn = (p) =>
|
|
791
|
-
p.replace(/\\/g, "/").replace(/:/g, "\\:").replace(/'/g, "'\\\\\\''"
|
|
783
|
+
p.replace(/\\/g, "/").replace(/:/g, "\\:").replace(/'/g, "'\\\\\\''");
|
|
792
784
|
|
|
793
785
|
const escapedPath = escapeFn(assFilePath);
|
|
794
786
|
let filterParams = `ass='${escapedPath}'`;
|
|
@@ -821,7 +813,7 @@ function validateSubtitleClip(clip) {
|
|
|
821
813
|
const ext = path.extname(clip.url).toLowerCase();
|
|
822
814
|
if (![".srt", ".ass", ".ssa", ".vtt"].includes(ext)) {
|
|
823
815
|
errors.push(
|
|
824
|
-
`Unsupported subtitle format '${ext}'. Supported: .srt, .ass, .ssa, .vtt
|
|
816
|
+
`Unsupported subtitle format '${ext}'. Supported: .srt, .ass, .ssa, .vtt`,
|
|
825
817
|
);
|
|
826
818
|
}
|
|
827
819
|
}
|
|
@@ -2,18 +2,26 @@ const path = require("path");
|
|
|
2
2
|
const { spawn } = require("child_process");
|
|
3
3
|
const { buildFiltersForWindows } = require("./text_renderer");
|
|
4
4
|
const { buildTextBatchCommand } = require("./command_builder");
|
|
5
|
-
const { FFmpegError } = require("../core/errors");
|
|
5
|
+
const { FFmpegError, ExportCancelledError } = require("../core/errors");
|
|
6
6
|
const { parseFFmpegCommand } = require("../lib/utils");
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Run an FFmpeg command using spawn() to avoid command injection.
|
|
10
10
|
* @param {string} cmd - The full FFmpeg command string
|
|
11
|
-
* @param {
|
|
11
|
+
* @param {Object} [options] - Optional settings
|
|
12
|
+
* @param {Function} [options.onLog] - Log callback receiving { level, message }
|
|
13
|
+
* @param {AbortSignal} [options.signal] - Abort signal to cancel the process
|
|
12
14
|
* @returns {Promise<void>}
|
|
13
15
|
* @throws {FFmpegError} If ffmpeg fails
|
|
16
|
+
* @throws {ExportCancelledError} If aborted via signal
|
|
14
17
|
*/
|
|
15
|
-
function runCmd(cmd, onLog) {
|
|
18
|
+
function runCmd(cmd, { onLog, signal } = {}) {
|
|
16
19
|
return new Promise((resolve, reject) => {
|
|
20
|
+
if (signal && signal.aborted) {
|
|
21
|
+
reject(new ExportCancelledError());
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
const args = parseFFmpegCommand(cmd);
|
|
18
26
|
const ffmpegPath = args.shift(); // Remove 'ffmpeg' from args
|
|
19
27
|
|
|
@@ -22,6 +30,18 @@ function runCmd(cmd, onLog) {
|
|
|
22
30
|
});
|
|
23
31
|
|
|
24
32
|
let stderr = "";
|
|
33
|
+
let cancelled = false;
|
|
34
|
+
|
|
35
|
+
if (signal) {
|
|
36
|
+
const abortHandler = () => {
|
|
37
|
+
cancelled = true;
|
|
38
|
+
proc.kill("SIGTERM");
|
|
39
|
+
};
|
|
40
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
41
|
+
proc.on("close", () => {
|
|
42
|
+
signal.removeEventListener("abort", abortHandler);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
25
45
|
|
|
26
46
|
proc.stdout.on("data", (data) => {
|
|
27
47
|
const chunk = data.toString();
|
|
@@ -43,11 +63,15 @@ function runCmd(cmd, onLog) {
|
|
|
43
63
|
new FFmpegError(`FFmpeg text batch process error: ${error.message}`, {
|
|
44
64
|
stderr,
|
|
45
65
|
command: cmd,
|
|
46
|
-
})
|
|
66
|
+
}),
|
|
47
67
|
);
|
|
48
68
|
});
|
|
49
69
|
|
|
50
70
|
proc.on("close", (code) => {
|
|
71
|
+
if (cancelled) {
|
|
72
|
+
reject(new ExportCancelledError());
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
51
75
|
if (code !== 0) {
|
|
52
76
|
console.error("FFmpeg text batch stderr:", stderr);
|
|
53
77
|
reject(
|
|
@@ -55,7 +79,7 @@ function runCmd(cmd, onLog) {
|
|
|
55
79
|
stderr,
|
|
56
80
|
command: cmd,
|
|
57
81
|
exitCode: code,
|
|
58
|
-
})
|
|
82
|
+
}),
|
|
59
83
|
);
|
|
60
84
|
return;
|
|
61
85
|
}
|
|
@@ -75,6 +99,7 @@ async function runTextPasses({
|
|
|
75
99
|
batchSize = 75,
|
|
76
100
|
onLog,
|
|
77
101
|
tempDir,
|
|
102
|
+
signal,
|
|
78
103
|
}) {
|
|
79
104
|
const tempOutputs = [];
|
|
80
105
|
let currentInput = baseOutputPath;
|
|
@@ -87,12 +112,12 @@ async function runTextPasses({
|
|
|
87
112
|
batch,
|
|
88
113
|
canvasWidth,
|
|
89
114
|
canvasHeight,
|
|
90
|
-
"[invid]"
|
|
115
|
+
"[invid]",
|
|
91
116
|
);
|
|
92
117
|
|
|
93
118
|
const batchOutput = path.join(
|
|
94
119
|
intermediateDir,
|
|
95
|
-
`textpass_${i}_${path.basename(baseOutputPath)}
|
|
120
|
+
`textpass_${i}_${path.basename(baseOutputPath)}`,
|
|
96
121
|
);
|
|
97
122
|
tempOutputs.push(batchOutput);
|
|
98
123
|
|
|
@@ -104,7 +129,7 @@ async function runTextPasses({
|
|
|
104
129
|
intermediateCrf,
|
|
105
130
|
outputPath: batchOutput,
|
|
106
131
|
});
|
|
107
|
-
await runCmd(cmd, onLog);
|
|
132
|
+
await runCmd(cmd, { onLog, signal });
|
|
108
133
|
currentInput = batchOutput;
|
|
109
134
|
passes += 1;
|
|
110
135
|
}
|
|
@@ -33,7 +33,7 @@ function baseYExpression(baseClip, canvasHeight) {
|
|
|
33
33
|
return `(${canvasHeight} - text_h)/2${offsetStr}`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function buildYParamAnimated(baseClip, canvasHeight,
|
|
36
|
+
function buildYParamAnimated(baseClip, canvasHeight, _start, _end) {
|
|
37
37
|
const baseY = baseYExpression(baseClip, canvasHeight);
|
|
38
38
|
return `:y=${baseY}`;
|
|
39
39
|
}
|
|
@@ -70,7 +70,7 @@ function buildAlphaParam(baseClip, start, end) {
|
|
|
70
70
|
return "";
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
function buildFontsizeParam(baseClip, start,
|
|
73
|
+
function buildFontsizeParam(baseClip, start, _end) {
|
|
74
74
|
const anim = baseClip.animation || {};
|
|
75
75
|
const type = anim.type || "none";
|
|
76
76
|
const baseSize = baseClip.fontSize;
|
|
@@ -79,9 +79,9 @@ function buildFontsizeParam(baseClip, start, end) {
|
|
|
79
79
|
const entry =
|
|
80
80
|
typeof anim.in === "number" ? anim.in : C.DEFAULT_TEXT_ANIM_IN;
|
|
81
81
|
return `:fontsize=if(lt(t\\,${start + entry})\\,${(baseSize * 0.7).toFixed(
|
|
82
|
-
3
|
|
82
|
+
3,
|
|
83
83
|
)}+${(baseSize * 0.3).toFixed(
|
|
84
|
-
3
|
|
84
|
+
3,
|
|
85
85
|
)}*sin(PI/2*(t-${start})/${entry})\\,${baseSize})`;
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -98,11 +98,11 @@ function buildFontsizeParam(baseClip, start, end) {
|
|
|
98
98
|
const phase2Duration = (entry * 0.4).toFixed(4);
|
|
99
99
|
// Phase 1: 0.7 -> 1.1 (ease-out), Phase 2: 1.1 -> 1.0 (ease-in-out)
|
|
100
100
|
return `:fontsize=if(lt(t\\,${growEnd.toFixed(
|
|
101
|
-
4
|
|
101
|
+
4,
|
|
102
102
|
)})\\,${minSize}+${growAmount}*sin(PI/2*(t-${start})/${phase1Duration})\\,if(lt(t\\,${
|
|
103
103
|
start + entry
|
|
104
104
|
})\\,${overshoot}-${settleAmount}*sin(PI/2*(t-${growEnd.toFixed(
|
|
105
|
-
4
|
|
105
|
+
4,
|
|
106
106
|
)})/${phase2Duration})\\,${baseSize}))`;
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -144,13 +144,13 @@ function buildDrawtextParams(
|
|
|
144
144
|
canvasWidth,
|
|
145
145
|
canvasHeight,
|
|
146
146
|
start,
|
|
147
|
-
end
|
|
147
|
+
end,
|
|
148
148
|
) {
|
|
149
149
|
const fontSpec = baseClip.fontFile
|
|
150
150
|
? `fontfile='${Strings.escapeTextFilePath(baseClip.fontFile)}'`
|
|
151
151
|
: baseClip.fontFamily
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
? `font=${baseClip.fontFamily}`
|
|
153
|
+
: `font=Sans`;
|
|
154
154
|
|
|
155
155
|
// Use textfile approach if a temp file path is provided (for problematic characters)
|
|
156
156
|
// Only use textfile if the text matches the original clip text (not for typewriter frames, etc.)
|
|
@@ -282,7 +282,7 @@ function buildTextFilters(
|
|
|
282
282
|
textClips,
|
|
283
283
|
canvasWidth,
|
|
284
284
|
canvasHeight,
|
|
285
|
-
initialVideoLabel
|
|
285
|
+
initialVideoLabel,
|
|
286
286
|
) {
|
|
287
287
|
let filterString = "";
|
|
288
288
|
let currentLabel = initialVideoLabel;
|
|
@@ -311,7 +311,7 @@ function buildTextFilters(
|
|
|
311
311
|
canvasWidth,
|
|
312
312
|
canvasHeight,
|
|
313
313
|
w.start,
|
|
314
|
-
w.end
|
|
314
|
+
w.end,
|
|
315
315
|
);
|
|
316
316
|
// Use gte/lt for non-overlapping windows (inclusive start, exclusive end)
|
|
317
317
|
// Last window uses inclusive end so text stays visible
|
|
@@ -332,7 +332,7 @@ function buildTextFilters(
|
|
|
332
332
|
canvasWidth,
|
|
333
333
|
canvasHeight,
|
|
334
334
|
clip.position,
|
|
335
|
-
clip.end
|
|
335
|
+
clip.end,
|
|
336
336
|
);
|
|
337
337
|
const enable = `:enable='between(t,${clip.position},${clip.end})'`;
|
|
338
338
|
const outLabel = nextLabel();
|
|
@@ -355,7 +355,7 @@ function buildTextFilters(
|
|
|
355
355
|
canvasWidth,
|
|
356
356
|
canvasHeight,
|
|
357
357
|
w.start,
|
|
358
|
-
w.end
|
|
358
|
+
w.end,
|
|
359
359
|
);
|
|
360
360
|
const enable = `:enable='between(t,${w.start},${w.end})'`;
|
|
361
361
|
const outLabel = nextLabel();
|
|
@@ -375,7 +375,7 @@ function buildTextFilters(
|
|
|
375
375
|
canvasWidth,
|
|
376
376
|
canvasHeight,
|
|
377
377
|
w.start,
|
|
378
|
-
w.end
|
|
378
|
+
w.end,
|
|
379
379
|
);
|
|
380
380
|
const enable = `:enable='between(t,${w.start},${w.end})'`;
|
|
381
381
|
const outLabel = nextLabel();
|
|
@@ -392,7 +392,7 @@ function buildTextFilters(
|
|
|
392
392
|
canvasWidth,
|
|
393
393
|
canvasHeight,
|
|
394
394
|
clip.position,
|
|
395
|
-
clip.end
|
|
395
|
+
clip.end,
|
|
396
396
|
);
|
|
397
397
|
const enable = `:enable='between(t,${clip.position},${clip.end})'`;
|
|
398
398
|
const outLabel = nextLabel();
|
|
@@ -477,7 +477,7 @@ function buildFiltersForWindows(
|
|
|
477
477
|
windows,
|
|
478
478
|
canvasWidth,
|
|
479
479
|
canvasHeight,
|
|
480
|
-
initialVideoLabel
|
|
480
|
+
initialVideoLabel,
|
|
481
481
|
) {
|
|
482
482
|
let filterString = "";
|
|
483
483
|
let currentLabel = initialVideoLabel;
|
|
@@ -495,7 +495,7 @@ function buildFiltersForWindows(
|
|
|
495
495
|
canvasWidth,
|
|
496
496
|
canvasHeight,
|
|
497
497
|
win.start,
|
|
498
|
-
win.end
|
|
498
|
+
win.end,
|
|
499
499
|
);
|
|
500
500
|
const enable = `:enable='between(t,${win.start},${win.end})'`;
|
|
501
501
|
const outLabel = nextLabel();
|
|
@@ -89,7 +89,7 @@ function resolveKenBurnsOptions(kenBurns, width, height, sourceWidth, sourceHeig
|
|
|
89
89
|
startZoom = DEFAULT_PAN_ZOOM;
|
|
90
90
|
endZoom = DEFAULT_PAN_ZOOM;
|
|
91
91
|
|
|
92
|
-
let panAxis
|
|
92
|
+
let panAxis;
|
|
93
93
|
const hasSourceDims =
|
|
94
94
|
typeof sourceWidth === "number" &&
|
|
95
95
|
typeof sourceHeight === "number" &&
|
|
@@ -269,7 +269,7 @@ function buildVideoFilter(project, videoClips) {
|
|
|
269
269
|
width,
|
|
270
270
|
height,
|
|
271
271
|
clip.width,
|
|
272
|
-
clip.height
|
|
272
|
+
clip.height,
|
|
273
273
|
);
|
|
274
274
|
const zoomExpr = buildZoomExpr(startZoom, endZoom, framesMinusOne, easing);
|
|
275
275
|
const xPosExpr = buildPositionExpr(startX, endX, framesMinusOne, easing);
|
|
@@ -287,7 +287,8 @@ function buildVideoFilter(project, videoClips) {
|
|
|
287
287
|
if (kbFit === "blur-fill") {
|
|
288
288
|
const { cw, ch } = computeContainedSize(clip.width, clip.height, width, height);
|
|
289
289
|
const sigma = typeof clip.blurIntensity === "number" && Number.isFinite(clip.blurIntensity) && clip.blurIntensity > 0
|
|
290
|
-
? clip.blurIntensity
|
|
290
|
+
? clip.blurIntensity
|
|
291
|
+
: 40;
|
|
291
292
|
const overscanCW = computeOverscanWidth(cw, startZoom, endZoom);
|
|
292
293
|
const cs = `${cw}x${ch}`;
|
|
293
294
|
const kbbgLabel = `[kbbg${videoIndex}]`;
|
|
@@ -314,7 +315,8 @@ function buildVideoFilter(project, videoClips) {
|
|
|
314
315
|
|
|
315
316
|
if (fit === "blur-fill") {
|
|
316
317
|
const sigma = typeof clip.blurIntensity === "number" && Number.isFinite(clip.blurIntensity) && clip.blurIntensity > 0
|
|
317
|
-
? clip.blurIntensity
|
|
318
|
+
? clip.blurIntensity
|
|
319
|
+
: 40;
|
|
318
320
|
const bgLabel = `[bg${videoIndex}]`;
|
|
319
321
|
const fgLabel = `[fg${videoIndex}]`;
|
|
320
322
|
const bgrLabel = `[bgr${videoIndex}]`;
|
|
@@ -328,7 +328,7 @@ function validateWatermarkConfig(config) {
|
|
|
328
328
|
if (config[prop] != null && typeof config[prop] === "string") {
|
|
329
329
|
if (!isValidFFmpegColor(config[prop])) {
|
|
330
330
|
errors.push(
|
|
331
|
-
`watermark.${prop} "${config[prop]}" is not a valid FFmpeg color. Use a named color (e.g. "white", "red"), hex (#RRGGBB), or color@alpha (e.g. "black@0.5")
|
|
331
|
+
`watermark.${prop} "${config[prop]}" is not a valid FFmpeg color. Use a named color (e.g. "white", "red"), hex (#RRGGBB), or color@alpha (e.g. "black@0.5").`,
|
|
332
332
|
);
|
|
333
333
|
}
|
|
334
334
|
}
|