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/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 };