simple-ffmpegjs 0.3.3 → 0.3.5

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 CHANGED
@@ -28,6 +28,7 @@
28
28
  - [Platform Presets](#platform-presets)
29
29
  - [Watermarks](#watermarks)
30
30
  - [Progress Information](#progress-information)
31
+ - [Logging](#logging)
31
32
  - [Error Handling](#error-handling)
32
33
  - [Cancellation](#cancellation)
33
34
  - [Gap Handling](#gap-handling)
@@ -390,6 +391,47 @@ console.log(audio.duration); // 180.5
390
391
  console.log(audio.sampleRate); // 44100
391
392
  ```
392
393
 
394
+ #### `SIMPLEFFMPEG.snapshot(filePath, options)`
395
+
396
+ Capture a single frame from a video file and save it as an image. This is a static method — no project instance needed.
397
+
398
+ The output format is determined by the `outputPath` file extension. FFmpeg handles format detection internally, so `.jpg` produces JPEG, `.png` produces PNG, `.webp` produces WebP, etc.
399
+
400
+ ```ts
401
+ await SIMPLEFFMPEG.snapshot("./video.mp4", {
402
+ outputPath: "./frame.png",
403
+ time: 5,
404
+ });
405
+ ```
406
+
407
+ **Snapshot Options:**
408
+
409
+ | Option | Type | Default | Description |
410
+ | ------------ | -------- | ------- | --------------------------------------------------------------------------- |
411
+ | `outputPath` | `string` | - | **Required.** Output image path (extension determines format) |
412
+ | `time` | `number` | `0` | Time in seconds to capture the frame at |
413
+ | `width` | `number` | - | Output width in pixels (maintains aspect ratio if height omitted) |
414
+ | `height` | `number` | - | Output height in pixels (maintains aspect ratio if width omitted) |
415
+ | `quality` | `number` | `2` | JPEG quality 1-31, lower is better (only applies to `.jpg`/`.jpeg` output) |
416
+
417
+ **Supported formats:** `.jpg` / `.jpeg`, `.png`, `.webp`, `.bmp`, `.tiff`
418
+
419
+ ```ts
420
+ // Save as JPEG with quality control and resize
421
+ await SIMPLEFFMPEG.snapshot("./video.mp4", {
422
+ outputPath: "./thumb.jpg",
423
+ time: 10,
424
+ width: 640,
425
+ quality: 4,
426
+ });
427
+
428
+ // Save as WebP
429
+ await SIMPLEFFMPEG.snapshot("./video.mp4", {
430
+ outputPath: "./preview.webp",
431
+ time: 0,
432
+ });
433
+ ```
434
+
393
435
  #### `project.export(options)`
394
436
 
395
437
  Build and execute the FFmpeg command to render the final video.
@@ -421,6 +463,7 @@ await project.export(options?: ExportOptions): Promise<string>
421
463
  | `verbose` | `boolean` | `false` | Enable verbose logging |
422
464
  | `saveCommand` | `string` | - | Save FFmpeg command to file |
423
465
  | `onProgress` | `function` | - | Progress callback |
466
+ | `onLog` | `function` | - | FFmpeg log callback (see [Logging](#logging) section) |
424
467
  | `signal` | `AbortSignal` | - | Cancellation signal |
425
468
  | `watermark` | `object` | - | Add watermark overlay (see Watermarks section) |
426
469
  | `compensateTransitions` | `boolean` | `true` | Auto-adjust text timings for transition overlap (see below) |
@@ -722,6 +765,21 @@ onProgress: ({ percent, phase }) => {
722
765
  }
723
766
  ```
724
767
 
768
+ ### Logging
769
+
770
+ Use the `onLog` callback to receive real-time FFmpeg output. Each log entry includes a `level` (`"stderr"` or `"stdout"`) and the raw `message` string. This is useful for debugging, monitoring, or piping FFmpeg output to your own logging system.
771
+
772
+ ```ts
773
+ await project.export({
774
+ outputPath: "./output.mp4",
775
+ onLog: ({ level, message }) => {
776
+ console.log(`[ffmpeg:${level}] ${message}`);
777
+ },
778
+ });
779
+ ```
780
+
781
+ The callback fires for every data chunk FFmpeg writes, including encoding stats, warnings, and codec information. It works alongside `onProgress` — both can be used simultaneously.
782
+
725
783
  ### Error Handling
726
784
 
727
785
  The library provides custom error classes for structured error handling:
@@ -729,7 +787,7 @@ The library provides custom error classes for structured error handling:
729
787
  | Error Class | When Thrown | Properties |
730
788
  | ---------------------- | -------------------------- | --------------------------------------------------------------------------- |
731
789
  | `ValidationError` | Invalid clip configuration | `errors[]`, `warnings[]` (structured issues with `code`, `path`, `message`) |
732
- | `FFmpegError` | FFmpeg command fails | `stderr`, `command`, `exitCode` |
790
+ | `FFmpegError` | FFmpeg command fails | `stderr`, `command`, `exitCode`, `details` |
733
791
  | `MediaNotFoundError` | File not found | `path` |
734
792
  | `ExportCancelledError` | Export aborted | - |
735
793
 
@@ -746,8 +804,9 @@ try {
746
804
  console.warn(`[${w.code}] ${w.path}: ${w.message}`)
747
805
  );
748
806
  } else if (error.name === "FFmpegError") {
749
- console.error("FFmpeg failed:", error.stderr);
750
- console.error("Command was:", error.command);
807
+ // Structured details for bug reports (last 50 lines of stderr, command, exitCode)
808
+ console.error("FFmpeg failed:", error.details);
809
+ // { stderrTail: "...", command: "ffmpeg ...", exitCode: 1 }
751
810
  } else if (error.name === "MediaNotFoundError") {
752
811
  console.error("File not found:", error.path);
753
812
  } else if (error.name === "ExportCancelledError") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simple-ffmpegjs",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Declarative video composition for Node.js — define clips, transitions, text, and audio as simple objects, and let FFmpeg handle the rest.",
5
5
  "author": "Brayden Blackwell <braydenblackwell21@gmail.com> (https://github.com/Fats403)",
6
6
  "license": "MIT",
@@ -32,6 +32,20 @@ class FFmpegError extends SimpleffmpegError {
32
32
  this.command = command;
33
33
  this.exitCode = exitCode;
34
34
  }
35
+
36
+ /**
37
+ * Structured error details for easy bug reporting.
38
+ * Contains the last 50 lines of stderr, the command, and exit code.
39
+ */
40
+ get details() {
41
+ const lines = (this.stderr || "").split("\n");
42
+ const tail = lines.slice(-50).join("\n");
43
+ return {
44
+ stderrTail: tail,
45
+ command: this.command,
46
+ exitCode: this.exitCode,
47
+ };
48
+ }
35
49
  }
36
50
 
37
51
  /**
@@ -1,4 +1,11 @@
1
- function buildAudioForVideoClips(project, videoClips) {
1
+ /**
2
+ * Build audio filter chain for video clips.
3
+ *
4
+ * @param {Object} project - The SIMPLEFFMPEG project instance
5
+ * @param {Array} videoClips - Array of video clip objects
6
+ * @param {Map} [transitionOffsets] - Map of clip -> cumulative transition offset in seconds
7
+ */
8
+ function buildAudioForVideoClips(project, videoClips, transitionOffsets) {
2
9
  let audioFilter = "";
3
10
  const labels = [];
4
11
 
@@ -15,9 +22,11 @@ function buildAudioForVideoClips(project, videoClips) {
15
22
  : requestedDuration;
16
23
  const clipDuration = Math.max(0, Math.min(requestedDuration, maxAvailable));
17
24
 
18
- const adelayMs = Math.round(Math.max(0, clip.position || 0) * 1000);
25
+ const offset = transitionOffsets ? (transitionOffsets.get(clip) || 0) : 0;
26
+ const adelayMs = Math.round(Math.max(0, (clip.position || 0) - offset) * 1000);
27
+ const vol = clip.volume != null ? clip.volume : 1;
19
28
  const out = `[va${inputIndex}]`;
20
- audioFilter += `[${inputIndex}:a]atrim=start=${clip.cutFrom}:duration=${clipDuration},asetpts=PTS-STARTPTS,adelay=${adelayMs}|${adelayMs}${out};`;
29
+ audioFilter += `[${inputIndex}:a]volume=${vol},atrim=start=${clip.cutFrom}:duration=${clipDuration},asetpts=PTS-STARTPTS,adelay=${adelayMs}|${adelayMs}${out};`;
21
30
  labels.push(out);
22
31
  });
23
32
 
@@ -209,6 +209,45 @@ function buildThumbnailCommand({ inputPath, outputPath, time, width, height }) {
209
209
  return cmd;
210
210
  }
211
211
 
212
+ /**
213
+ * Build command to capture a single frame from a video file.
214
+ * Output format is determined by the outputPath extension (jpg, png, webp, etc.).
215
+ *
216
+ * @param {Object} options
217
+ * @param {string} options.inputPath - Path to source video
218
+ * @param {string} options.outputPath - Output image path (extension determines format)
219
+ * @param {number} [options.time=0] - Time in seconds to capture
220
+ * @param {number} [options.width] - Output width (maintains aspect if height omitted)
221
+ * @param {number} [options.height] - Output height (maintains aspect if width omitted)
222
+ * @param {number} [options.quality] - JPEG quality 1-31, lower is better (only applies to JPEG)
223
+ * @returns {string} FFmpeg command string
224
+ */
225
+ function buildSnapshotCommand({
226
+ inputPath,
227
+ outputPath,
228
+ time = 0,
229
+ width,
230
+ height,
231
+ quality,
232
+ }) {
233
+ let cmd = `ffmpeg -y -ss ${time} -i "${escapeFilePath(
234
+ inputPath
235
+ )}" -vframes 1 `;
236
+
237
+ if (width || height) {
238
+ const w = width || -1;
239
+ const h = height || -1;
240
+ cmd += `-vf "scale=${w}:${h}" `;
241
+ }
242
+
243
+ if (quality != null) {
244
+ cmd += `-q:v ${quality} `;
245
+ }
246
+
247
+ cmd += `"${escapeFilePath(outputPath)}"`;
248
+ return cmd;
249
+ }
250
+
212
251
  /**
213
252
  * Escape metadata value for FFmpeg
214
253
  */
@@ -223,5 +262,6 @@ module.exports = {
223
262
  buildMainCommand,
224
263
  buildTextBatchCommand,
225
264
  buildThumbnailCommand,
265
+ buildSnapshotCommand,
226
266
  escapeMetadata,
227
267
  };
@@ -1,7 +1,3 @@
1
- function escapeSingleQuotes(text) {
2
- return String(text).replace(/'/g, "\\'");
3
- }
4
-
5
1
  /**
6
2
  * Escape a file path for use in FFmpeg command line arguments.
7
3
  * Prevents command injection by escaping quotes and backslashes.
@@ -21,32 +17,54 @@ function escapeFilePath(filePath) {
21
17
  */
22
18
  function hasProblematicChars(text) {
23
19
  if (typeof text !== "string") return false;
24
- // These characters cannot be reliably escaped in filter_complex parsing
25
- // when passed through shell double-quoting
26
- return /[,;{}\[\]"]/.test(text);
20
+ // These characters cannot be reliably escaped in filter_complex parsing.
21
+ // Single quotes (') are included because FFmpeg's av_get_token does NOT
22
+ // support \' inside single-quoted strings — ' always ends the quoted value.
23
+ // Non-ASCII characters are also routed to textfile to avoid parser issues
24
+ // with UTF-8 inside filter_complex.
25
+ return /[,;{}\[\]"']/.test(text) || /[^\x20-\x7E]/.test(text);
27
26
  }
28
27
 
29
28
  function escapeDrawtextText(text) {
30
29
  if (typeof text !== "string") return "";
31
- // Escape characters that have special meaning in FFmpeg drawtext filter
32
- // AND characters that break shell parsing (since filter_complex is double-quoted)
30
+ // Escape characters that have special meaning in FFmpeg drawtext filter.
31
+ //
32
+ // FFmpeg parses the filter_complex value through TWO levels of av_get_token:
33
+ // Level 1 — filter-graph parser (terminators: [ , ; \n)
34
+ // Level 2 — filter option parser (terminators: : = )
35
+ // Both levels handle '...' quoting and \x escaping identically.
36
+ //
37
+ // To embed a literal single quote that survives both levels:
38
+ // 1. End the current level-1 quoted segment: '
39
+ // 2. \\ — level 1 escape → produces \ in output
40
+ // 3. \' — level 1 escape → produces ' in output
41
+ // So level 2 sees \' → escaped quote → literal '
42
+ // 4. Re-open a new level-1 quoted segment: '
43
+ // The 6-char replacement per apostrophe is: '\\\''
44
+ //
45
+ // Backslash (\\) and colon (\:) escaping inside the quoted segments is
46
+ // passed through literally by level 1 (no escape processing inside '...'),
47
+ // then processed by level 2 as escape sequences: \\ → \ and \: → :
33
48
  return text
34
- .replace(/\\/g, "\\\\") // Escape backslashes first
35
- .replace(/'/g, "\\'") // Escape single quotes (text delimiter)
36
- .replace(/"/g, '\\"') // Escape double quotes (shell safety)
37
- .replace(/:/g, "\\:") // Escape colons (option separator)
49
+ .replace(/\\/g, "\\\\") // Escape backslashes (level 2 decodes \\ → \)
50
+ .replace(/'/g, "'\\\\\\''" ) // End quote, \\' (two-level escape), re-open quote
51
+ .replace(/:/g, "\\:") // Escape colons (level 2 decodes \: → :)
38
52
  .replace(/\n/g, " ") // Replace newlines with space (multiline not supported)
39
53
  .replace(/\r/g, ""); // Remove carriage returns
40
54
  }
41
55
 
42
56
  /**
43
- * Escape a file path for use in FFmpeg filters (Windows paths need special handling)
57
+ * Escape a file path for use inside single-quoted FFmpeg filter parameters
58
+ * (e.g., textfile='...', ass='...').
59
+ *
60
+ * These paths are parsed through two levels of av_get_token, so single
61
+ * quotes need the same '\\\'' two-level escape as drawtext text values.
44
62
  */
45
63
  function escapeTextFilePath(filePath) {
46
64
  if (typeof filePath !== "string") return "";
47
- // FFmpeg on Windows needs forward slashes and escaped colons
48
65
  return filePath
49
66
  .replace(/\\/g, "/") // Convert backslashes to forward slashes
67
+ .replace(/'/g, "'\\\\\\''" ) // Two-level apostrophe escape (same as escapeDrawtextText)
50
68
  .replace(/:/g, "\\:"); // Escape colons (for Windows drive letters)
51
69
  }
52
70
 
@@ -62,7 +80,6 @@ function getClipAudioString(clip, inputIndex) {
62
80
  }
63
81
 
64
82
  module.exports = {
65
- escapeSingleQuotes,
66
83
  escapeFilePath,
67
84
  escapeDrawtextText,
68
85
  getClipAudioString,
@@ -637,11 +637,11 @@ function loadSubtitleFile(filePath, options, canvasWidth, canvasHeight) {
637
637
  * @returns {{ filter: string, finalLabel: string }}
638
638
  */
639
639
  function buildASSFilter(assFilePath, inputLabel) {
640
- // Escape path for FFmpeg filter
640
+ // Escape path for FFmpeg filter (must survive two levels of av_get_token)
641
641
  const escapedPath = assFilePath
642
642
  .replace(/\\/g, "/")
643
643
  .replace(/:/g, "\\:")
644
- .replace(/'/g, "'\\''");
644
+ .replace(/'/g, "'\\\\\\''" );
645
645
 
646
646
  const outputLabel = "[outass]";
647
647
  const filter = `${inputLabel}ass='${escapedPath}'${outputLabel}`;
@@ -8,10 +8,11 @@ const { parseFFmpegCommand } = require("../lib/utils");
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
12
  * @returns {Promise<void>}
12
13
  * @throws {FFmpegError} If ffmpeg fails
13
14
  */
14
- function runCmd(cmd) {
15
+ function runCmd(cmd, onLog) {
15
16
  return new Promise((resolve, reject) => {
16
17
  const args = parseFFmpegCommand(cmd);
17
18
  const ffmpegPath = args.shift(); // Remove 'ffmpeg' from args
@@ -22,8 +23,19 @@ function runCmd(cmd) {
22
23
 
23
24
  let stderr = "";
24
25
 
26
+ proc.stdout.on("data", (data) => {
27
+ const chunk = data.toString();
28
+ if (onLog && typeof onLog === "function") {
29
+ onLog({ level: "stdout", message: chunk });
30
+ }
31
+ });
32
+
25
33
  proc.stderr.on("data", (data) => {
26
- stderr += data.toString();
34
+ const chunk = data.toString();
35
+ stderr += chunk;
36
+ if (onLog && typeof onLog === "function") {
37
+ onLog({ level: "stderr", message: chunk });
38
+ }
27
39
  });
28
40
 
29
41
  proc.on("error", (error) => {
@@ -61,6 +73,7 @@ async function runTextPasses({
61
73
  intermediatePreset,
62
74
  intermediateCrf,
63
75
  batchSize = 75,
76
+ onLog,
64
77
  }) {
65
78
  const tempOutputs = [];
66
79
  let currentInput = baseOutputPath;
@@ -89,7 +102,7 @@ async function runTextPasses({
89
102
  intermediateCrf,
90
103
  outputPath: batchOutput,
91
104
  });
92
- await runCmd(cmd);
105
+ await runCmd(cmd, onLog);
93
106
  currentInput = batchOutput;
94
107
  passes += 1;
95
108
  }
@@ -147,7 +147,7 @@ function buildDrawtextParams(
147
147
  end
148
148
  ) {
149
149
  const fontSpec = baseClip.fontFile
150
- ? `fontfile=${baseClip.fontFile}`
150
+ ? `fontfile='${Strings.escapeTextFilePath(baseClip.fontFile)}'`
151
151
  : baseClip.fontFamily
152
152
  ? `font=${baseClip.fontFamily}`
153
153
  : `font=Sans`;
@@ -29,7 +29,7 @@ function buildVideoFilter(project, videoClips) {
29
29
  if (gaps.length > 0) {
30
30
  const blackClips = createBlackClipsForGaps(gaps, fps, width, height);
31
31
  allVisualClips = [...videoClips, ...blackClips].sort(
32
- (a, b) => (a.position || 0) - (b.position || 0)
32
+ (a, b) => (a.position || 0) - (b.position || 0),
33
33
  );
34
34
  }
35
35
  }
@@ -41,7 +41,7 @@ function buildVideoFilter(project, videoClips) {
41
41
 
42
42
  const requestedDuration = Math.max(
43
43
  0,
44
- (clip.end || 0) - (clip.position || 0)
44
+ (clip.end || 0) - (clip.position || 0),
45
45
  );
46
46
 
47
47
  // Handle synthetic black fill clips
@@ -85,12 +85,12 @@ function buildVideoFilter(project, videoClips) {
85
85
  if (type === "zoom-in") {
86
86
  const inc = (zoomAmount / framesMinusOne).toFixed(6);
87
87
  // Ensure first frame starts exactly at base zoom=1 (on==0)
88
- zoomExpr = `if(eq(on\\,0)\\,1\\,zoom+${inc})`;
88
+ zoomExpr = `if(eq(on,0),1,zoom+${inc})`;
89
89
  } else if (type === "zoom-out") {
90
90
  const start = (1 + zoomAmount).toFixed(4);
91
91
  const dec = (zoomAmount / framesMinusOne).toFixed(6);
92
92
  // Start pre-zoomed on first frame to avoid jump
93
- zoomExpr = `if(eq(on\\,0)\\,${start}\\,zoom-${dec})`;
93
+ zoomExpr = `if(eq(on,0),${start},zoom-${dec})`;
94
94
  } else {
95
95
  const panZoom = 1.12;
96
96
  zoomExpr = `${panZoom}`;
@@ -113,7 +113,9 @@ function buildVideoFilter(project, videoClips) {
113
113
 
114
114
  // Scale to cover target dimensions (upscaling if needed), then center crop
115
115
  // force_original_aspect_ratio=increase ensures image covers the target area
116
- filterComplex += `[${inputIndex}:v]select=eq(n\\,0),setpts=PTS-STARTPTS,scale=${width}:${height}:force_original_aspect_ratio=increase,setsar=1:1,crop=${width}:${height}:(iw-${width})/2:(ih-${height})/2,scale=${overscanW}:-1,zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${frames}:s=${s},fps=${fps},settb=1/${fps}${scaledLabel};`;
116
+ // select='eq(n,0)' uses single quotes to protect the comma from the graph parser.
117
+ // zoompan z/x/y values are also single-quoted — commas inside are literal.
118
+ filterComplex += `[${inputIndex}:v]select='eq(n,0)',setpts=PTS-STARTPTS,scale=${width}:${height}:force_original_aspect_ratio=increase,setsar=1:1,crop=${width}:${height}:(iw-${width})/2:(ih-${height})/2,scale=${overscanW}:-1,zoompan=z='${zoomExpr}':x='${xExpr}':y='${yExpr}':d=${frames}:s=${s},fps=${fps},settb=1/${fps}${scaledLabel};`;
117
119
  } else {
118
120
  filterComplex += `[${inputIndex}:v]trim=start=${
119
121
  clip.cutFrom || 0
@@ -134,7 +136,7 @@ function buildVideoFilter(project, videoClips) {
134
136
  }
135
137
 
136
138
  const hasTransitions = scaledStreams.some(
137
- (s, i) => i > 0 && s.clip.transition
139
+ (s, i) => i > 0 && s.clip.transition,
138
140
  );
139
141
 
140
142
  if (!hasTransitions) {
@@ -1,16 +1,5 @@
1
1
  const Strings = require("./strings");
2
2
 
3
- /**
4
- * Position presets for watermarks
5
- */
6
- const POSITION_PRESETS = {
7
- "top-left": (margin) => ({ x: margin, y: margin }),
8
- "top-right": (margin) => ({ x: `W-w-${margin}`, y: margin }),
9
- "bottom-left": (margin) => ({ x: margin, y: `H-h-${margin}` }),
10
- "bottom-right": (margin) => ({ x: `W-w-${margin}`, y: `H-h-${margin}` }),
11
- center: () => ({ x: "(W-w)/2", y: "(H-h)/2" }),
12
- };
13
-
14
3
  /**
15
4
  * Calculate position expressions for overlay/drawtext
16
5
  * @param {Object} config - Watermark config with position, margin
@@ -91,7 +80,7 @@ function buildImageWatermark(
91
80
  watermarkInputIndex,
92
81
  canvasWidth,
93
82
  canvasHeight,
94
- totalDuration
83
+ totalDuration,
95
84
  ) {
96
85
  const scale = typeof config.scale === "number" ? config.scale : 0.15;
97
86
  const opacity = typeof config.opacity === "number" ? config.opacity : 1;
@@ -144,7 +133,7 @@ function buildTextWatermark(
144
133
  videoLabel,
145
134
  canvasWidth,
146
135
  canvasHeight,
147
- totalDuration
136
+ totalDuration,
148
137
  ) {
149
138
  const text = config.text || "";
150
139
  const fontSize = typeof config.fontSize === "number" ? config.fontSize : 24;
@@ -158,9 +147,8 @@ function buildTextWatermark(
158
147
  // Escape text for drawtext
159
148
  const escapedText = Strings.escapeDrawtextText(text);
160
149
 
161
- // Font specification
162
150
  const fontSpec = config.fontFile
163
- ? `fontfile=${config.fontFile}`
151
+ ? `fontfile='${Strings.escapeTextFilePath(config.fontFile)}'`
164
152
  : `font=${fontFamily}`;
165
153
 
166
154
  // Calculate position (for text)
@@ -257,7 +245,7 @@ function buildWatermarkFilter(
257
245
  watermarkInputIndex,
258
246
  canvasWidth,
259
247
  canvasHeight,
260
- totalDuration
248
+ totalDuration,
261
249
  ) {
262
250
  if (!config || typeof config !== "object") {
263
251
  return { filter: "", finalLabel: videoLabel, needsInput: false };
@@ -271,7 +259,7 @@ function buildWatermarkFilter(
271
259
  videoLabel,
272
260
  canvasWidth,
273
261
  canvasHeight,
274
- totalDuration
262
+ totalDuration,
275
263
  );
276
264
  return { ...result, needsInput: false };
277
265
  }
@@ -288,7 +276,7 @@ function buildWatermarkFilter(
288
276
  watermarkInputIndex,
289
277
  canvasWidth,
290
278
  canvasHeight,
291
- totalDuration
279
+ totalDuration,
292
280
  );
293
281
  return { ...result, needsInput: true };
294
282
  }
@@ -369,8 +357,8 @@ function validateWatermarkConfig(config) {
369
357
  if (!validPositions.includes(config.position)) {
370
358
  errors.push(
371
359
  `watermark.position must be one of: ${validPositions.join(
372
- ", "
373
- )}; got '${config.position}'`
360
+ ", ",
361
+ )}; got '${config.position}'`,
374
362
  );
375
363
  }
376
364
  } else if (typeof config.position === "object" && config.position !== null) {
package/src/lib/utils.js CHANGED
@@ -76,9 +76,10 @@ function parseFFmpegProgress(line, totalDuration) {
76
76
  * @param {number} options.totalDuration - Expected output duration in seconds (for progress %)
77
77
  * @param {Function} options.onProgress - Progress callback
78
78
  * @param {AbortSignal} options.signal - AbortSignal for cancellation
79
+ * @param {Function} options.onLog - Log callback receiving { level: "stderr"|"stdout", message: string }
79
80
  * @returns {Promise<{stdout: string, stderr: string}>}
80
81
  */
81
- function runFFmpeg({ command, totalDuration = 0, onProgress, signal }) {
82
+ function runFFmpeg({ command, totalDuration = 0, onProgress, signal, onLog }) {
82
83
  return new Promise((resolve, reject) => {
83
84
  // Parse command into args (simple split, assumes no quoted args with spaces in values)
84
85
  // FFmpeg commands from this library don't have spaces in quoted paths handled this way
@@ -115,13 +116,21 @@ function runFFmpeg({ command, totalDuration = 0, onProgress, signal }) {
115
116
  }
116
117
 
117
118
  proc.stdout.on("data", (data) => {
118
- stdout += data.toString();
119
+ const chunk = data.toString();
120
+ stdout += chunk;
121
+ if (onLog && typeof onLog === "function") {
122
+ onLog({ level: "stdout", message: chunk });
123
+ }
119
124
  });
120
125
 
121
126
  proc.stderr.on("data", (data) => {
122
127
  const chunk = data.toString();
123
128
  stderr += chunk;
124
129
 
130
+ if (onLog && typeof onLog === "function") {
131
+ onLog({ level: "stderr", message: chunk });
132
+ }
133
+
125
134
  // Parse progress from stderr (FFmpeg outputs progress to stderr)
126
135
  if (onProgress && typeof onProgress === "function") {
127
136
  const progress = parseFFmpegProgress(chunk, totalDuration);
@@ -164,8 +173,22 @@ function runFFmpeg({ command, totalDuration = 0, onProgress, signal }) {
164
173
  }
165
174
 
166
175
  /**
167
- * Parse a command string into an array of arguments
168
- * Handles quoted strings and escaped characters
176
+ * Parse a command string into an array of arguments.
177
+ *
178
+ * Inside quoted strings (single or double):
179
+ * - All characters are literal (no escape processing).
180
+ * - The matching closing quote ends the argument segment.
181
+ *
182
+ * This deliberately avoids backslash-escape handling because the
183
+ * filter_complex value relies on \\, \, and \: being passed through
184
+ * verbatim to FFmpeg. For example drawtext's fontsize expressions
185
+ * use \\, (which drawtext decodes as \, → escaped comma) and text
186
+ * values use \\\\ (which drawtext decodes as \\ → literal backslash).
187
+ * Any unescaping here would corrupt those sequences.
188
+ *
189
+ * Outside quotes:
190
+ * - Whitespace separates arguments.
191
+ * - All other characters (including backslash) are literal.
169
192
  */
170
193
  function parseFFmpegCommand(command) {
171
194
  const args = [];
@@ -179,14 +202,12 @@ function parseFFmpegCommand(command) {
179
202
  if (inQuote) {
180
203
  if (char === quoteChar) {
181
204
  inQuote = false;
182
- // Don't add the closing quote to the argument
183
205
  } else {
184
206
  current += char;
185
207
  }
186
208
  } else if (char === '"' || char === "'") {
187
209
  inQuote = true;
188
210
  quoteChar = char;
189
- // Don't add the opening quote to the argument
190
211
  } else if (char === " " || char === "\t") {
191
212
  if (current.length > 0) {
192
213
  args.push(current);
@@ -28,6 +28,7 @@ const C = require("./core/constants");
28
28
  const {
29
29
  buildMainCommand,
30
30
  buildThumbnailCommand,
31
+ buildSnapshotCommand,
31
32
  } = require("./ffmpeg/command_builder");
32
33
  const { runTextPasses } = require("./ffmpeg/text_passes");
33
34
  const { formatBytes, runFFmpeg } = require("./lib/utils");
@@ -252,7 +253,7 @@ class SIMPLEFFMPEG {
252
253
  await Promise.all(
253
254
  resolvedClips.map((clipObj) => {
254
255
  if (clipObj.type === "video" || clipObj.type === "audio") {
255
- clipObj.volume = clipObj.volume || 1;
256
+ clipObj.volume = clipObj.volume != null ? clipObj.volume : 1;
256
257
  clipObj.cutFrom = clipObj.cutFrom || 0;
257
258
  if (clipObj.type === "video" && clipObj.transition) {
258
259
  clipObj.transition = {
@@ -438,8 +439,18 @@ class SIMPLEFFMPEG {
438
439
  }
439
440
 
440
441
  // Audio for video clips (aligned amix)
442
+ // Compute cumulative transition offsets so audio adelay values
443
+ // match the xfade-compressed video timeline.
441
444
  if (videoClips.length > 0) {
442
- const ares = buildAudioForVideoClips(this, videoClips);
445
+ const transitionOffsets = new Map();
446
+ let cumOffset = 0;
447
+ for (let i = 0; i < videoClips.length; i++) {
448
+ if (i > 0 && videoClips[i].transition) {
449
+ cumOffset += videoClips[i].transition.duration || 0;
450
+ }
451
+ transitionOffsets.set(videoClips[i], cumOffset);
452
+ }
453
+ const ares = buildAudioForVideoClips(this, videoClips, transitionOffsets);
443
454
  filterComplex += ares.filter;
444
455
  finalAudioLabel = ares.finalAudioLabel || finalAudioLabel;
445
456
  hasAudio = hasAudio || ares.hasAudio;
@@ -860,7 +871,7 @@ class SIMPLEFFMPEG {
860
871
 
861
872
  this._isExporting = true;
862
873
  const t0 = Date.now();
863
- const { onProgress, signal } = options;
874
+ const { onProgress, signal, onLog } = options;
864
875
 
865
876
  let prepared;
866
877
  try {
@@ -948,6 +959,7 @@ class SIMPLEFFMPEG {
948
959
  command: pass1Command,
949
960
  totalDuration,
950
961
  signal,
962
+ onLog,
951
963
  });
952
964
 
953
965
  // Second pass
@@ -984,6 +996,7 @@ class SIMPLEFFMPEG {
984
996
  totalDuration,
985
997
  onProgress,
986
998
  signal,
999
+ onLog,
987
1000
  });
988
1001
 
989
1002
  // Clean up pass log files
@@ -998,6 +1011,7 @@ class SIMPLEFFMPEG {
998
1011
  totalDuration,
999
1012
  onProgress,
1000
1013
  signal,
1014
+ onLog,
1001
1015
  });
1002
1016
  }
1003
1017
 
@@ -1020,6 +1034,7 @@ class SIMPLEFFMPEG {
1020
1034
  intermediatePreset: exportOptions.intermediatePreset,
1021
1035
  intermediateCrf: exportOptions.intermediateCrf,
1022
1036
  batchSize: exportOptions.textMaxNodesPerPass,
1037
+ onLog,
1023
1038
  });
1024
1039
  passes = textPasses;
1025
1040
  if (finalPath !== exportOptions.outputPath) {
@@ -1047,7 +1062,7 @@ class SIMPLEFFMPEG {
1047
1062
  console.log("simple-ffmpeg: Generating thumbnail...");
1048
1063
  }
1049
1064
 
1050
- await runFFmpeg({ command: thumbCommand });
1065
+ await runFFmpeg({ command: thumbCommand, onLog });
1051
1066
  console.log(`simple-ffmpeg: Thumbnail -> ${thumbOptions.outputPath}`);
1052
1067
  }
1053
1068
 
@@ -1211,6 +1226,72 @@ class SIMPLEFFMPEG {
1211
1226
  return probeMedia(filePath);
1212
1227
  }
1213
1228
 
1229
+ /**
1230
+ * Capture a single frame from a video file and save it as an image.
1231
+ *
1232
+ * The output format is determined by the `outputPath` file extension.
1233
+ * Supported formats include: `.jpg`/`.jpeg`, `.png`, `.webp`, `.bmp`, `.tiff`.
1234
+ *
1235
+ * @param {string} filePath - Path to the source video file
1236
+ * @param {Object} options - Snapshot options
1237
+ * @param {string} options.outputPath - Output image path (extension determines format)
1238
+ * @param {number} [options.time=0] - Time in seconds to capture the frame at
1239
+ * @param {number} [options.width] - Output width in pixels (maintains aspect ratio if height omitted)
1240
+ * @param {number} [options.height] - Output height in pixels (maintains aspect ratio if width omitted)
1241
+ * @param {number} [options.quality] - JPEG quality 1-31, lower is better (default: 2, only applies to JPEG)
1242
+ * @returns {Promise<string>} The resolved output path
1243
+ * @throws {SimpleffmpegError} If filePath or outputPath is missing
1244
+ * @throws {FFmpegError} If FFmpeg fails to extract the frame
1245
+ *
1246
+ * @example
1247
+ * // Save as PNG
1248
+ * await SIMPLEFFMPEG.snapshot("./video.mp4", {
1249
+ * outputPath: "./frame.png",
1250
+ * time: 5,
1251
+ * });
1252
+ *
1253
+ * @example
1254
+ * // Save as JPEG with quality and resize
1255
+ * await SIMPLEFFMPEG.snapshot("./video.mp4", {
1256
+ * outputPath: "./thumb.jpg",
1257
+ * time: 10,
1258
+ * width: 640,
1259
+ * quality: 4,
1260
+ * });
1261
+ */
1262
+ static async snapshot(filePath, options = {}) {
1263
+ if (!filePath) {
1264
+ throw new SimpleffmpegError(
1265
+ "snapshot() requires a filePath as the first argument"
1266
+ );
1267
+ }
1268
+ if (!options.outputPath) {
1269
+ throw new SimpleffmpegError(
1270
+ "snapshot() requires options.outputPath to be specified"
1271
+ );
1272
+ }
1273
+
1274
+ const {
1275
+ outputPath,
1276
+ time = 0,
1277
+ width,
1278
+ height,
1279
+ quality,
1280
+ } = options;
1281
+
1282
+ const command = buildSnapshotCommand({
1283
+ inputPath: filePath,
1284
+ outputPath,
1285
+ time,
1286
+ width,
1287
+ height,
1288
+ quality,
1289
+ });
1290
+
1291
+ await runFFmpeg({ command });
1292
+ return outputPath;
1293
+ }
1294
+
1214
1295
  /**
1215
1296
  * Format validation result as human-readable string
1216
1297
  * @param {Object} result - Validation result from validate()
package/types/index.d.mts CHANGED
@@ -21,6 +21,12 @@ declare namespace SIMPLEFFMPEG {
21
21
  stderr: string;
22
22
  command: string;
23
23
  exitCode: number | null;
24
+ /** Structured error details for bug reporting */
25
+ readonly details: {
26
+ stderrTail: string;
27
+ command: string;
28
+ exitCode: number | null;
29
+ };
24
30
  }
25
31
 
26
32
  /** Thrown when a media file cannot be found or accessed */
@@ -295,6 +301,12 @@ declare namespace SIMPLEFFMPEG {
295
301
  fillGaps?: "none" | "black";
296
302
  }
297
303
 
304
+ /** Log entry passed to onLog callback */
305
+ interface LogEntry {
306
+ level: "stderr" | "stdout";
307
+ message: string;
308
+ }
309
+
298
310
  /** Progress information passed to onProgress callback */
299
311
  interface ProgressInfo {
300
312
  /** Current frame number being processed */
@@ -334,6 +346,20 @@ declare namespace SIMPLEFFMPEG {
334
346
  height?: number;
335
347
  }
336
348
 
349
+ /** Options for SIMPLEFFMPEG.snapshot() — capture a single frame from a video */
350
+ interface SnapshotOptions {
351
+ /** Output image path (extension determines format: .jpg, .png, .webp, .bmp, .tiff) */
352
+ outputPath: string;
353
+ /** Time in seconds to capture the frame at (default: 0) */
354
+ time?: number;
355
+ /** Output width in pixels (maintains aspect ratio if height omitted) */
356
+ width?: number;
357
+ /** Output height in pixels (maintains aspect ratio if width omitted) */
358
+ height?: number;
359
+ /** JPEG quality 1-31, lower is better (default: 2, only applies to JPEG output) */
360
+ quality?: number;
361
+ }
362
+
337
363
  type HardwareAcceleration =
338
364
  | "auto"
339
365
  | "videotoolbox"
@@ -483,6 +509,8 @@ declare namespace SIMPLEFFMPEG {
483
509
 
484
510
  // Callbacks & Control
485
511
  onProgress?: (progress: ProgressInfo) => void;
512
+ /** FFmpeg log callback for real-time stderr/stdout output */
513
+ onLog?: (entry: LogEntry) => void;
486
514
  signal?: AbortSignal;
487
515
 
488
516
  // Text Batching
@@ -670,6 +698,28 @@ declare class SIMPLEFFMPEG {
670
698
  */
671
699
  static probe(filePath: string): Promise<SIMPLEFFMPEG.MediaInfo>;
672
700
 
701
+ /**
702
+ * Capture a single frame from a video file and save it as an image.
703
+ * The output format is determined by the outputPath file extension
704
+ * (.jpg, .png, .webp, .bmp, .tiff).
705
+ *
706
+ * @param filePath - Path to the source video file
707
+ * @param options - Snapshot options
708
+ * @returns The output path
709
+ * @throws {SIMPLEFFMPEG.SimpleffmpegError} If filePath or outputPath is missing
710
+ * @throws {SIMPLEFFMPEG.FFmpegError} If FFmpeg fails to extract the frame
711
+ *
712
+ * @example
713
+ * await SIMPLEFFMPEG.snapshot("./video.mp4", {
714
+ * outputPath: "./frame.png",
715
+ * time: 5,
716
+ * });
717
+ */
718
+ static snapshot(
719
+ filePath: string,
720
+ options: SIMPLEFFMPEG.SnapshotOptions
721
+ ): Promise<string>;
722
+
673
723
  /**
674
724
  * Format validation result as human-readable string
675
725
  */
package/types/index.d.ts CHANGED
@@ -21,6 +21,12 @@ declare namespace SIMPLEFFMPEG {
21
21
  stderr: string;
22
22
  command: string;
23
23
  exitCode: number | null;
24
+ /** Structured error details for bug reporting */
25
+ readonly details: {
26
+ stderrTail: string;
27
+ command: string;
28
+ exitCode: number | null;
29
+ };
24
30
  }
25
31
 
26
32
  /** Thrown when a media file cannot be found or accessed */
@@ -295,6 +301,12 @@ declare namespace SIMPLEFFMPEG {
295
301
  fillGaps?: "none" | "black";
296
302
  }
297
303
 
304
+ /** Log entry passed to onLog callback */
305
+ interface LogEntry {
306
+ level: "stderr" | "stdout";
307
+ message: string;
308
+ }
309
+
298
310
  /** Progress information passed to onProgress callback */
299
311
  interface ProgressInfo {
300
312
  /** Current frame number being processed */
@@ -339,6 +351,20 @@ declare namespace SIMPLEFFMPEG {
339
351
  height?: number;
340
352
  }
341
353
 
354
+ /** Options for SIMPLEFFMPEG.snapshot() — capture a single frame from a video */
355
+ interface SnapshotOptions {
356
+ /** Output image path (extension determines format: .jpg, .png, .webp, .bmp, .tiff) */
357
+ outputPath: string;
358
+ /** Time in seconds to capture the frame at (default: 0) */
359
+ time?: number;
360
+ /** Output width in pixels (maintains aspect ratio if height omitted) */
361
+ width?: number;
362
+ /** Output height in pixels (maintains aspect ratio if width omitted) */
363
+ height?: number;
364
+ /** JPEG quality 1-31, lower is better (default: 2, only applies to JPEG output) */
365
+ quality?: number;
366
+ }
367
+
342
368
  /** Hardware acceleration options */
343
369
  type HardwareAcceleration =
344
370
  | "auto"
@@ -563,6 +589,8 @@ declare namespace SIMPLEFFMPEG {
563
589
 
564
590
  /** Progress callback for monitoring export progress */
565
591
  onProgress?: (progress: ProgressInfo) => void;
592
+ /** FFmpeg log callback for real-time stderr/stdout output */
593
+ onLog?: (entry: LogEntry) => void;
566
594
  /** AbortSignal for cancelling the export */
567
595
  signal?: AbortSignal;
568
596
 
@@ -769,6 +797,28 @@ declare class SIMPLEFFMPEG {
769
797
  */
770
798
  static probe(filePath: string): Promise<SIMPLEFFMPEG.MediaInfo>;
771
799
 
800
+ /**
801
+ * Capture a single frame from a video file and save it as an image.
802
+ * The output format is determined by the outputPath file extension
803
+ * (.jpg, .png, .webp, .bmp, .tiff).
804
+ *
805
+ * @param filePath - Path to the source video file
806
+ * @param options - Snapshot options
807
+ * @returns The output path
808
+ * @throws {SIMPLEFFMPEG.SimpleffmpegError} If filePath or outputPath is missing
809
+ * @throws {SIMPLEFFMPEG.FFmpegError} If FFmpeg fails to extract the frame
810
+ *
811
+ * @example
812
+ * await SIMPLEFFMPEG.snapshot("./video.mp4", {
813
+ * outputPath: "./frame.png",
814
+ * time: 5,
815
+ * });
816
+ */
817
+ static snapshot(
818
+ filePath: string,
819
+ options: SIMPLEFFMPEG.SnapshotOptions
820
+ ): Promise<string>;
821
+
772
822
  /**
773
823
  * Format validation result as human-readable string
774
824
  */