pwebm 0.0.1-alpha.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 +22 -0
- package/README.md +15 -0
- package/TODO.md +55 -0
- package/bun.lock +33 -0
- package/package.json +29 -0
- package/pwebm +2 -0
- package/src/args.ts +445 -0
- package/src/config.ts +49 -0
- package/src/constants.ts +15 -0
- package/src/ffmpeg.ts +685 -0
- package/src/ipc.ts +182 -0
- package/src/logger.ts +131 -0
- package/src/main.ts +86 -0
- package/src/paths.ts +44 -0
- package/src/queue.ts +68 -0
- package/src/schema/args.ts +36 -0
- package/src/schema/config.ts +23 -0
- package/src/schema/ffmpeg.ts +21 -0
- package/src/schema/ffprobe.ts +57 -0
- package/src/schema/ipc.ts +43 -0
- package/src/schema/status.ts +30 -0
- package/src/status.ts +53 -0
- package/src/utils.ts +3 -0
- package/tsconfig.json +19 -0
package/src/ffmpeg.ts
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { queue } from "./queue";
|
|
2
|
+
import { status } from "./status";
|
|
3
|
+
import { logger } from "./logger";
|
|
4
|
+
import { CLI_NAME } from "./constants";
|
|
5
|
+
import { unlinkSync } from "fs";
|
|
6
|
+
import { ArgsSchema } from "./schema/args";
|
|
7
|
+
import { FFProbeSchema } from "./schema/ffprobe";
|
|
8
|
+
import { ProgressSchema } from "./schema/ffmpeg";
|
|
9
|
+
import { Subprocess as _Subprocess } from "bun";
|
|
10
|
+
import { TEMP_PATH, NULL_DEVICE_PATH } from "./paths";
|
|
11
|
+
|
|
12
|
+
import path from "path";
|
|
13
|
+
|
|
14
|
+
type Subprocess = _Subprocess<"ignore", "pipe", "pipe">;
|
|
15
|
+
|
|
16
|
+
let stderr = "";
|
|
17
|
+
let forceKilled = false;
|
|
18
|
+
let ffmpegProcess: Subprocess | undefined;
|
|
19
|
+
|
|
20
|
+
const encode = async (args: ArgsSchema) => {
|
|
21
|
+
const duration = deduceDuration(args);
|
|
22
|
+
|
|
23
|
+
const inputs = args.inputs.flatMap((input) => {
|
|
24
|
+
const result = [];
|
|
25
|
+
|
|
26
|
+
if (input.startTime) {
|
|
27
|
+
result.push("-ss", input.startTime);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (input.stopTime) {
|
|
31
|
+
result.push("-to", input.stopTime);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
result.push("-i", input.file);
|
|
35
|
+
|
|
36
|
+
return result;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const outputSeeking = [];
|
|
40
|
+
|
|
41
|
+
if (args.output?.startTime) {
|
|
42
|
+
outputSeeking.push("-ss", args.output.startTime);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (args.output?.stopTime) {
|
|
46
|
+
outputSeeking.push("-to", args.output.stopTime);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const lavfi = args.lavfi ? ["-lavfi", args.lavfi] : [];
|
|
50
|
+
const extraParams = args.extraParams || [];
|
|
51
|
+
|
|
52
|
+
const encoder = args.extraParams?.includes("-c:v")
|
|
53
|
+
? args.extraParams[args.extraParams.lastIndexOf("-c:v") + 1]
|
|
54
|
+
: args.encoder;
|
|
55
|
+
|
|
56
|
+
const isWebmEncoder = encoder.includes("libvpx");
|
|
57
|
+
|
|
58
|
+
const outFile =
|
|
59
|
+
args.output?.file ||
|
|
60
|
+
path.join(
|
|
61
|
+
args.videoPath,
|
|
62
|
+
generateRandomFilename() + (isWebmEncoder ? ".webm" : ".mkv"),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const userMapping = !!args.extraParams?.includes("-map");
|
|
66
|
+
|
|
67
|
+
if (!isWebmEncoder) {
|
|
68
|
+
// if the encoder is not for webms (libvpx/libvpx-vp9), let's just do a single pass with the copied streams
|
|
69
|
+
// the goal here is to copy everything (audio, subtitles, attachments, etc) as is, and encode the video stream
|
|
70
|
+
// with the crf value in constant quality mode, if map is used in extra params, we will drop our mappings
|
|
71
|
+
// this is an escape hatch for users that sometimes want to use other encoders like libx264 with copied streams (me)
|
|
72
|
+
|
|
73
|
+
const mappings = userMapping
|
|
74
|
+
? []
|
|
75
|
+
: args.inputs.flatMap((_, index) => ["-map", index.toString()]);
|
|
76
|
+
|
|
77
|
+
const cmd = [
|
|
78
|
+
"ffmpeg",
|
|
79
|
+
"-hide_banner",
|
|
80
|
+
"-progress",
|
|
81
|
+
"pipe:1",
|
|
82
|
+
...inputs,
|
|
83
|
+
...outputSeeking,
|
|
84
|
+
...mappings,
|
|
85
|
+
"-c",
|
|
86
|
+
"copy",
|
|
87
|
+
"-c:v",
|
|
88
|
+
args.encoder,
|
|
89
|
+
"-crf",
|
|
90
|
+
args.crf.toString(),
|
|
91
|
+
...lavfi,
|
|
92
|
+
"-b:v",
|
|
93
|
+
"0",
|
|
94
|
+
"-preset",
|
|
95
|
+
"veryslow", // veryslow is used here as the preset, can be changed with extra params which takes precedence
|
|
96
|
+
...extraParams,
|
|
97
|
+
outFile,
|
|
98
|
+
"-y",
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
logger.info(
|
|
102
|
+
queue.getStatus() + ": " + "{BLUE}Processing the single pass{/BLUE}",
|
|
103
|
+
{
|
|
104
|
+
logToConsole: true,
|
|
105
|
+
fancyConsole: {
|
|
106
|
+
colors: true,
|
|
107
|
+
noNewLine: true,
|
|
108
|
+
clearPreviousLine: true,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
status.updateSinglePass();
|
|
114
|
+
|
|
115
|
+
logger.info("Executing: " + cmd.join(" "));
|
|
116
|
+
|
|
117
|
+
const singlePassProcess = Bun.spawn({ cmd, stderr: "pipe" });
|
|
118
|
+
|
|
119
|
+
ffmpegProcess = singlePassProcess;
|
|
120
|
+
|
|
121
|
+
let previousProgressPercentage = 0;
|
|
122
|
+
|
|
123
|
+
processStdout(singlePassProcess, (progress) => {
|
|
124
|
+
const newProgressPercentage = Math.trunc(
|
|
125
|
+
(progress.outTime * 100) / duration,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (newProgressPercentage !== previousProgressPercentage) {
|
|
129
|
+
// only log unique percentage progress
|
|
130
|
+
logger.info(
|
|
131
|
+
`${queue.getStatus()}: {BLUE}${Math.trunc((progress.outTime * 100) / duration)}%{/BLUE}`,
|
|
132
|
+
{
|
|
133
|
+
logToConsole: true,
|
|
134
|
+
fancyConsole: {
|
|
135
|
+
colors: true,
|
|
136
|
+
noNewLine: true,
|
|
137
|
+
clearPreviousLine: true,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
status.updateSinglePass(newProgressPercentage);
|
|
143
|
+
|
|
144
|
+
previousProgressPercentage = newProgressPercentage;
|
|
145
|
+
}
|
|
146
|
+
// need duration
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
processStderr(singlePassProcess);
|
|
150
|
+
|
|
151
|
+
await singlePassProcess.exited;
|
|
152
|
+
|
|
153
|
+
if (ffmpegProcess.exitCode !== 0 && !forceKilled) {
|
|
154
|
+
logger.error(
|
|
155
|
+
"Error processing the single pass, ffmpeg exited with code: " +
|
|
156
|
+
ffmpegProcess.exitCode,
|
|
157
|
+
);
|
|
158
|
+
logger.error(stderr);
|
|
159
|
+
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (forceKilled) {
|
|
164
|
+
logKilled();
|
|
165
|
+
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const queueIsDone = queue.getProcessedCount() === queue.getTotalCount();
|
|
170
|
+
|
|
171
|
+
logger.info(queue.getStatus() + ": {GREEN}100%{/GREEN}", {
|
|
172
|
+
logToConsole: true,
|
|
173
|
+
fancyConsole: {
|
|
174
|
+
colors: true,
|
|
175
|
+
noNewLine: queueIsDone ? false : true,
|
|
176
|
+
clearPreviousLine: true,
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
status.updateSinglePass(100);
|
|
181
|
+
|
|
182
|
+
if (queueIsDone) {
|
|
183
|
+
logger.info("All encodings done");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// this is just for convenience to quick burn subtitles for single input streams
|
|
190
|
+
// picks the first input file and burns its subtitles to the output stream
|
|
191
|
+
// this won't work with input seeking since they will be de-synced
|
|
192
|
+
const subtitles = `subtitles=${escapeSpecialCharacters(args.inputs[0].file)}`;
|
|
193
|
+
|
|
194
|
+
if (args.subs && args.lavfi) {
|
|
195
|
+
lavfi[1] += "," + subtitles;
|
|
196
|
+
} else if (args.subs) {
|
|
197
|
+
lavfi.push("-lavfi", subtitles);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// we don't want any of these
|
|
201
|
+
// i could explicitly just map the video stream, but want ffmpeg pick it automatically
|
|
202
|
+
// as the default, if the codec options are used or any mapping, we will skip these
|
|
203
|
+
const noAudio = extraParams.includes("-c:a") || userMapping ? [] : ["-an"];
|
|
204
|
+
const noSoftSubs = extraParams.includes("-c:s") || userMapping ? [] : ["-sn"];
|
|
205
|
+
|
|
206
|
+
const noDataStreams =
|
|
207
|
+
extraParams.includes("-c:d") || userMapping ? [] : ["-dn"];
|
|
208
|
+
|
|
209
|
+
const cmd = [
|
|
210
|
+
"ffmpeg",
|
|
211
|
+
"-hide_banner",
|
|
212
|
+
"-progress",
|
|
213
|
+
"pipe:1",
|
|
214
|
+
...inputs,
|
|
215
|
+
...outputSeeking,
|
|
216
|
+
"-c:v",
|
|
217
|
+
args.encoder,
|
|
218
|
+
"-crf",
|
|
219
|
+
args.crf.toString(),
|
|
220
|
+
"-deadline",
|
|
221
|
+
args.deadline,
|
|
222
|
+
"-cpu-used",
|
|
223
|
+
args.cpuUsed.toString(),
|
|
224
|
+
...lavfi,
|
|
225
|
+
"-b:v",
|
|
226
|
+
"0",
|
|
227
|
+
"-row-mt",
|
|
228
|
+
"1",
|
|
229
|
+
"-map_metadata",
|
|
230
|
+
"-1",
|
|
231
|
+
"-map_chapters",
|
|
232
|
+
"-1",
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const passLogFile = path.join(TEMP_PATH, CLI_NAME + "2pass");
|
|
236
|
+
|
|
237
|
+
const firstPassCmd = [
|
|
238
|
+
...cmd,
|
|
239
|
+
"-dn",
|
|
240
|
+
"-sn",
|
|
241
|
+
"-an", // first pass doesn't need audio
|
|
242
|
+
"-f",
|
|
243
|
+
"null",
|
|
244
|
+
"-pass",
|
|
245
|
+
"1",
|
|
246
|
+
"-passlogfile",
|
|
247
|
+
passLogFile,
|
|
248
|
+
...extraParams,
|
|
249
|
+
NULL_DEVICE_PATH,
|
|
250
|
+
"-y",
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
const secondPassCmd = [
|
|
254
|
+
...cmd,
|
|
255
|
+
...noAudio,
|
|
256
|
+
...noSoftSubs,
|
|
257
|
+
...noDataStreams,
|
|
258
|
+
"-f",
|
|
259
|
+
"webm",
|
|
260
|
+
"-pass",
|
|
261
|
+
"2",
|
|
262
|
+
"-passlogfile",
|
|
263
|
+
passLogFile,
|
|
264
|
+
...extraParams,
|
|
265
|
+
outFile,
|
|
266
|
+
"-y",
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
// first try tries to encode with just the crf value (constant quality mode
|
|
270
|
+
// triggered by b:v 0), subsequent tries will try to encode with bitrate calculation
|
|
271
|
+
// bitrate = size limit / duration * 8
|
|
272
|
+
// with the exceeding percentage removed in the following tries with a minimum of 0.02%
|
|
273
|
+
|
|
274
|
+
let failed: boolean;
|
|
275
|
+
let bitrate = 0;
|
|
276
|
+
let triesCount = 1;
|
|
277
|
+
|
|
278
|
+
const limitInBytes = args.sizeLimit * 1024 ** 2; // convert from MiB to bytes
|
|
279
|
+
|
|
280
|
+
do {
|
|
281
|
+
failed = false;
|
|
282
|
+
|
|
283
|
+
logger.info(
|
|
284
|
+
`${queue.getStatus()}: {BLUE}Processing the first pass${triesCount > 1 ? ` {YELLOW}(try ${triesCount}){/YELLOW}` : ""}{/BLUE}`,
|
|
285
|
+
{
|
|
286
|
+
logToConsole: true,
|
|
287
|
+
fancyConsole: {
|
|
288
|
+
colors: true,
|
|
289
|
+
noNewLine: true,
|
|
290
|
+
clearPreviousLine: true,
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
status.updateFirstPass(undefined, triesCount);
|
|
296
|
+
|
|
297
|
+
logger.info("Executing: " + firstPassCmd.join(" "));
|
|
298
|
+
|
|
299
|
+
const firstPassProcess = Bun.spawn({ cmd: firstPassCmd, stderr: "pipe" });
|
|
300
|
+
|
|
301
|
+
ffmpegProcess = firstPassProcess;
|
|
302
|
+
|
|
303
|
+
processStderr(firstPassProcess);
|
|
304
|
+
|
|
305
|
+
await firstPassProcess.exited;
|
|
306
|
+
|
|
307
|
+
if (firstPassProcess.exitCode !== 0 && !forceKilled) {
|
|
308
|
+
logger.error("Couldn't process first pass");
|
|
309
|
+
|
|
310
|
+
removePassLogFile(passLogFile);
|
|
311
|
+
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (forceKilled) {
|
|
316
|
+
removePassLogFile(passLogFile);
|
|
317
|
+
|
|
318
|
+
logKilled();
|
|
319
|
+
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
logger.info(
|
|
324
|
+
`${queue.getStatus()}: {BLUE}Processing the second pass${triesCount > 1 ? ` {YELLOW}(try ${triesCount}){/YELLOW}` : ""}{/BLUE}`,
|
|
325
|
+
{
|
|
326
|
+
logToConsole: true,
|
|
327
|
+
fancyConsole: {
|
|
328
|
+
colors: true,
|
|
329
|
+
noNewLine: true,
|
|
330
|
+
clearPreviousLine: true,
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
status.updateSecondPass(undefined, triesCount);
|
|
336
|
+
|
|
337
|
+
logger.info("Executing: " + secondPassCmd.join(" "));
|
|
338
|
+
|
|
339
|
+
const secondPassProcess = Bun.spawn({ cmd: secondPassCmd, stderr: "pipe" });
|
|
340
|
+
|
|
341
|
+
ffmpegProcess = secondPassProcess;
|
|
342
|
+
|
|
343
|
+
let previousProgressPercentage = 0;
|
|
344
|
+
|
|
345
|
+
processStdout(secondPassProcess, (progress) => {
|
|
346
|
+
const newProgressPercentage = Math.trunc(
|
|
347
|
+
(progress.outTime * 100) / duration,
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
if (newProgressPercentage !== previousProgressPercentage) {
|
|
351
|
+
// only log unique percentage progress
|
|
352
|
+
logger.info(
|
|
353
|
+
`${queue.getStatus()}: {BLUE}${Math.trunc((progress.outTime * 100) / duration)}%${triesCount > 1 ? ` {YELLOW}(try ${triesCount}){/YELLOW}` : ""}{/BLUE}`,
|
|
354
|
+
{
|
|
355
|
+
logToConsole: true,
|
|
356
|
+
fancyConsole: {
|
|
357
|
+
colors: true,
|
|
358
|
+
noNewLine: true,
|
|
359
|
+
clearPreviousLine: true,
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
status.updateSecondPass(newProgressPercentage, triesCount);
|
|
365
|
+
|
|
366
|
+
previousProgressPercentage = newProgressPercentage;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (limitInBytes === 0 || failed || progress.totalSize <= limitInBytes) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
failed = true;
|
|
374
|
+
|
|
375
|
+
const offsetPercentage = Number(
|
|
376
|
+
(((progress.totalSize - limitInBytes) / limitInBytes) * 100).toFixed(3),
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
logger.warn(
|
|
380
|
+
`${queue.getStatus()}: {RED}File size is greater than the limit by ${offsetPercentage}% with ${triesCount === 1 ? "crf " + args.crf : "bitrate " + (bitrate / 1000).toFixed(2) + "K"}{/RED}`,
|
|
381
|
+
{
|
|
382
|
+
logToConsole: true,
|
|
383
|
+
fancyConsole: {
|
|
384
|
+
colors: true,
|
|
385
|
+
noNewLine: false,
|
|
386
|
+
clearPreviousLine: true,
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
if (triesCount === 1) {
|
|
392
|
+
bitrate = Math.floor((limitInBytes / duration) * 8);
|
|
393
|
+
|
|
394
|
+
// set the crf to 10 for a targeted bitrate next
|
|
395
|
+
firstPassCmd[firstPassCmd.lastIndexOf("-crf") + 1] = "10";
|
|
396
|
+
secondPassCmd[secondPassCmd.lastIndexOf("-crf") + 1] = "10";
|
|
397
|
+
} else {
|
|
398
|
+
const percent = offsetPercentage < 0.02 ? 0.02 : offsetPercentage;
|
|
399
|
+
|
|
400
|
+
bitrate -= Math.floor((percent / 100) * bitrate);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// replace the b:v 0 with the calculated bitrate
|
|
404
|
+
firstPassCmd[firstPassCmd.lastIndexOf("-b:v") + 1] = bitrate.toString();
|
|
405
|
+
secondPassCmd[secondPassCmd.lastIndexOf("-b:v") + 1] = bitrate.toString();
|
|
406
|
+
|
|
407
|
+
logger.warn(
|
|
408
|
+
`${queue.getStatus()}: {RED}Retrying with bitrate ${(bitrate / 1000).toFixed(2)}K{/RED}`,
|
|
409
|
+
{
|
|
410
|
+
logToConsole: true,
|
|
411
|
+
fancyConsole: {
|
|
412
|
+
colors: true,
|
|
413
|
+
noNewLine: false,
|
|
414
|
+
clearPreviousLine: false,
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
triesCount++;
|
|
420
|
+
|
|
421
|
+
secondPassProcess.kill("SIGKILL");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
processStderr(secondPassProcess);
|
|
425
|
+
|
|
426
|
+
await secondPassProcess.exited;
|
|
427
|
+
} while (failed);
|
|
428
|
+
|
|
429
|
+
removePassLogFile(passLogFile);
|
|
430
|
+
|
|
431
|
+
if (ffmpegProcess.exitCode !== 0 && !forceKilled) {
|
|
432
|
+
logger.error(
|
|
433
|
+
"Error processing the second pass, ffmpeg exited with code: " +
|
|
434
|
+
ffmpegProcess.exitCode,
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
logger.error(stderr);
|
|
438
|
+
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (forceKilled) {
|
|
443
|
+
logKilled();
|
|
444
|
+
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const queueIsDone = queue.getProcessedCount() === queue.getTotalCount();
|
|
449
|
+
|
|
450
|
+
logger.info(queue.getStatus() + ": {GREEN}100%{/GREEN}", {
|
|
451
|
+
logToConsole: true,
|
|
452
|
+
fancyConsole: {
|
|
453
|
+
colors: true,
|
|
454
|
+
noNewLine: queueIsDone ? false : true,
|
|
455
|
+
clearPreviousLine: true,
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
status.updateSecondPass(100);
|
|
460
|
+
|
|
461
|
+
if (queueIsDone) {
|
|
462
|
+
logger.info("All encodings done");
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const processStderr = async (process: Subprocess) => {
|
|
467
|
+
for await (const chunk of process.stderr) {
|
|
468
|
+
stderr += new TextDecoder().decode(chunk);
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const logKilled = () => {
|
|
473
|
+
logger.warn("ffmpeg was killed");
|
|
474
|
+
|
|
475
|
+
logger.info(queue.getStatus() + ": {RED}Killed{/RED}", {
|
|
476
|
+
logToConsole: true,
|
|
477
|
+
fancyConsole: {
|
|
478
|
+
colors: true,
|
|
479
|
+
noNewLine: false,
|
|
480
|
+
clearPreviousLine: true,
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const processStdout = async (
|
|
486
|
+
process: Subprocess,
|
|
487
|
+
onProgress: (progress: ProgressSchema) => void,
|
|
488
|
+
) => {
|
|
489
|
+
for await (const chunk of process.stdout) {
|
|
490
|
+
const text = new TextDecoder().decode(chunk);
|
|
491
|
+
|
|
492
|
+
const data: Record<string, string> = {};
|
|
493
|
+
|
|
494
|
+
text.split("\n").forEach((line) => {
|
|
495
|
+
const [key, value] = line.split("=");
|
|
496
|
+
|
|
497
|
+
if (!key || !value) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
data[key.trim()] = value.trim();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const parsedProgress = ProgressSchema.safeParse(data);
|
|
505
|
+
|
|
506
|
+
if (!parsedProgress.success) {
|
|
507
|
+
// values can be N/A, skip them to keep showing the previous values
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
onProgress(parsedProgress.data);
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const kill = () => {
|
|
516
|
+
if (!ffmpegProcess) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
logger.info(`Killing ffmpeg (PID: ${ffmpegProcess.pid})`);
|
|
521
|
+
|
|
522
|
+
forceKilled = true;
|
|
523
|
+
ffmpegProcess.kill("SIGKILL");
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const removePassLogFile = (file: string) => {
|
|
527
|
+
file = file + "-0.log";
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
logger.info(`Deleting the ${file} file`);
|
|
531
|
+
|
|
532
|
+
unlinkSync(file);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
logger.error("Couldn't delete pass log file: " + file);
|
|
535
|
+
|
|
536
|
+
if (error instanceof Error) {
|
|
537
|
+
logger.error(error.message);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const generateRandomFilename = () =>
|
|
543
|
+
Date.now() +
|
|
544
|
+
Math.floor(Math.random() * 1000)
|
|
545
|
+
.toString()
|
|
546
|
+
.padStart(3, "0");
|
|
547
|
+
|
|
548
|
+
const getSeconds = (timestamp: string) => {
|
|
549
|
+
const parts = timestamp.split(":").map(Number);
|
|
550
|
+
|
|
551
|
+
let seconds = 0;
|
|
552
|
+
|
|
553
|
+
while (parts.length) {
|
|
554
|
+
seconds = seconds * 60 + (parts.shift() || 0);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return Number(seconds.toFixed(3));
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const escapeSpecialCharacters = (value: string) =>
|
|
561
|
+
value
|
|
562
|
+
// Square brackets
|
|
563
|
+
.replace(/\[/g, "\\[")
|
|
564
|
+
.replace(/\]/g, "\\]")
|
|
565
|
+
// Single quotes
|
|
566
|
+
.replace(/'/g, "\\\\\\'")
|
|
567
|
+
// Semicolon
|
|
568
|
+
.replace(/;/g, "\\;")
|
|
569
|
+
// Colon
|
|
570
|
+
.replace(/:/g, "\\\\:")
|
|
571
|
+
// Comma
|
|
572
|
+
.replace(/,/g, "\\,");
|
|
573
|
+
|
|
574
|
+
const deduceDuration = (args: ArgsSchema) => {
|
|
575
|
+
// if output seeking stop time is set with no output start time, the
|
|
576
|
+
// duration will be the stop time
|
|
577
|
+
if (args.output?.stopTime && !args.output?.startTime) {
|
|
578
|
+
return getSeconds(args.output.stopTime);
|
|
579
|
+
} else if (args.output?.startTime && args.output?.stopTime) {
|
|
580
|
+
// if both output seeking start and stop times are set, let's remove
|
|
581
|
+
// the start time from the stop time
|
|
582
|
+
return getSeconds(args.output.stopTime) - getSeconds(args.output.startTime);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const { data: metadata, error } = getInputMetadata(args.inputs);
|
|
586
|
+
|
|
587
|
+
if (error) {
|
|
588
|
+
logger.error("Error reading the input metadata");
|
|
589
|
+
logger.error(error.message);
|
|
590
|
+
|
|
591
|
+
process.exit(1);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// the following is a very simplistic approach to deduce the duration
|
|
595
|
+
// but should work for most cases
|
|
596
|
+
|
|
597
|
+
// the duration is only used for bitrate calculation for size limited
|
|
598
|
+
// webms and the percentage that is shown during the encoding process
|
|
599
|
+
|
|
600
|
+
// no output seeking, let's just pick the longest input if no lavfi concat
|
|
601
|
+
// with the input seeking times or duration of the input metadata
|
|
602
|
+
if (!args.lavfi?.includes("concat")) {
|
|
603
|
+
const durations = getInputDurations(args.inputs, metadata);
|
|
604
|
+
|
|
605
|
+
return Math.max(...durations);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// if lavfi concat is used, let's sum the durations of the inputs
|
|
609
|
+
const durations = getInputDurations(args.inputs, metadata);
|
|
610
|
+
|
|
611
|
+
return durations.reduce((acc, curr) => acc + curr, 0);
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const getInputDurations = (
|
|
615
|
+
inputs: ArgsSchema["inputs"],
|
|
616
|
+
metadata: FFProbeSchema[],
|
|
617
|
+
) =>
|
|
618
|
+
metadata.map((input, index) => {
|
|
619
|
+
if (inputs[index].startTime && inputs[index].stopTime) {
|
|
620
|
+
return (
|
|
621
|
+
getSeconds(inputs[index].stopTime) - getSeconds(inputs[index].startTime)
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (inputs[index].startTime) {
|
|
626
|
+
return input.format.duration - getSeconds(inputs[index].startTime);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (inputs[index].stopTime) {
|
|
630
|
+
return getSeconds(inputs[index].stopTime);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return input.format.duration;
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
const getInputMetadata = (inputs: { file: string }[]) => {
|
|
637
|
+
const inputsMetadata: FFProbeSchema[] = [];
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
inputs.forEach(({ file }) => {
|
|
641
|
+
const ffprobeProcess = Bun.spawnSync([
|
|
642
|
+
"ffprobe",
|
|
643
|
+
"-v",
|
|
644
|
+
"error",
|
|
645
|
+
"-show_format",
|
|
646
|
+
"-show_streams",
|
|
647
|
+
"-print_format",
|
|
648
|
+
"json",
|
|
649
|
+
file,
|
|
650
|
+
]);
|
|
651
|
+
|
|
652
|
+
if (!ffprobeProcess.success) {
|
|
653
|
+
throw new Error("Error reading the file " + file);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const parsedOutput = FFProbeSchema.safeParse(
|
|
657
|
+
JSON.parse(ffprobeProcess.stdout.toString()),
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
if (!parsedOutput.success) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
"Error parsing the output from ffprobe: " +
|
|
663
|
+
JSON.stringify(parsedOutput.error.flatten().fieldErrors, null, 2),
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
inputsMetadata.push(parsedOutput.data);
|
|
668
|
+
});
|
|
669
|
+
} catch (error) {
|
|
670
|
+
if (error instanceof Error) {
|
|
671
|
+
return {
|
|
672
|
+
data: null,
|
|
673
|
+
error,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return {
|
|
680
|
+
error: null,
|
|
681
|
+
data: inputsMetadata,
|
|
682
|
+
};
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
export const ffmpeg = { kill, encode };
|