simple-ffmpegjs 0.3.3 → 0.3.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 +62 -3
- package/package.json +1 -1
- package/src/core/errors.js +14 -0
- package/src/ffmpeg/command_builder.js +40 -0
- package/src/ffmpeg/strings.js +33 -16
- package/src/ffmpeg/subtitle_builder.js +2 -2
- package/src/ffmpeg/text_passes.js +16 -3
- package/src/ffmpeg/text_renderer.js +1 -1
- package/src/ffmpeg/video_builder.js +8 -6
- package/src/ffmpeg/watermark_builder.js +8 -20
- package/src/lib/utils.js +27 -6
- package/src/simpleffmpeg.js +73 -2
- package/types/index.d.mts +50 -0
- package/types/index.d.ts +50 -0
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
|
-
|
|
750
|
-
console.error("
|
|
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
|
+
"version": "0.3.4",
|
|
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",
|
package/src/core/errors.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -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
|
};
|
package/src/ffmpeg/strings.js
CHANGED
|
@@ -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
|
-
//
|
|
26
|
-
|
|
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
|
-
//
|
|
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
|
|
35
|
-
.replace(/'/g, "
|
|
36
|
-
.replace(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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);
|
package/src/simpleffmpeg.js
CHANGED
|
@@ -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");
|
|
@@ -860,7 +861,7 @@ class SIMPLEFFMPEG {
|
|
|
860
861
|
|
|
861
862
|
this._isExporting = true;
|
|
862
863
|
const t0 = Date.now();
|
|
863
|
-
const { onProgress, signal } = options;
|
|
864
|
+
const { onProgress, signal, onLog } = options;
|
|
864
865
|
|
|
865
866
|
let prepared;
|
|
866
867
|
try {
|
|
@@ -948,6 +949,7 @@ class SIMPLEFFMPEG {
|
|
|
948
949
|
command: pass1Command,
|
|
949
950
|
totalDuration,
|
|
950
951
|
signal,
|
|
952
|
+
onLog,
|
|
951
953
|
});
|
|
952
954
|
|
|
953
955
|
// Second pass
|
|
@@ -984,6 +986,7 @@ class SIMPLEFFMPEG {
|
|
|
984
986
|
totalDuration,
|
|
985
987
|
onProgress,
|
|
986
988
|
signal,
|
|
989
|
+
onLog,
|
|
987
990
|
});
|
|
988
991
|
|
|
989
992
|
// Clean up pass log files
|
|
@@ -998,6 +1001,7 @@ class SIMPLEFFMPEG {
|
|
|
998
1001
|
totalDuration,
|
|
999
1002
|
onProgress,
|
|
1000
1003
|
signal,
|
|
1004
|
+
onLog,
|
|
1001
1005
|
});
|
|
1002
1006
|
}
|
|
1003
1007
|
|
|
@@ -1020,6 +1024,7 @@ class SIMPLEFFMPEG {
|
|
|
1020
1024
|
intermediatePreset: exportOptions.intermediatePreset,
|
|
1021
1025
|
intermediateCrf: exportOptions.intermediateCrf,
|
|
1022
1026
|
batchSize: exportOptions.textMaxNodesPerPass,
|
|
1027
|
+
onLog,
|
|
1023
1028
|
});
|
|
1024
1029
|
passes = textPasses;
|
|
1025
1030
|
if (finalPath !== exportOptions.outputPath) {
|
|
@@ -1047,7 +1052,7 @@ class SIMPLEFFMPEG {
|
|
|
1047
1052
|
console.log("simple-ffmpeg: Generating thumbnail...");
|
|
1048
1053
|
}
|
|
1049
1054
|
|
|
1050
|
-
await runFFmpeg({ command: thumbCommand });
|
|
1055
|
+
await runFFmpeg({ command: thumbCommand, onLog });
|
|
1051
1056
|
console.log(`simple-ffmpeg: Thumbnail -> ${thumbOptions.outputPath}`);
|
|
1052
1057
|
}
|
|
1053
1058
|
|
|
@@ -1211,6 +1216,72 @@ class SIMPLEFFMPEG {
|
|
|
1211
1216
|
return probeMedia(filePath);
|
|
1212
1217
|
}
|
|
1213
1218
|
|
|
1219
|
+
/**
|
|
1220
|
+
* Capture a single frame from a video file and save it as an image.
|
|
1221
|
+
*
|
|
1222
|
+
* The output format is determined by the `outputPath` file extension.
|
|
1223
|
+
* Supported formats include: `.jpg`/`.jpeg`, `.png`, `.webp`, `.bmp`, `.tiff`.
|
|
1224
|
+
*
|
|
1225
|
+
* @param {string} filePath - Path to the source video file
|
|
1226
|
+
* @param {Object} options - Snapshot options
|
|
1227
|
+
* @param {string} options.outputPath - Output image path (extension determines format)
|
|
1228
|
+
* @param {number} [options.time=0] - Time in seconds to capture the frame at
|
|
1229
|
+
* @param {number} [options.width] - Output width in pixels (maintains aspect ratio if height omitted)
|
|
1230
|
+
* @param {number} [options.height] - Output height in pixels (maintains aspect ratio if width omitted)
|
|
1231
|
+
* @param {number} [options.quality] - JPEG quality 1-31, lower is better (default: 2, only applies to JPEG)
|
|
1232
|
+
* @returns {Promise<string>} The resolved output path
|
|
1233
|
+
* @throws {SimpleffmpegError} If filePath or outputPath is missing
|
|
1234
|
+
* @throws {FFmpegError} If FFmpeg fails to extract the frame
|
|
1235
|
+
*
|
|
1236
|
+
* @example
|
|
1237
|
+
* // Save as PNG
|
|
1238
|
+
* await SIMPLEFFMPEG.snapshot("./video.mp4", {
|
|
1239
|
+
* outputPath: "./frame.png",
|
|
1240
|
+
* time: 5,
|
|
1241
|
+
* });
|
|
1242
|
+
*
|
|
1243
|
+
* @example
|
|
1244
|
+
* // Save as JPEG with quality and resize
|
|
1245
|
+
* await SIMPLEFFMPEG.snapshot("./video.mp4", {
|
|
1246
|
+
* outputPath: "./thumb.jpg",
|
|
1247
|
+
* time: 10,
|
|
1248
|
+
* width: 640,
|
|
1249
|
+
* quality: 4,
|
|
1250
|
+
* });
|
|
1251
|
+
*/
|
|
1252
|
+
static async snapshot(filePath, options = {}) {
|
|
1253
|
+
if (!filePath) {
|
|
1254
|
+
throw new SimpleffmpegError(
|
|
1255
|
+
"snapshot() requires a filePath as the first argument"
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
if (!options.outputPath) {
|
|
1259
|
+
throw new SimpleffmpegError(
|
|
1260
|
+
"snapshot() requires options.outputPath to be specified"
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const {
|
|
1265
|
+
outputPath,
|
|
1266
|
+
time = 0,
|
|
1267
|
+
width,
|
|
1268
|
+
height,
|
|
1269
|
+
quality,
|
|
1270
|
+
} = options;
|
|
1271
|
+
|
|
1272
|
+
const command = buildSnapshotCommand({
|
|
1273
|
+
inputPath: filePath,
|
|
1274
|
+
outputPath,
|
|
1275
|
+
time,
|
|
1276
|
+
width,
|
|
1277
|
+
height,
|
|
1278
|
+
quality,
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
await runFFmpeg({ command });
|
|
1282
|
+
return outputPath;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1214
1285
|
/**
|
|
1215
1286
|
* Format validation result as human-readable string
|
|
1216
1287
|
* @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
|
*/
|