simple-ffmpegjs 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +448 -302
- package/assets/example-thumbnail.jpg +0 -0
- package/package.json +50 -23
- package/src/core/constants.js +39 -1
- package/src/core/errors.js +64 -0
- package/src/core/gaps.js +81 -0
- package/src/core/validation.js +30 -26
- package/src/ffmpeg/command_builder.js +168 -8
- package/src/ffmpeg/text_renderer.js +10 -4
- package/src/ffmpeg/video_builder.js +47 -2
- package/src/lib/utils.js +200 -1
- package/src/loaders.js +4 -4
- package/src/simpleffmpeg.js +493 -237
- package/types/index.d.mts +215 -5
- package/types/index.d.ts +277 -5
package/src/lib/utils.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
const { spawn } = require("child_process");
|
|
2
|
+
const { FFmpegError, ExportCancelledError } = require("../core/errors");
|
|
3
|
+
|
|
1
4
|
const formatBytes = (bytes) => {
|
|
2
5
|
if (!Number.isFinite(bytes)) return `${bytes}`;
|
|
3
6
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
@@ -10,4 +13,200 @@ const formatBytes = (bytes) => {
|
|
|
10
13
|
return `${n.toFixed(n < 10 && i > 0 ? 2 : 1)} ${units[i]}`;
|
|
11
14
|
};
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Parse FFmpeg time string (HH:MM:SS.ms) to seconds
|
|
18
|
+
*/
|
|
19
|
+
function parseFFmpegTime(timeStr) {
|
|
20
|
+
if (!timeStr) return 0;
|
|
21
|
+
const parts = timeStr.split(":");
|
|
22
|
+
if (parts.length === 3) {
|
|
23
|
+
const [hours, minutes, seconds] = parts;
|
|
24
|
+
return (
|
|
25
|
+
parseFloat(hours) * 3600 + parseFloat(minutes) * 60 + parseFloat(seconds)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return parseFloat(timeStr) || 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse FFmpeg progress line and extract metrics
|
|
33
|
+
*/
|
|
34
|
+
function parseFFmpegProgress(line, totalDuration) {
|
|
35
|
+
const progress = {};
|
|
36
|
+
|
|
37
|
+
// Parse frame= 120
|
|
38
|
+
const frameMatch = line.match(/frame=\s*(\d+)/);
|
|
39
|
+
if (frameMatch) progress.frame = parseInt(frameMatch[1], 10);
|
|
40
|
+
|
|
41
|
+
// Parse fps=45.2
|
|
42
|
+
const fpsMatch = line.match(/fps=\s*([\d.]+)/);
|
|
43
|
+
if (fpsMatch) progress.fps = parseFloat(fpsMatch[1]);
|
|
44
|
+
|
|
45
|
+
// Parse time=00:00:04.00
|
|
46
|
+
const timeMatch = line.match(/time=\s*([\d:.]+)/);
|
|
47
|
+
if (timeMatch) {
|
|
48
|
+
progress.timeProcessed = parseFFmpegTime(timeMatch[1]);
|
|
49
|
+
if (totalDuration > 0) {
|
|
50
|
+
progress.percent = Math.min(
|
|
51
|
+
100,
|
|
52
|
+
Math.round((progress.timeProcessed / totalDuration) * 100)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Parse speed=1.5x
|
|
58
|
+
const speedMatch = line.match(/speed=\s*([\d.]+)x/);
|
|
59
|
+
if (speedMatch) progress.speed = parseFloat(speedMatch[1]);
|
|
60
|
+
|
|
61
|
+
// Parse bitrate=1234kbits/s
|
|
62
|
+
const bitrateMatch = line.match(/bitrate=\s*([\d.]+)kbits\/s/);
|
|
63
|
+
if (bitrateMatch) progress.bitrate = parseFloat(bitrateMatch[1]);
|
|
64
|
+
|
|
65
|
+
// Parse size= 1234kB
|
|
66
|
+
const sizeMatch = line.match(/size=\s*(\d+)kB/);
|
|
67
|
+
if (sizeMatch) progress.size = parseInt(sizeMatch[1], 10) * 1024;
|
|
68
|
+
|
|
69
|
+
return progress;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run FFmpeg command with spawn, supporting progress callbacks and cancellation
|
|
74
|
+
* @param {Object} options
|
|
75
|
+
* @param {string} options.command - The full FFmpeg command string
|
|
76
|
+
* @param {number} options.totalDuration - Expected output duration in seconds (for progress %)
|
|
77
|
+
* @param {Function} options.onProgress - Progress callback
|
|
78
|
+
* @param {AbortSignal} options.signal - AbortSignal for cancellation
|
|
79
|
+
* @returns {Promise<{stdout: string, stderr: string}>}
|
|
80
|
+
*/
|
|
81
|
+
function runFFmpeg({ command, totalDuration = 0, onProgress, signal }) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
// Parse command into args (simple split, assumes no quoted args with spaces in values)
|
|
84
|
+
// FFmpeg commands from this library don't have spaces in quoted paths handled this way
|
|
85
|
+
// We need to handle the command string properly
|
|
86
|
+
const args = parseFFmpegCommand(command);
|
|
87
|
+
const ffmpegPath = args.shift(); // Remove 'ffmpeg' from args
|
|
88
|
+
|
|
89
|
+
const proc = spawn(ffmpegPath, args, {
|
|
90
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
let stdout = "";
|
|
94
|
+
let stderr = "";
|
|
95
|
+
let cancelled = false;
|
|
96
|
+
|
|
97
|
+
// Handle cancellation
|
|
98
|
+
if (signal) {
|
|
99
|
+
const abortHandler = () => {
|
|
100
|
+
cancelled = true;
|
|
101
|
+
proc.kill("SIGTERM");
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (signal.aborted) {
|
|
105
|
+
proc.kill("SIGTERM");
|
|
106
|
+
reject(new ExportCancelledError());
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
111
|
+
|
|
112
|
+
proc.on("close", () => {
|
|
113
|
+
signal.removeEventListener("abort", abortHandler);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
proc.stdout.on("data", (data) => {
|
|
118
|
+
stdout += data.toString();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
proc.stderr.on("data", (data) => {
|
|
122
|
+
const chunk = data.toString();
|
|
123
|
+
stderr += chunk;
|
|
124
|
+
|
|
125
|
+
// Parse progress from stderr (FFmpeg outputs progress to stderr)
|
|
126
|
+
if (onProgress && typeof onProgress === "function") {
|
|
127
|
+
const progress = parseFFmpegProgress(chunk, totalDuration);
|
|
128
|
+
if (Object.keys(progress).length > 0) {
|
|
129
|
+
onProgress(progress);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
proc.on("error", (error) => {
|
|
135
|
+
reject(
|
|
136
|
+
new FFmpegError(`FFmpeg process error: ${error.message}`, {
|
|
137
|
+
stderr,
|
|
138
|
+
command,
|
|
139
|
+
})
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
proc.on("close", (code) => {
|
|
144
|
+
if (cancelled) {
|
|
145
|
+
reject(new ExportCancelledError());
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (code !== 0) {
|
|
150
|
+
reject(
|
|
151
|
+
new FFmpegError(`FFmpeg exited with code ${code}`, {
|
|
152
|
+
stderr,
|
|
153
|
+
command,
|
|
154
|
+
exitCode: code,
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
resolve({ stdout, stderr });
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Parse a command string into an array of arguments
|
|
167
|
+
* Handles quoted strings and escaped characters
|
|
168
|
+
*/
|
|
169
|
+
function parseFFmpegCommand(command) {
|
|
170
|
+
const args = [];
|
|
171
|
+
let current = "";
|
|
172
|
+
let inQuote = false;
|
|
173
|
+
let quoteChar = "";
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < command.length; i++) {
|
|
176
|
+
const char = command[i];
|
|
177
|
+
|
|
178
|
+
if (inQuote) {
|
|
179
|
+
if (char === quoteChar) {
|
|
180
|
+
inQuote = false;
|
|
181
|
+
// Don't add the closing quote to the argument
|
|
182
|
+
} else {
|
|
183
|
+
current += char;
|
|
184
|
+
}
|
|
185
|
+
} else if (char === '"' || char === "'") {
|
|
186
|
+
inQuote = true;
|
|
187
|
+
quoteChar = char;
|
|
188
|
+
// Don't add the opening quote to the argument
|
|
189
|
+
} else if (char === " " || char === "\t") {
|
|
190
|
+
if (current.length > 0) {
|
|
191
|
+
args.push(current);
|
|
192
|
+
current = "";
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
current += char;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (current.length > 0) {
|
|
200
|
+
args.push(current);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return args;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = {
|
|
207
|
+
formatBytes,
|
|
208
|
+
parseFFmpegTime,
|
|
209
|
+
parseFFmpegProgress,
|
|
210
|
+
runFFmpeg,
|
|
211
|
+
parseFFmpegCommand,
|
|
212
|
+
};
|
package/src/loaders.js
CHANGED
|
@@ -118,12 +118,12 @@ function loadText(project, clipObj) {
|
|
|
118
118
|
fontSize: clipObj.fontSize || C.DEFAULT_FONT_SIZE,
|
|
119
119
|
fontColor: clipObj.fontColor || C.DEFAULT_FONT_COLOR,
|
|
120
120
|
};
|
|
121
|
-
if (typeof clipObj.
|
|
121
|
+
if (typeof clipObj.xPercent === "number") clip.xPercent = clipObj.xPercent;
|
|
122
122
|
else if (typeof clipObj.x === "number") clip.x = clipObj.x;
|
|
123
|
-
else clip.
|
|
124
|
-
if (typeof clipObj.
|
|
123
|
+
else clip.xPercent = 0.5; // Default to centered
|
|
124
|
+
if (typeof clipObj.yPercent === "number") clip.yPercent = clipObj.yPercent;
|
|
125
125
|
else if (typeof clipObj.y === "number") clip.y = clipObj.y;
|
|
126
|
-
else clip.
|
|
126
|
+
else clip.yPercent = 0.5; // Default to centered
|
|
127
127
|
project.textClips.push(clip);
|
|
128
128
|
}
|
|
129
129
|
|