simple-ffmpegjs 0.5.2 → 0.5.3

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.
@@ -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
- (c) => c.type === "video" || c.type === "image"
22
- ).length > 0
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 };
@@ -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 /[,;{}\[\]"']/.test(text) || /[^\x20-\x7E]/.test(text);
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, "'\\\\\\''" ) // End quote, \\' (two-level escape), re-open quote
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, "'\\\\\\''" ) // Two-level apostrophe escape (same as escapeDrawtextText)
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 {Function} [onLog] - Optional log callback receiving { level, message }
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, start, end) {
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, end) {
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
- ? `font=${baseClip.fontFamily}`
153
- : `font=Sans`;
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 = "horizontal";
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 : 40;
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 : 40;
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
  }