easycaptionkit 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,1032 @@
1
+ "use strict";
2
+
3
+ // ffmpeg and ffprobe, the ones already on the machine: what the video is,
4
+ // what this ffmpeg can do, the one encode that lays the captions over the
5
+ // picture, and frames of the result to look at. Nothing is downloaded.
6
+
7
+ const fs = require("node:fs");
8
+ const path = require("node:path");
9
+ const { spawn } = require("node:child_process");
10
+ const { track } = require("./running");
11
+
12
+ // Where package managers put it, for an agent or app whose PATH is short.
13
+ const KNOWN = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/snap/bin"];
14
+
15
+ // The first program by a name on PATH, in a folder `skip` does not pass
16
+ // over.
17
+ function onPath(name, { skip = () => false } = {}) {
18
+ for (const dir of String(process.env.PATH || "").split(path.delimiter)) {
19
+ if (!dir || skip(dir)) continue;
20
+ const file = path.join(dir, name);
21
+ try {
22
+ fs.accessSync(file, fs.constants.X_OK);
23
+ if (fs.statSync(file).isFile()) return file;
24
+ } catch {
25
+ // Not here.
26
+ }
27
+ }
28
+ return "";
29
+ }
30
+
31
+ const exe = (name) => (process.platform === "win32" ? `${name}.exe` : name);
32
+
33
+ const bare = (value) => !(value.includes("/") || (process.platform === "win32" && value.includes("\\")));
34
+ const isFile = (file) => {
35
+ try {
36
+ return fs.statSync(file).isFile();
37
+ } catch {
38
+ return false;
39
+ }
40
+ };
41
+
42
+ // A program named in the environment, as a whole path: a path is taken
43
+ // from the folder the run started in, so a program run in another folder
44
+ // (the encode runs in its work folder) still finds it, and a bare name is
45
+ // looked up on PATH, as a shell would. Empty when it is not there.
46
+ function given(value) {
47
+ if (bare(value)) return onPath(value) || onPath(exe(value));
48
+ const file = path.resolve(value);
49
+ return isFile(file) ? file : "";
50
+ }
51
+
52
+ // What an environment variable that names a program names, when that is
53
+ // no program: "FFMPEG_PATH is /opt/ffmpeg, and there is no file there".
54
+ // Empty when it is not set or names one.
55
+ function namedNothing(variable) {
56
+ const value = process.env[variable];
57
+ if (!value || given(value)) return "";
58
+ const what = bare(value) ? "and no program by that name is on PATH" : fs.existsSync(path.resolve(value)) ? "a folder, not a program" : "and there is no file there";
59
+ return `${variable} is ${value}, ${what}`;
60
+ }
61
+
62
+ function findFfmpeg() {
63
+ if (process.env.FFMPEG_PATH) return given(process.env.FFMPEG_PATH);
64
+ return onPath(exe("ffmpeg")) || (process.platform === "win32" ? "" : KNOWN.map((d) => path.join(d, "ffmpeg")).find((f) => fs.existsSync(f)) || "");
65
+ }
66
+
67
+ // ffprobe sits next to ffmpeg in every package.
68
+ function findFfprobe(ffmpeg) {
69
+ if (process.env.FFPROBE_PATH) return given(process.env.FFPROBE_PATH);
70
+ const next = ffmpeg ? path.join(path.dirname(ffmpeg), exe("ffprobe")) : "";
71
+ if (next && fs.existsSync(next)) return next;
72
+ return onPath(exe("ffprobe")) || (process.platform === "win32" ? "" : KNOWN.map((d) => path.join(d, "ffprobe")).find((f) => fs.existsSync(f)) || "");
73
+ }
74
+
75
+ const MISSING = "ffmpeg and ffprobe were not found. Install them (brew install ffmpeg, sudo apt install ffmpeg or winget install ffmpeg) or set FFMPEG_PATH and FFPROBE_PATH to them.";
76
+
77
+ function tools() {
78
+ for (const [variable, name] of [["FFMPEG_PATH", "ffmpeg"], ["FFPROBE_PATH", "ffprobe"]]) {
79
+ const nothing = namedNothing(variable);
80
+ if (nothing) throw new Error(`${nothing}. Set it to the path of ${name}, or unset it to use the one on PATH.`);
81
+ }
82
+ const ffmpeg = findFfmpeg();
83
+ const ffprobe = findFfprobe(ffmpeg);
84
+ if (!ffmpeg) throw new Error(MISSING);
85
+ if (!ffprobe) throw new Error(`ffprobe was not found next to ${ffmpeg} or on PATH. It comes with ffmpeg: install it, or set FFPROBE_PATH to it.`);
86
+ return { ffmpeg, ffprobe };
87
+ }
88
+
89
+ // What ffmpeg prints once it has given up, after the lines that say why:
90
+ // how it stopped, which says nothing of the cause. ffmpeg 4.4 ends with
91
+ // the first ones and the bare text of the error, as "Invalid argument",
92
+ // ffmpeg 7 and newer with the others, and ffmpeg 8 says last that its
93
+ // encoder never opened and that it wrote nothing, after a filter that
94
+ // could not start, or that filtering failed, after the scaler said which
95
+ // frame it could not convert.
96
+ const CLOSING = [
97
+ /^Could not open encoder before EOF/,
98
+ /^Error while filtering\b/,
99
+ /^Nothing was written into output file/,
100
+ /^Conversion failed!?$/,
101
+ /^Error while processing the decoded data/,
102
+ /^Failed to inject frame into filter network/,
103
+ /^Error reinitializing filters!?$/,
104
+ /^Error initializing complex filters\.?$/,
105
+ /^Error opening filters!?$/,
106
+ /^Exiting normally/,
107
+ /^(?:Invalid argument|Operation not permitted|Cannot allocate memory|Invalid data found when processing input|Unknown error occurred)$/,
108
+ /^Error opening (?:input|output) files?\b/,
109
+ /^Error selecting an encoder$/,
110
+ /^Error closing file\b/,
111
+ /^Error muxing a packet$/,
112
+ /^Task finished with error code/,
113
+ /^Terminating thread with return code/,
114
+ /^Error initializing filters$/,
115
+ /^Error : /,
116
+ ];
117
+ // The words of a line that says what went wrong, as ffmpeg 8's "Impossible
118
+ // to convert between the formats supported by" two filters.
119
+ const WRONG = /error|\b(?:invalid|unable|cannot|can't|could not|couldn't|failed|not found|no such|unknown|unrecognized|not supported|impossible|denied|no space|refused|undefined)\b/i;
120
+ // The signals a program ends by when it crashes.
121
+ const CRASHES = new Set(["SIGSEGV", "SIGBUS", "SIGILL", "SIGABRT", "SIGFPE"]);
122
+
123
+ // Why ffmpeg or ffprobe failed, as words that follow "could not ...: ".
124
+ // By a signal: which one, and for SIGKILL, which the system sends a
125
+ // program when memory runs out, as on a machine running many jobs, what
126
+ // to do. Else the last line it printed that names what went wrong, without
127
+ // the closing lines and the "[name @ 0x...]" that starts a line, as
128
+ // "Error applying option 'color_primaries' to filter 'setparams'" in the
129
+ // place of ffmpeg's last line, "Error : Invalid argument". ffmpeg itself
130
+ // takes SIGTERM and SIGINT, ends with code 255, and says so only at a
131
+ // level -loglevel error leaves out: code 255 with nothing said is that.
132
+ function whyFailed({ code, signal, stderr }, who = "it") {
133
+ if (signal === "SIGKILL") return `${who} was stopped by SIGKILL, as the system stops a program when memory runs out. Run again with fewer jobs at once, or with a smaller video`;
134
+ if (signal) return CRASHES.has(signal) ? `${who} crashed (${signal})` : `${who} was stopped by ${signal}`;
135
+ const lines = String(stderr || "")
136
+ .split(/\r?\n|\r/)
137
+ .filter((line) => line.trim() && !/^\s/.test(line))
138
+ .map((line) => line.replace(/^(?:\[[^\]]*\]\s*)+/, "").trim())
139
+ .filter(Boolean);
140
+ const telling = lines.filter((line) => !CLOSING.some((closing) => closing.test(line)));
141
+ const said = telling.filter((line) => WRONG.test(line)).pop() || telling.pop();
142
+ if (said) return said;
143
+ if (code === 255) return `${who} ended with code 255, as ffmpeg does when a signal such as SIGTERM or SIGINT stops it`;
144
+ return lines.pop() || `${who} ended with code ${code} and said nothing`;
145
+ }
146
+
147
+ // Runs a program to its end, in the folder `cwd` when given. A run that
148
+ // prints nothing for `quiet` ms is stopped, so a stuck ffmpeg is an
149
+ // error, not a hang, unless what `busy` gives, as the size and the time of
150
+ // the file it writes, changed in that time: ffmpeg prints nothing while it
151
+ // writes a whole MP4 again to move its index to the front. With `binary`,
152
+ // stdout is the bytes it wrote, not text.
153
+ function run(binary, args, { quiet = 120_000, onStdout = null, cwd = undefined, busy = null, binary: bytes = false } = {}) {
154
+ return new Promise((resolve, reject) => {
155
+ let child;
156
+ try {
157
+ child = track(spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"], cwd }));
158
+ } catch (err) {
159
+ reject(err);
160
+ return;
161
+ }
162
+ let stdout = "";
163
+ const chunks = [];
164
+ let stderr = "";
165
+ let timer = null;
166
+ const arm = () => {
167
+ clearTimeout(timer);
168
+ const before = busy ? busy() : null;
169
+ timer = setTimeout(() => {
170
+ if (busy && busy() !== before) return arm();
171
+ child.kill("SIGKILL");
172
+ reject(new Error(`${path.basename(binary)} stopped answering for ${Math.round(quiet / 1000)} s and was stopped`));
173
+ }, quiet);
174
+ timer.unref();
175
+ };
176
+ arm();
177
+ child.stdout.on("data", (d) => {
178
+ arm();
179
+ if (onStdout) onStdout(String(d));
180
+ else if (bytes) chunks.push(d);
181
+ else stdout += d;
182
+ });
183
+ child.stderr.on("data", (d) => {
184
+ arm();
185
+ stderr = (stderr + d).slice(-40_000);
186
+ });
187
+ child.on("error", (err) => {
188
+ clearTimeout(timer);
189
+ reject(err.code === "ENOENT" ? new Error(MISSING) : err);
190
+ });
191
+ child.on("close", (code, signal) => {
192
+ clearTimeout(timer);
193
+ resolve({ code, signal, stdout: bytes ? Buffer.concat(chunks) : stdout, stderr });
194
+ });
195
+ });
196
+ }
197
+
198
+ // ─── what the video is ────────────────────────────────────────────────────
199
+
200
+ function ratio(text) {
201
+ const m = /^(\d+(?:\.\d+)?)[:/](\d+(?:\.\d+)?)$/.exec(String(text || ""));
202
+ if (!m) return NaN;
203
+ const d = Number(m[2]);
204
+ return d ? Number(m[1]) / d : NaN;
205
+ }
206
+
207
+ // Seconds from a Matroska DURATION tag, 00:00:06.000000000; 0 for none.
208
+ function tagSeconds(tags) {
209
+ const key = Object.keys(tags || {}).find((k) => /^DURATION(?:-\w+)?$/i.test(k));
210
+ const m = key ? /^(\d+):(\d{1,2}):(\d{1,2}(?:\.\d+)?)$/.exec(String(tags[key]).trim()) : null;
211
+ return m ? Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]) : 0;
212
+ }
213
+
214
+ // The first video stream as it is shown: turned upright when the file says
215
+ // it is rotated, and with square pixels. Its frame rate, the length the
216
+ // file states (0 where it states none), color and sound.
217
+ async function probe(file, bins) {
218
+ if (!fs.existsSync(file)) throw new Error(`Cannot read ${file}: no such file`);
219
+ if (fs.statSync(file).isDirectory()) throw new Error(`${file} is a folder, not a video`);
220
+ // ffmpeg reads a name like take1:final.mp4 as a protocol and its path; a
221
+ // whole path is only ever a file.
222
+ const whole = path.resolve(file);
223
+ const result = await run(bins.ffprobe, ["-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", whole], { quiet: 60_000 });
224
+ const { code, stdout, stderr } = result;
225
+ if (code !== 0) {
226
+ if (!result.signal && /Invalid data found|could not find codec|moov atom not found/i.test(stderr)) throw new Error(`Cannot read ${file}: ffmpeg does not know it as a video, or it is cut short`);
227
+ throw new Error(`Cannot read ${file}: ${whyFailed(result, "ffprobe")}`);
228
+ }
229
+ let info;
230
+ try {
231
+ info = JSON.parse(stdout);
232
+ } catch {
233
+ throw new Error(`Cannot read ${file}: ffprobe gave no answer`);
234
+ }
235
+ const streams = info.streams || [];
236
+ const video = streams.find((s) => s.codec_type === "video" && !(s.disposition && s.disposition.attached_pic));
237
+ if (!video) throw new Error(`${file} has no video stream${streams.some((s) => s.codec_type === "audio") ? ", only sound" : ""}`);
238
+ let rotation = 0;
239
+ for (const side of video.side_data_list || []) if (side.rotation !== undefined) rotation = Number(side.rotation) || 0;
240
+ if (!rotation && video.tags && video.tags.rotate) rotation = Number(video.tags.rotate) || 0;
241
+ rotation = ((Math.round(rotation / 90) * 90) % 360 + 360) % 360;
242
+ const sar = ratio(video.sample_aspect_ratio);
243
+ const square = !(sar > 0) || Math.abs(sar - 1) < 0.01;
244
+ let width = Number(video.width);
245
+ let height = Number(video.height);
246
+ if (!square) width = Math.round(width * sar);
247
+ if (rotation === 90 || rotation === 270) [width, height] = [height, width];
248
+ const avg = ratio(video.avg_frame_rate);
249
+ const base = ratio(video.r_frame_rate);
250
+ // The stream's own rate when the average is that to a thousandth: in a
251
+ // millisecond time base, as a WebM has, frames sit up to a millisecond
252
+ // off the rate's grid, and the average comes out as 24.003.
253
+ const fps = avg > 0 && avg < 1000 ? (base > 0 && base < 1000 && Math.abs(avg - base) / base < 0.001 ? base : avg) : base > 0 && base < 1000 ? base : 30;
254
+ const start = Number(info.format && info.format.start_time);
255
+ const fileStart = Number.isFinite(start) ? start : 0;
256
+ // How long the picture runs by what the file states, from where the file
257
+ // starts, which ffmpeg takes as 0: its own stream's length from where
258
+ // that stream starts, as Matroska and WebM keep it in a DURATION tag, a
259
+ // picture that starts after the sound too; else the file's, which is its
260
+ // longest stream's, the sound's as often as the picture's.
261
+ const own = Number(video.duration) > 0 ? Number(video.duration) : tagSeconds(video.tags);
262
+ const streamStart = Number(video.start_time);
263
+ const stated = own > 0 ? (Number.isFinite(streamStart) ? streamStart : fileStart) + own - fileStart : Number(info.format && info.format.duration) || 0;
264
+ const duration = stated > 0 ? stated : 0;
265
+ const audio = streams
266
+ .filter((s) => s.codec_type === "audio")
267
+ .map((s) => ({ index: s.index, codec: s.codec_name || "", channels: Number(s.channels) || 0, rate: Number(s.sample_rate) || 0, layout: s.channel_layout || "" }));
268
+ const tb = /^(\d+)\/(\d+)$/.exec(video.time_base || "");
269
+ const chapters = Array.isArray(info.chapters) ? info.chapters.length : 0;
270
+ return {
271
+ file: whole,
272
+ name: file,
273
+ // The stream to caption by its number in the file: a cover picture
274
+ // stored as a video stream can come before it.
275
+ index: video.index,
276
+ // The stream's time base, which every frame's time is a whole number of.
277
+ timeBase: tb && Number(tb[1]) > 0 && Number(tb[2]) > 0 ? `${tb[1]}:${tb[2]}` : "",
278
+ container: (info.format && info.format.format_name) || "",
279
+ codec: video.codec_name || "",
280
+ stored: { width: Number(video.width), height: Number(video.height) },
281
+ width,
282
+ height,
283
+ // The size written: H.264 in 4:2:0 needs even sides.
284
+ out: { width: width - (width % 2), height: height - (height % 2) },
285
+ rotation,
286
+ sar: square ? 1 : sar,
287
+ pixFmt: video.pix_fmt || "",
288
+ fps,
289
+ // The stream's base rate, as ffmpeg writes it: 30/1, 30000/1001.
290
+ baseRate: base > 0 && /^\d+\/\d+$/.test(video.r_frame_rate || "") ? video.r_frame_rate : "",
291
+ duration,
292
+ // Whether that length is the picture's own or the file's.
293
+ statedBy: own > 0 ? "stream" : duration > 0 ? "file" : "",
294
+ // Where the file starts, which ffmpeg takes as 0.
295
+ start: fileStart,
296
+ frames: Number(video.nb_frames) || Math.round(duration * fps),
297
+ color: {
298
+ range: video.color_range === "pc" ? "pc" : video.color_range === "tv" ? "tv" : "",
299
+ space: video.color_space || "",
300
+ transfer: video.color_transfer || "",
301
+ primaries: video.color_primaries || "",
302
+ },
303
+ hdr: /smpte2084|arib-std-b67/.test(video.color_transfer || ""),
304
+ audio,
305
+ chapters,
306
+ // What the captioned video leaves out: the file's own tags, other than
307
+ // the ones every muxer writes, its cover picture, as yt-dlp
308
+ // --embed-thumbnail and podcast tools store it, its other video tracks,
309
+ // its subtitle and data tracks and the files attached to a Matroska
310
+ // file, as its fonts. A chapter list is kept, and MP4 keeps it in a text
311
+ // track of its own, which is no data track of the file's.
312
+ left: {
313
+ tags: Object.keys((info.format && info.format.tags) || {}).filter((key) => !/^(?:major_brand|minor_version|compatible_brands|encoder)$/i.test(key)),
314
+ covers: streams.filter((st) => st.codec_type === "video" && st.disposition && st.disposition.attached_pic).length,
315
+ videos: streams.filter((st) => st.codec_type === "video" && st !== video && !(st.disposition && st.disposition.attached_pic)).length,
316
+ subtitles: streams.filter((st) => st.codec_type === "subtitle").length,
317
+ data: streams.filter((st) => st.codec_type === "data" && !(chapters && st.codec_tag_string === "text")).length,
318
+ attachments: streams.filter((st) => st.codec_type === "attachment").length,
319
+ },
320
+ };
321
+ }
322
+
323
+ // The video to caption: what probe says of it, and how long its picture
324
+ // and its sound run, read from the times of their packets, which ffprobe
325
+ // reads without decoding them, from the start ffmpeg takes as 0. The last
326
+ // frame runs as long as its packet says, as a screen recording that ends
327
+ // on a still screen holds its last frame for seconds, or as long as the
328
+ // video's frames do on average. A WebM a browser recorded (Chrome's
329
+ // MediaRecorder) states no length, and is as long as its frames and its
330
+ // sound run. A file cut short, as an interrupted download or copy leaves
331
+ // it, states the whole length, and so can a header written to claim more:
332
+ // where every stream, the sound too, ends more than half a second before
333
+ // it, the captions end where the last frame starts, which can be cut short
334
+ // itself. A note says so. Elsewhere the picture runs to the length the
335
+ // file states for it, or further where its frames do, and where the sound
336
+ // runs on more than half a second past it, the captions go on to the end
337
+ // of the sound. { ...probe, duration, frames, times, picture: { start,
338
+ // end, last }, sound, soundPast, notes }: times are the frames', and last
339
+ // is where the last frame starts.
340
+ async function readVideo(file, bins) {
341
+ const v = await probe(file, bins);
342
+ const { video: own, ends } = await packetsOf(file, bins, v.index);
343
+ const notes = [];
344
+ if (!own.times.length) {
345
+ if (v.duration > 0) return { ...v, times: new Float64Array(0), picture: { start: 0, end: v.duration, last: 0 }, sound: 0, soundPast: false, notes };
346
+ throw new Error(`Cannot read ${file}: it states no length, and ffprobe found no times for its frames. Write it again with them, as ffmpeg -i <file> -c copy <new file> does, and caption that`);
347
+ }
348
+ const times = own.times.sort();
349
+ const first = times[0];
350
+ const lastStart = times[times.length - 1];
351
+ const step = times.length > 1 ? (lastStart - first) / (times.length - 1) : 1 / v.fps;
352
+ const zero = Math.min(v.start, first);
353
+ const framesEnd = lastStart + (own.last.dur > 0 ? own.last.dur : step) - zero;
354
+ let sound = 0;
355
+ for (const a of v.audio) if (ends.has(a.index)) sound = Math.max(sound, ends.get(a.index) - zero);
356
+ // Every frame counts, the ones with no time too.
357
+ const found = { ...v, frames: Math.max(own.count, times.length), times: times.map((t) => t - zero), sound };
358
+ const last = lastStart - zero;
359
+ if (v.duration > 0 && Math.max(framesEnd, sound) < v.duration - 0.5) {
360
+ const cut = Math.max(last, step);
361
+ notes.push(`The video says it is ${v.duration.toFixed(2)} s long, and its frames end at ${cut.toFixed(2)} s${sound > 0 ? ` and its sound at ${sound.toFixed(2)} s` : ""}, as in a file cut short by an interrupted download or copy, so the captions end there.`);
362
+ return { ...found, duration: cut, picture: { start: first - zero, end: cut, last }, soundPast: false, notes };
363
+ }
364
+ // The length the file states for the picture itself, where its frames
365
+ // end within half a second of it, the shorter of the two; else the
366
+ // longer, as a still last frame a player holds to it, or frames that
367
+ // run past what the header says.
368
+ let end = framesEnd;
369
+ if (v.statedBy === "stream") end = Math.abs(framesEnd - v.duration) <= 0.5 ? Math.min(framesEnd, v.duration) : Math.max(framesEnd, v.duration);
370
+ const soundPast = sound > end + 0.5;
371
+ if (!(v.duration > 0)) notes.push(`The video states no length, as a recording a browser saved often does, so its length was read from its frames: ${end.toFixed(2)} s.`);
372
+ return { ...found, duration: soundPast ? sound : end, picture: { start: first - zero, end, last }, soundPast, notes };
373
+ }
374
+
375
+ // What a program prints, handed to `take` a line at a time as it comes,
376
+ // so nothing keeps the whole of it: an onStdout for run, and an end that
377
+ // hands over a last line with no line break after it.
378
+ function byLine(take) {
379
+ let rest = "";
380
+ return {
381
+ onStdout(text) {
382
+ const lines = (rest + text).split("\n");
383
+ rest = lines.pop();
384
+ for (const line of lines) take(line);
385
+ },
386
+ end() {
387
+ if (rest) take(rest);
388
+ rest = "";
389
+ },
390
+ };
391
+ }
392
+
393
+ // Numbers kept as they come, eight bytes each, in a typed array that
394
+ // grows: a ten-hour video has two million frames.
395
+ function numbers() {
396
+ let kept = new Float64Array(4096);
397
+ let n = 0;
398
+ return {
399
+ push(value) {
400
+ if (n === kept.length) {
401
+ const more = new Float64Array(n * 2);
402
+ more.set(kept);
403
+ kept = more;
404
+ }
405
+ kept[n++] = value;
406
+ },
407
+ done: () => kept.slice(0, n),
408
+ };
409
+ }
410
+
411
+ // The packets of a video, read without decoding them, as ffmpeg shows and
412
+ // plays them, a line of ffprobe's at a time, so a long video takes no more
413
+ // than its frames' times: of the video stream `index`, the times of its
414
+ // packets, in seconds, how many it has with a time and without, and its
415
+ // last packet's time and length, NaN where it gives none; of every other
416
+ // stream, where its last packet ends. { video: { times, count, last: {
417
+ // pts, dur } }, ends: Map(stream index => seconds) }. A packet an MP4's
418
+ // edit list discards is left out: a cut made without encoding again, as
419
+ // ffmpeg -ss with -c copy and LosslessCut make it, keeps the frames from
420
+ // the keyframe before the cut, at times before 0, and no player shows
421
+ // them. A packet with no time to be shown at, as an AVI gives a B-frame's,
422
+ // has the time it is decoded at, which ffmpeg gives that frame too.
423
+ async function packetsOf(file, bins, index) {
424
+ const times = numbers();
425
+ let count = 0;
426
+ let last = null;
427
+ const ends = new Map();
428
+ const lines = byLine((line) => {
429
+ const [stream, pts, dts, dur, flags] = line.split(",");
430
+ if (!/^\d+$/.test(stream || "") || /D/.test(flags || "")) return;
431
+ const i = Number(stream);
432
+ const t = Number.isFinite(parseFloat(pts)) ? parseFloat(pts) : parseFloat(dts);
433
+ const length = parseFloat(dur);
434
+ if (i === index) count++;
435
+ if (!Number.isFinite(t)) return;
436
+ if (i !== index) ends.set(i, Math.max(ends.has(i) ? ends.get(i) : -Infinity, t + (length > 0 ? length : 0)));
437
+ else {
438
+ times.push(t);
439
+ if (!last || t >= last.pts) last = { pts: t, dur: length };
440
+ }
441
+ });
442
+ const result = await run(bins.ffprobe, ["-v", "error", "-show_entries", "packet=stream_index,pts_time,dts_time,duration_time,flags", "-of", "csv=p=0", path.resolve(file)], { quiet: 120_000, onStdout: lines.onStdout });
443
+ lines.end();
444
+ if (result.code !== 0) throw new Error(`Cannot read the frames of ${path.basename(file)}: ${whyFailed(result, "ffprobe")}`);
445
+ return { video: { times: times.done(), count, last }, ends };
446
+ }
447
+
448
+ // Every frame's time in a video's first video stream, in order, read from
449
+ // its packets without decoding them, a line at a time.
450
+ async function frameTimes(file, bins) {
451
+ const times = numbers();
452
+ const lines = byLine((line) => {
453
+ const t = parseFloat(line);
454
+ if (Number.isFinite(t)) times.push(t);
455
+ });
456
+ const result = await run(bins.ffprobe, ["-v", "error", "-select_streams", "v:0", "-show_entries", "packet=pts_time", "-of", "csv=p=0", path.resolve(file)], { quiet: 120_000, onStdout: lines.onStdout });
457
+ lines.end();
458
+ if (result.code !== 0) throw new Error(`Cannot read the frames of ${path.basename(file)}: ${whyFailed(result, "ffprobe")}`);
459
+ return times.done().sort();
460
+ }
461
+
462
+ // The frame rate frame times show, and whether it varies: frames that all
463
+ // come within a millisecond and a half of a steady beat do not vary, as in
464
+ // a millisecond time base; `stated` is the rate the stream gives, taken
465
+ // when the times agree with it to a thousandth.
466
+ function rateOf(times, stated = 0) {
467
+ if (times.length < 2) return { fps: stated, variable: false };
468
+ let low = Infinity;
469
+ let high = -Infinity;
470
+ for (let i = 1; i < times.length; i++) {
471
+ const step = times[i] - times[i - 1];
472
+ low = Math.min(low, step);
473
+ high = Math.max(high, step);
474
+ }
475
+ const fps = (times.length - 1) / (times[times.length - 1] - times[0]);
476
+ return { fps: stated > 0 && Math.abs(fps - stated) / stated < 0.001 ? stated : fps, variable: high - low > 0.0015 };
477
+ }
478
+
479
+ // The rate most of a video's frames come at, from their times.
480
+ function mostRate(v) {
481
+ const steps = numbers();
482
+ for (let i = 1; i < v.times.length; i++) if (v.times[i] - v.times[i - 1] > 0.0005) steps.push(v.times[i] - v.times[i - 1]);
483
+ const sorted = steps.done().sort();
484
+ return sorted.length ? 1 / sorted[Math.floor(sorted.length / 2)] : v.fps > 0 ? v.fps : 30;
485
+ }
486
+
487
+ // The steady rate to give a video, as ffmpeg writes a rate: its stream's
488
+ // base rate, where that is a usual rate from 24 to 60 and no less than the
489
+ // rate most of its frames come at. Else a usual rate of 24 or more: the
490
+ // first of 24, 25 and 30 that repeats each frame the same number of
491
+ // times, as 24 does for a still picture at 1 or 2 frames a second and 30
492
+ // at 10 or 15, or the nearest of 24, 25, 30, 50 and 60 at or above the
493
+ // rate most frames come at.
494
+ function steadyRate(v) {
495
+ const most = mostRate(v);
496
+ const base = ratio(v.baseRate);
497
+ if (base >= 23.9 && base <= 60 && base >= most * 0.98) return { rate: v.baseRate, fps: base };
498
+ const whole = [24, 25, 30].find((r) => most < 23.9 && Math.abs(r / most - Math.round(r / most)) < 0.01);
499
+ const floor = Math.min(Math.max(most, 24), 60);
500
+ const near = whole || [24, 25, 30, 50, 60].filter((r) => r >= floor * 0.98).reduce((best, r) => (Math.abs(Math.log(r / floor)) < Math.abs(Math.log(best / floor)) ? r : best));
501
+ return { rate: String(near), fps: near };
502
+ }
503
+
504
+ // How late a caption can come or go on the video's own frames before the
505
+ // video is given more: a tenth of a second, a third of a spoken word.
506
+ const LATE = 0.1;
507
+
508
+ // Whether captioned.mp4 needs frames the video does not have. The
509
+ // captions show on the video's own frames, so where the video holds a
510
+ // frame long (a screen recording that writes a frame only when the picture
511
+ // changes, a still last frame), has few frames a second (a still picture
512
+ // over a podcast's sound), starts after its sound, or ends before it, a
513
+ // caption or a lit word that comes or goes there shows late, or on no
514
+ // frame at all. Where any does by more than a tenth of a second, the video
515
+ // gets frames at a steady rate: { rate, fps, end, last, captions, held,
516
+ // few, start, past }, with how many captions would have come or gone late,
517
+ // the longest a frame held among them, the frames a second of a video
518
+ // whose frames all come at one low rate, and whether a caption came before
519
+ // the picture starts or after it ends; else null. `states` are the caption
520
+ // states.
521
+ function fillFor(v, states) {
522
+ const times = v.times || [];
523
+ if (!times.length) return null;
524
+ const { rate, fps } = steadyRate(v);
525
+ // The first frame at or after t, as its index; past the last, the count.
526
+ const at = (t) => {
527
+ let lo = 0;
528
+ let hi = times.length;
529
+ while (lo < hi) {
530
+ const mid = (lo + hi) >> 1;
531
+ if (times[mid] >= t - 0.0005) hi = mid;
532
+ else lo = mid + 1;
533
+ }
534
+ return lo;
535
+ };
536
+ const late = new Set();
537
+ let held = 0;
538
+ let start = false;
539
+ let past = false;
540
+ for (const s of states) {
541
+ for (const t of [s.from, s.to]) {
542
+ if (t >= v.duration - 0.001) continue;
543
+ const i = at(t);
544
+ // The frame it shows on: the next, or past the last none before the
545
+ // picture ends, and none at all after it.
546
+ const next = i < times.length ? times[i] : t < v.picture.end - 0.001 ? v.picture.end : Infinity;
547
+ if (next - t <= LATE) continue;
548
+ late.add(s.cue);
549
+ if (i === 0) start = true;
550
+ else if (next === Infinity) past = true;
551
+ // The frame before holds to the next, or past the last to the end of the picture.
552
+ else held = Math.max(held, next - times[i - 1]);
553
+ }
554
+ }
555
+ if (!late.size) return null;
556
+ // Frames that all come at one rate, each held as long as the others: a
557
+ // video of few frames a second.
558
+ const own = mostRate(v);
559
+ const few = held > 0 && !rateOf(times).variable && held <= 1.5 / own ? round3(own) : 0;
560
+ return { rate, fps, end: v.duration, last: v.picture.last, captions: late.size, held, few, start, past };
561
+ }
562
+
563
+ const round3 = (value) => Math.round(value * 1000) / 1000;
564
+
565
+ // ─── what this ffmpeg can do ──────────────────────────────────────────────
566
+
567
+ async function capabilities(bins) {
568
+ const [about, filters, encoders] = await Promise.all([run(bins.ffmpeg, ["-hide_banner", "-version"], { quiet: 20_000 }), run(bins.ffmpeg, ["-hide_banner", "-filters"], { quiet: 20_000 }), run(bins.ffmpeg, ["-hide_banner", "-encoders"], { quiet: 20_000 })]);
569
+ // 4.4 is the oldest ffmpeg the kit is tried on: Ubuntu 22.04's. A build
570
+ // from git says no number and is newer.
571
+ const v = /ffmpeg version n?(\d+)\.(\d+)/.exec(about.stdout);
572
+ const atLeast = (major, minor) => !v || Number(v[1]) > major || (Number(v[1]) === major && Number(v[2]) >= minor);
573
+ if (!atLeast(4, 4)) throw new Error(`ffmpeg 4.4 or newer is needed, and this one is ${v[1]}.${v[2]}. Install a newer one (brew install ffmpeg, or a static build) or set FFMPEG_PATH to it.`);
574
+ const hasFilter = (name) => new RegExp(`^\\s*\\S+\\s+${name}\\s`, "m").test(filters.stdout);
575
+ const hasEncoder = (name) => new RegExp(`^\\s*V\\S*\\s+${name}\\s`, "m").test(encoders.stdout);
576
+ let encoder = null;
577
+ // No B-frames: with them the file starts behind an edit list, and a tool
578
+ // that reads frames without it is two frames off the captions' times.
579
+ if (hasEncoder("libx264")) encoder = { name: "libx264", args: ["-c:v", "libx264", "-preset", "medium", "-crf", "18", "-profile:v", "high", "-bf", "0"] };
580
+ else if (hasEncoder("h264_videotoolbox")) encoder = { name: "h264_videotoolbox", args: ["-c:v", "h264_videotoolbox", "-q:v", "65", "-profile:v", "high", "-bf", "0"] };
581
+ else if (hasEncoder("libopenh264")) encoder = { name: "libopenh264", args: ["-c:v", "libopenh264", "-b:v", "12M"] };
582
+ else if (hasEncoder("h264_mf")) encoder = { name: "h264_mf", args: ["-c:v", "h264_mf", "-b:v", "12M"] };
583
+ // Whether x264 here writes 10 bits, which an HDR video needs to stay HDR.
584
+ let tenBit = false;
585
+ if (encoder && encoder.name === "libx264") {
586
+ const help = await run(bins.ffmpeg, ["-hide_banner", "-h", "encoder=libx264"], { quiet: 20_000 });
587
+ tenBit = /yuv420p10le/.test(help.stdout);
588
+ }
589
+ const setparams = hasFilter("setparams");
590
+ return {
591
+ // -fps_mode came in 5.1, in the place of -vsync.
592
+ fpsMode: atLeast(5, 1),
593
+ overlay: hasFilter("overlay"),
594
+ libass: hasFilter("ass"),
595
+ setparams,
596
+ // The names setparams takes for each of its options.
597
+ colorNames: setparams ? optionNames((await run(bins.ffmpeg, ["-hide_banner", "-h", "filter=setparams"], { quiet: 20_000 })).stdout) : {},
598
+ encoder,
599
+ tenBit,
600
+ aac: /^\s*A\S*\s+aac\s/m.test(encoders.stdout),
601
+ };
602
+ }
603
+
604
+ // The names each option of a filter takes, from its help: { option:
605
+ // Set(name) }. An option's line gives its type in angle brackets, and the
606
+ // names it takes follow it, each with its number.
607
+ function optionNames(help) {
608
+ const names = {};
609
+ let at = null;
610
+ for (const line of String(help).split("\n")) {
611
+ const option = /^\s+([\w-]+)\s+<\w+>/.exec(line);
612
+ const constant = /^\s+(\S+)\s+-?\d+\s/.exec(line);
613
+ if (option) names[option[1]] = at = new Set();
614
+ else if (constant && at) at.add(constant[1]);
615
+ else at = null;
616
+ }
617
+ return names;
618
+ }
619
+
620
+ // ─── the encode ───────────────────────────────────────────────────────────
621
+
622
+ // A path as an option's value inside a filter graph, escaped twice: for
623
+ // the option parser (backslash, quote and colon), then for the graph
624
+ // parser (backslash, quote, brackets, comma and semicolon), which takes
625
+ // the first escapes off again before the option parser sees the value.
626
+ // Both take the spaces, tabs and line breaks that are not escaped off the
627
+ // ends of a value, so a folder whose name ends in a space keeps it only
628
+ // when it is escaped for each. A backslash separates folders only on
629
+ // Windows; elsewhere it is a letter of a name, escaped like the others.
630
+ const filterPath = (p, platform = process.platform) =>
631
+ (platform === "win32" ? String(p).replace(/\\/g, "/") : String(p))
632
+ .replace(/[\\': \t\n\r]/g, "\\$&")
633
+ .replace(/[\\'[\],; \t\n\r]/g, "\\$&");
634
+
635
+ // The video's picture made ready for the captions: square pixels, an even
636
+ // size (H.264 in 4:2:0 needs one) and 4:2:0, in 8 bits, or 10 for HDR.
637
+ // ffmpeg has already turned a rotated video upright by then. A picture in
638
+ // full range, as some phones and screen recorders write it, stays in full
639
+ // range: ffmpeg 4.4 takes a full range 4:2:0 picture to TV range on its way
640
+ // to yuv420p while its tag goes on saying full range, and the video comes
641
+ // out pale. A picture stored as RGB is converted with the matrix the
642
+ // captions take, in TV range, where ffmpeg 4.4 took BT.601 whatever the
643
+ // size and said nothing, and a YCgCo one first with the colorspace filter,
644
+ // which no scaler converts it with. `color` is colorOf's.
645
+ function prepareChain(v, deep = false, color = colorOf(v)) {
646
+ const steps = [];
647
+ if (color.recode) steps.push(color.recode);
648
+ const how = color.rgb ? `out_color_matrix=${color.matrix}:out_range=tv` : color.range === "pc" ? "in_range=pc:out_range=pc" : "";
649
+ if (v.sar !== 1) steps.push(`scale=${v.out.width}:${v.out.height}${how ? `:${how}` : ""},setsar=1`);
650
+ else {
651
+ if (v.width !== v.out.width || v.height !== v.out.height) steps.push(`crop=${v.out.width}:${v.out.height}:0:0`);
652
+ if (how) steps.push(`scale=${how}`);
653
+ }
654
+ steps.push(deep ? "format=yuv420p10le" : "format=yuv420p");
655
+ return steps.join(",");
656
+ }
657
+
658
+ // On HDR the captions' white would be the brightest white the screen can
659
+ // show. They are drawn at the reference white ITU-R BT.2408 gives for
660
+ // graphics instead: 75% of the signal for HLG, 58% for PQ.
661
+ function hdrLevel(v) {
662
+ if (/arib-std-b67/.test(v.color.transfer)) return 0.75;
663
+ if (/smpte2084/.test(v.color.transfer)) return 0.58;
664
+ return 1;
665
+ }
666
+
667
+ // The color tags ffmpeg 8's scaler converts a picture with, as ffprobe
668
+ // names them, found on ffmpeg 8.0 one value at a time. It converts no frame
669
+ // tagged otherwise to another pixel format: not a phone's full range, 4:2:2
670
+ // or 10 bits to the 4:2:0 the captions go on, and not captioned.mp4 to the
671
+ // JPEG or PNG of a picture. Primaries or a transfer ffprobe calls reserved,
672
+ // the log transfers log100 and log316 and such matrices as YCgCo and ICtCp
673
+ // are not among them. ffmpeg 4.4 converts them all.
674
+ const CONVERTED = {
675
+ color_primaries: new Set(["bt709", "bt470m", "bt470bg", "smpte170m", "smpte240m", "film", "bt2020", "smpte428", "smpte431", "smpte432", "jedec-p22", "ebu3213"]),
676
+ color_trc: new Set(["bt709", "bt470m", "bt470bg", "smpte170m", "smpte240m", "linear", "iec61966-2-4", "bt1361e", "iec61966-2-1", "bt2020-10", "bt2020-12", "smpte2084", "smpte428", "arib-std-b67"]),
677
+ colorspace: new Set(["gbr", "bt709", "fcc", "bt470bg", "smpte170m", "smpte240m", "bt2020nc"]),
678
+ };
679
+
680
+ // The pixel formats that store a picture as RGB, its red, green and blue,
681
+ // rather than as luma and color differences: QuickTime Animation, PNG or
682
+ // FFV1 in a MOV or MKV, an animated PNG or GIF, libx264rgb and VP9's
683
+ // gbrp. ffprobe tags such a picture's matrix gbr, or names none.
684
+ const RGB_FORMATS = /^(?:[0a]?(?:rgb|bgr)|gbr|bayer_|x2(?:rgb|bgr)|pal8)/;
685
+
686
+ // What a picture in a pixel format holds, from the format's name: { rgb,
687
+ // bits, chroma }, whether it is RGB, the bits of each value, and how its
688
+ // color is sampled against its luma: 4:4:4, 4:2:2, 4:2:0, 4:1:1, 4:4:0 or
689
+ // 4:1:0, "" for gray. yuv444p12le is 12 bits and 4:4:4, p010le 10 bits
690
+ // and 4:2:0, nv12 8 bits and 4:2:0, y210le 10 bits and 4:2:2.
691
+ function pixelFormat(name) {
692
+ const n = String(name || "");
693
+ const rgb = RGB_FORMATS.test(n);
694
+ let bits = 8;
695
+ for (const pattern of [/^yuva?\d+p(\d+)(?:le|be)$/, /^(?:gray|ya)(\d+)(?:le|be)$/, /^gbra?p(\d+)(?:le|be)$/, /^x2(?:rgb|bgr)(\d+)(?:le|be)$/, /^p\d(\d\d)(?:le|be)?$/, /^y2(\d\d)(?:le|be)?$/]) {
696
+ const m = pattern.exec(n);
697
+ if (m) {
698
+ bits = Number(m[1]);
699
+ break;
700
+ }
701
+ }
702
+ if (/^(?:v210|nv20|xv30|v30x)/.test(n)) bits = 10;
703
+ if (/^xv36/.test(n)) bits = 12;
704
+ if (/^(?:rgba?|bgra?)(?:48|64)/.test(n)) bits = 16;
705
+ const chroma = rgb ? "4:4:4" : /^(?:gray|ya|mono)/.test(n) ? "" : /444|nv24|nv42|^p4\d\d|^xv|^v30x|^vuy|^ayuv|^uyva/.test(n) ? "4:4:4" : /422|nv16|nv20|^p2\d\d|^y2\d\d|^v210|^yuyv|^yvyu|^uyvy/.test(n) ? "4:2:2" : /440/.test(n) ? "4:4:0" : /411/.test(n) ? "4:1:1" : /410/.test(n) ? "4:1:0" : "4:2:0";
706
+ return { rgb, bits, chroma };
707
+ }
708
+
709
+ // The matrices the colorspace filter converts a picture from, on 4.4 and
710
+ // 8, that no scaler converts it from: YCgCo.
711
+ const RECODED = new Set(["ycgco"]);
712
+
713
+ // The color matrix and range the captions are converted with, so a
714
+ // brand color lands on the same color in the video: the video's own, or
715
+ // what players assume for its size when it says nothing. `params` are the
716
+ // video's color tags for setparams, each by a name setparams takes
717
+ // (`names`, from capabilities): ffprobe names a value no standard gives,
718
+ // as 3, "reserved", which setparams does not take, and such a tag is not
719
+ // written. The matrix is always written, so captioned.mp4 says what it
720
+ // holds wherever it goes: the video's own, or where it names none, is
721
+ // stored as RGB or was converted, the one the captions were converted
722
+ // with. `retag` is for setparams first in a chain: a tag the scaler
723
+ // converts with is left as it is, and the others are unknown while the
724
+ // picture is worked on, or for a matrix the one the captions are
725
+ // converted with, until `params` gives them back where setparams takes
726
+ // their names. A matrix or a range the video does not name is the one
727
+ // the captions take there too: ffmpeg 7.1 and newer give the overlay's
728
+ // two pictures one matrix, and take a picture that names none for
729
+ // BT.601, so the captions of an HD video were converted to BT.601 where
730
+ // the chain has no scaler, and the video's own picture to BT.709 where it
731
+ // has one. A matrix tagged reserved is the one tag ffmpeg 8 does not
732
+ // let a frame carry into its filters at all, "Invalid color space", so
733
+ // `input` gives the video, as it is read, the matrix the captions are
734
+ // converted with in its place, by the name ffmpeg's -colorspace takes.
735
+ // `rgb` says the picture is stored as RGB and goes to the matrix the
736
+ // captions take in TV range, `recode` is the colorspace filter that takes
737
+ // a YCgCo one there first, and `kept` names a matrix ffmpeg converts no
738
+ // color into, which captioned.mp4 keeps, so the captions' colors are
739
+ // coded for another. `stand` is the matrix the captions take by the name
740
+ // ffmpeg gives it, and `header` the YCbCr Matrix line captions.ass gives
741
+ // players for the video: its own matrix and range where that is BT.601 or
742
+ // BT.709, named or taken for its size, else None, with which a player
743
+ // converts the colors of captions.ass with the video's own matrix.
744
+ function colorOf(v, names = {}) {
745
+ const rgb = RGB_FORMATS.test(v.pixFmt || "") || v.color.space === "gbr";
746
+ const space = rgb ? "" : v.color.space || "";
747
+ // Where the matrix is not named: BT.2020 for BT.2020's colors or HDR,
748
+ // else as players take it for the size.
749
+ const size = /bt2020/.test(v.color.primaries || "") || v.hdr ? "bt2020" : v.height >= 720 ? "bt709" : "bt601";
750
+ const matrix = rgb ? size : /bt2020/.test(space) ? "bt2020" : /bt709/.test(space) ? "bt709" : /smpte170m|bt470bg|bt601/.test(space) ? "bt601" : size;
751
+ const range = !rgb && v.color.range === "pc" ? "pc" : "tv";
752
+ const takes = (option, value) => Boolean(value) && value !== "unknown" && (names[option] && names[option].size ? names[option].has(value) : !/^reserved/.test(value));
753
+ const stand = { bt709: "bt709", bt601: "smpte170m", bt2020: "bt2020nc" }[matrix];
754
+ const odd = (option, value) => Boolean(value) && value !== "unknown" && !CONVERTED[option].has(value);
755
+ const recoded = RECODED.has(space);
756
+ const params = [];
757
+ // The colorspace filter tags what it converts BT.709: a primaries or a
758
+ // transfer the video does not name goes back to unknown.
759
+ for (const [option, value] of [["color_primaries", v.color.primaries], ["color_trc", v.color.transfer]]) {
760
+ if (takes(option, value)) params.push(`${option}=${value}`);
761
+ else if (recoded) params.push(`${option}=unknown`);
762
+ }
763
+ const kept = space && !recoded && odd("colorspace", space) && takes("colorspace", space) ? space : "";
764
+ params.push(`colorspace=${kept || (takes("colorspace", space) && !recoded ? space : stand)}`);
765
+ params.push(`range=${range}`);
766
+ const unnamed = !rgb && (!space || space === "unknown");
767
+ const retag = [odd("color_primaries", v.color.primaries) && "color_primaries=unknown", odd("color_trc", v.color.transfer) && "color_trc=unknown", (odd("colorspace", space) || unnamed) && `colorspace=${stand}`, !rgb && !v.color.range && `range=${range}`].filter(Boolean).join(":");
768
+ const input = /^reserved/.test(space) ? ["-colorspace", stand] : [];
769
+ // Only the matrix changes: the primaries and the transfer it is told are
770
+ // the same on both sides, whatever the video's are.
771
+ const recode = recoded ? `colorspace=iprimaries=bt709:itrc=bt709:ispace=${space}:irange=${range}:primaries=bt709:trc=bt709:space=${stand}:range=${range}` : "";
772
+ const plain = !rgb && !recoded && !kept && matrix !== "bt2020" && !/^reserved/.test(space);
773
+ const header = plain ? matrixLine(matrix, range) : "None";
774
+ return { matrix, range, params: params.join(":"), retag, input, rgb, recode, kept, stand, header };
775
+ }
776
+
777
+ // Each sound stream kept as it is when MP4 players take it (AAC, MP3),
778
+ // else made AAC at the same rate: { args, notes, tracks }, with how each
779
+ // track was written, in the order they are, as { copied, from }, from the
780
+ // codec it had.
781
+ function audioArgs(v, caps) {
782
+ const args = [];
783
+ const notes = [];
784
+ const tracks = [];
785
+ v.audio.forEach((a, i) => {
786
+ args.push("-map", `0:${a.index}`);
787
+ if (["aac", "mp3"].includes(a.codec)) {
788
+ args.push(`-c:a:${i}`, "copy");
789
+ tracks.push({ copied: true, from: a.codec });
790
+ } else {
791
+ if (!caps.aac) throw new Error(`The sound is ${a.codec}, which MP4 players do not take, and this ffmpeg has no AAC encoder to convert it`);
792
+ const bitrate = a.channels > 2 ? "384k" : a.channels === 1 ? "128k" : "192k";
793
+ args.push(`-c:a:${i}`, "aac", `-b:a:${i}`, bitrate);
794
+ notes.push(`${v.audio.length > 1 ? `Sound track ${i + 1}` : "The sound"} (${a.codec}) was made AAC at ${bitrate}, at the same rate, since MP4 players do not take ${a.codec}.`);
795
+ tracks.push({ copied: false, from: a.codec });
796
+ }
797
+ });
798
+ return { args, notes, tracks };
799
+ }
800
+
801
+ // The filter that draws an ASS file with libass: ffmpeg's ass filter, with
802
+ // libass's complex shaping, through HarfBuzz. The subtitles filter sets
803
+ // libass to its simple shaping, in which an Arabic letter takes its joined
804
+ // form only from a font that has the presentation forms, which Noto Kufi
805
+ // Arabic has not, so its letters come out as boxes, and Devanagari and
806
+ // Bengali letters join into no conjuncts.
807
+ const libassFilter = (ass, fontsDir = "") => `ass=filename=${filterPath(ass)}${fontsDir ? `:fontsdir=${filterPath(fontsDir)}` : ""}:shaping=complex`;
808
+
809
+ // The YCbCr Matrix line of an ASS file for a picture in the matrix (bt601,
810
+ // bt709 or bt2020) and range given: TV.709, PC.601. BT.2020 has no such
811
+ // name, and None has a player, or ffmpeg's ass filter, convert the colors
812
+ // with the picture's own matrix.
813
+ const matrixLine = (matrix, range) => (matrix === "bt2020" ? "None" : `${range === "pc" ? "PC" : "TV"}.${matrix === "bt709" ? "709" : "601"}`);
814
+
815
+ // Kr and Kb of each matrix, which give its luma and color differences.
816
+ const LUMA = { bt601: [0.299, 0.114], bt709: [0.2126, 0.0722], bt2020: [0.2627, 0.0593] };
817
+
818
+ // The color to give an ass filter that converts every color as BT.601
819
+ // does, for `hex` to come out as it is coded in `matrix`: { hex, shown },
820
+ // shown being the color the video then shows. That is `hex` itself, but
821
+ // for a color whose coding in `matrix` would need one past what RGB
822
+ // holds, as a pure green on an HD video would, which comes out duller.
823
+ function as601(hex, matrix) {
824
+ const code = (c, [kr, kb]) => {
825
+ const y = kr * c[0] + (1 - kr - kb) * c[1] + kb * c[2];
826
+ return [y, (c[2] - y) / (2 * (1 - kb)), (c[0] - y) / (2 * (1 - kr))];
827
+ };
828
+ const decode = ([y, u, v], [kr, kb]) => {
829
+ const r = y + 2 * (1 - kr) * v;
830
+ const b = y + 2 * (1 - kb) * u;
831
+ return [r, (y - kr * r - kb * b) / (1 - kr - kb), b];
832
+ };
833
+ const bytes = (c) => c.map((x) => Math.round(Math.min(1, Math.max(0, x)) * 255));
834
+ const text = (c) => `#${c.map((x) => x.toString(16).padStart(2, "0")).join("")}`;
835
+ const rgb = [1, 3, 5].map((i) => parseInt(String(hex).slice(i, i + 2), 16) / 255);
836
+ const given = bytes(decode(code(rgb, LUMA[matrix] || LUMA.bt601), LUMA.bt601));
837
+ const shown = bytes(decode(code(given.map((x) => x / 255), LUMA.bt601), LUMA[matrix] || LUMA.bt601));
838
+ return { hex: text(given), shown: text(shown) };
839
+ }
840
+
841
+ // Whether this ffmpeg's ass filter converts the colors of an ASS file with
842
+ // the matrix and range it is told, as ffmpeg 8 does by its YCbCr Matrix
843
+ // line and 7 by the tags of the frames it draws on, rather than every color
844
+ // as BT.601 does, in TV range or, on a yuvj420p picture, in full range, as
845
+ // 4.4 to 6 do: a red square drawn under PC.709 on a frame tagged BT.709 and
846
+ // full range has a luma of 54 then, and of 81 where BT.601 is taken. Asked
847
+ // once of each ffmpeg, in the folder `dir`.
848
+ const follows = new Map();
849
+ async function libassFollows(bins, dir, caps) {
850
+ if (follows.has(bins.ffmpeg)) return follows.get(bins.ffmpeg);
851
+ const file = path.join(dir, "matrix.ass");
852
+ fs.writeFileSync(file, ["[Script Info]", "ScriptType: v4.00+", "PlayResX: 16", "PlayResY: 16", "YCbCr Matrix: PC.709", "", "[V4+ Styles]", "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding", "Style: Square,Arial,10,&H000000FF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1", "", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text", "Dialogue: 0,0:00:00.00,0:00:05.00,Square,,0,0,0,,{\\pos(0,0)\\p1}m 0 0 l 16 0 16 16 0 16{\\p0}", ""].join("\n"));
853
+ const tags = caps.setparams ? ",setparams=colorspace=bt709:range=pc" : "";
854
+ // An ffmpeg that cannot say is taken to convert as BT.601 does, the way
855
+ // that comes out right on every ass filter, a very saturated color aside.
856
+ let result = { code: -1 };
857
+ try {
858
+ result = await run(bins.ffmpeg, ["-hide_banner", "-nostdin", "-loglevel", "error", "-f", "lavfi", "-i", `color=c=black:s=16x16:r=1:d=1,format=yuv420p${tags}`, "-vf", "ass=filename=matrix.ass", "-frames:v", "1", "-f", "rawvideo", "-"], { cwd: dir, quiet: 30_000, binary: true });
859
+ } catch {
860
+ // Not kept: the next run asks again.
861
+ }
862
+ fs.rmSync(file, { force: true });
863
+ const luma = result.code === 0 && result.stdout.length >= 256 ? result.stdout[8 * 16 + 8] : -1;
864
+ const answer = Math.abs(luma - 54) <= 3;
865
+ if (result.code === 0) follows.set(bins.ffmpeg, answer);
866
+ return answer;
867
+ }
868
+
869
+ // How libass draws a video's captions, from colorOf's colors and whether
870
+ // the ass filter follows the matrix it is told (libassFollows): { header,
871
+ // matrix, tint, full }. header is the burn's YCbCr Matrix line and matrix
872
+ // the one the picture is tagged with as the filter gets it, by ffmpeg's
873
+ // name, which ffmpeg 8 and 7 convert by: the picture's own. Where the
874
+ // filter takes BT.601 whatever it is told, both say BT.601, `tint` gives
875
+ // the color to write for each color, the one whose BT.601 coding is its
876
+ // coding in the picture's own matrix, and `full` says a full range picture
877
+ // goes to the filter as yuvj420p, the one picture 4.4 draws full range
878
+ // colors on.
879
+ function assColors(color, follow) {
880
+ if (follow) return { header: matrixLine(color.matrix, color.range), matrix: color.stand, tint: null, full: false };
881
+ return { header: matrixLine("bt601", color.range), matrix: "smpte170m", tint: color.matrix === "bt601" ? null : (hex) => as601(hex, color.matrix).hex, full: color.range === "pc" };
882
+ }
883
+
884
+ // The video's frames at a steady rate, `fill.rate` (30/1, 30000/1001),
885
+ // from 0 to `fill.end`, each frame repeated until the next comes: the first
886
+ // from 0 when the picture starts later, and the last to the end, past
887
+ // where the frames stop. fps pads the start; tpad holds the last frame
888
+ // whether or not fps already ran it to the end of its own duration, and
889
+ // trim ends the picture where the captions do.
890
+ const fillChain = (fill) => `fps=fps=${fill.rate}:start_time=0,tpad=stop_mode=clone:stop_duration=${Math.max(0, fill.end - fill.last + 1).toFixed(3)},trim=end=${fill.end.toFixed(3)},`;
891
+
892
+ const gcd = (a, b) => (b ? gcd(b, a % b) : a);
893
+
894
+ // The time base, as settb takes it, that holds every frame's time of a
895
+ // picture in the time base `base` (1:30, 1001:30000) and every
896
+ // millisecond, the times the caption states change at: 1 over the least
897
+ // common multiple of its denominator and 1000. The overlay reads the
898
+ // states in the time base of the picture they go over, rounded to the
899
+ // nearest of its steps, so in one as coarse as a frame (a picture given
900
+ // frames at a steady rate, an AVI, an MP4 whose timescale is its frame
901
+ // rate) a change came up to half a frame early. "" when the base is not
902
+ // known, or no finer one fits a time base's numbers.
903
+ function fineBase(base) {
904
+ const m = /^(\d+)[:/](\d+)$/.exec(String(base || ""));
905
+ if (!m || !Number(m[1]) || !Number(m[2])) return "";
906
+ const den = Number(m[2]) / gcd(Number(m[1]), Number(m[2]));
907
+ const fine = (den / gcd(den, 1000)) * 1000;
908
+ return fine <= 2 ** 31 - 1 ? `1/${fine}` : "";
909
+ }
910
+
911
+ // The time base of the frames at a steady rate (30/1, 30000/1001, 24),
912
+ // one over the rate.
913
+ const rateBase = (rate) => {
914
+ const m = /^(\d+)(?:\/(\d+))?$/.exec(String(rate));
915
+ return m ? `${m[2] || 1}:${m[1]}` : "";
916
+ };
917
+
918
+ // Lays the captions over the video in one pass. Two ways:
919
+ // - overlay: the caption states Chrome drew, in one animated PNG that
920
+ // shows each for its duration, read as one stream and laid at y.
921
+ // - libass: the ASS file drawn by libassFilter, on a picture tagged with
922
+ // the matrix `assMatrix` and the range the captions take, which ffmpeg 7
923
+ // converts their colors with, and with `assFull` as yuvj420p, for an ass
924
+ // filter that draws full range colors on no other picture (assColors).
925
+ // The captions show on the video's own frames. Where it has too few for
926
+ // them, as fillFor says, `fill` gives it frames at a steady rate first.
927
+ async function burn({ bins, caps, video: v, output, overlay = null, ass = null, assMatrix = "", assFull = false, fontsDir = "", fill = null, onProgress = null, quiet = 180_000 }) {
928
+ if (!caps.encoder) throw new Error("This ffmpeg has no H.264 encoder (libx264, VideoToolbox or OpenH264), so it cannot write the captioned MP4");
929
+ const color = colorOf(v, caps.colorNames);
930
+ // An HDR video stays HDR where x264 writes 10 bits.
931
+ const deep = v.hdr && caps.tenBit;
932
+ const level = hdrLevel(v);
933
+ const dim = level < 1 ? `colorchannelmixer=rr=${level}:gg=${level}:bb=${level},` : "";
934
+ const inputs = [...color.input, "-i", v.file];
935
+ // Tags the scaler converts with, before anything converts the picture.
936
+ const retag = caps.setparams && color.retag ? `setparams=${color.retag}` : "";
937
+ const first = `[0:${v.index}]${retag ? `${retag},` : ""}${fill ? fillChain(fill) : ""}${prepareChain(v, deep, color)}`;
938
+ let graph;
939
+ // The animated PNG goes by its name, with ffmpeg in its folder, so
940
+ // nothing in the folder's path (a % that names a numbered sequence, say)
941
+ // is taken for more than a name. No frame is held back to a rate.
942
+ const cwd = overlay ? path.dirname(overlay.file) : undefined;
943
+ if (overlay) {
944
+ inputs.push("-f", "apng", "-max_fps", "0", "-i", path.basename(overlay.file));
945
+ // A time base the states' milliseconds and the picture's frames both
946
+ // fall on, so each change shows from the first frame at or after it.
947
+ const fine = fineBase(fill ? rateBase(fill.rate) : v.timeBase);
948
+ graph =
949
+ `${first}${fine ? `,settb=${fine}` : ""}[m];` +
950
+ `[1:v]format=rgba,${dim}scale=out_color_matrix=${color.matrix}:out_range=${color.range},format=${deep ? "yuva420p10le" : "yuva420p"}[c];` +
951
+ `[m][c]overlay=x=0:y=${overlay.y}:eof_action=pass:format=${deep ? "yuv420p10" : "yuv420"}${caps.setparams ? `,setparams=${color.params}` : ""}[v]`;
952
+ } else {
953
+ const tag = caps.setparams ? `,setparams=colorspace=${assMatrix || color.stand}:range=${color.range}` : "";
954
+ const full = assFull && !deep;
955
+ graph = `${first}${tag}${full ? ",scale=in_range=pc:out_range=pc,format=yuvj420p" : ""},${libassFilter(ass, fontsDir)}${full ? ",scale=in_range=pc:out_range=pc,format=yuv420p" : ""}${caps.setparams ? `,setparams=${color.params}` : ""}[v]`;
956
+ }
957
+ const encoder = deep ? caps.encoder.args.map((a) => (a === "high" ? "high10" : a)) : caps.encoder.args;
958
+ const sound = audioArgs(v, caps);
959
+ const args = [
960
+ "-hide_banner", "-nostdin", "-y", "-loglevel", "error",
961
+ ...inputs,
962
+ "-filter_complex", graph,
963
+ "-map", "[v]",
964
+ ...sound.args,
965
+ ...encoder,
966
+ // Every frame at its own time: in the video's own time base, not moved
967
+ // onto a grid of the frame rate, which a variable rate is not on. Or,
968
+ // where it was given frames at a steady rate, at that rate.
969
+ ...(fill ? ["-r", fill.rate, ...(caps.fpsMode ? ["-fps_mode:v", "cfr"] : ["-vsync", "cfr"])] : v.timeBase ? ["-enc_time_base:v", v.timeBase, ...(caps.fpsMode ? ["-fps_mode:v", "vfr"] : ["-vsync", "vfr"])] : []),
970
+ "-pix_fmt", deep ? "yuv420p10le" : "yuv420p",
971
+ // The file's own tags go (a phone's say where it was recorded); each
972
+ // sound track keeps its own, its language among them, and the
973
+ // chapters stay.
974
+ "-map_metadata:g", "-1", "-map_chapters", "0",
975
+ "-metadata", "comment=Captioned with easycaptionkit",
976
+ "-movflags", "+faststart",
977
+ "-progress", "pipe:1", "-nostats",
978
+ output,
979
+ ];
980
+ let reported = -1;
981
+ // After the last frame, +faststart writes the whole file again with its
982
+ // index first and prints nothing until it is done, a minute and more for
983
+ // a video of tens of GB: while the file still grows or changes, ffmpeg
984
+ // is at work.
985
+ const target = path.resolve(cwd || "", output);
986
+ const written = () => {
987
+ try {
988
+ const s = fs.statSync(target);
989
+ return `${s.size} ${s.mtimeMs}`;
990
+ } catch {
991
+ return "";
992
+ }
993
+ };
994
+ const result = await run(bins.ffmpeg, args, {
995
+ quiet,
996
+ busy: written,
997
+ cwd,
998
+ onStdout: (text) => {
999
+ const m = /out_time_us=(\d+)/.exec(text);
1000
+ if (!m || !onProgress) return;
1001
+ const share = Math.min(1, Number(m[1]) / 1e6 / v.duration);
1002
+ const step = Math.floor(share * 10);
1003
+ if (step > reported) {
1004
+ reported = step;
1005
+ onProgress(share);
1006
+ }
1007
+ },
1008
+ });
1009
+ if (result.code !== 0) throw new Error(`ffmpeg could not write ${path.basename(output)}: ${whyFailed(result)}`);
1010
+ // `frameTags` are for frameAt: `retag`, as captioned.mp4 has the tags
1011
+ // setparams gave back, and for an SDR video `untag`, which leaves its
1012
+ // primaries and transfer out of a picture of a frame. The PNG encoder
1013
+ // writes them into the PNG (cICP, cHRM and gAMA), and Chrome converts
1014
+ // the colors by them, darker or lighter than players show the video.
1015
+ const untag = caps.setparams && !v.hdr ? "color_primaries=unknown:color_trc=unknown" : "";
1016
+ return { notes: sound.notes, tracks: sound.tracks, color, deep, level, frameTags: { retag: retag ? color.retag : "", untag } };
1017
+ }
1018
+
1019
+ // One frame of a video at a time, scaled to `width`, as JPEG or PNG.
1020
+ // -update 1 writes the name as it is: a % in it is not a frame number.
1021
+ // `retag` gives setparams the tags the scaler converts with, as burn does,
1022
+ // and `untag`, last, the tags the picture written leaves out.
1023
+ async function frameAt(bins, file, t, output, { width = 0, retag = "", untag = "" } = {}) {
1024
+ const steps = [...(retag ? [`setparams=${retag}`] : []), ...(width ? [`scale=${width}:-2:flags=lanczos`] : []), ...(untag ? [`setparams=${untag}`] : [])];
1025
+ const scale = steps.length ? ["-vf", steps.join(",")] : [];
1026
+ const result = await run(bins.ffmpeg, ["-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-ss", Math.max(0, t).toFixed(3), "-i", file, "-frames:v", "1", ...scale, "-q:v", "3", "-update", "1", output], { quiet: 60_000 });
1027
+ if (result.code !== 0) throw new Error(`ffmpeg could not take a frame at ${t.toFixed(2)} s: ${whyFailed(result)}`);
1028
+ if (!fs.existsSync(output)) throw new Error(`ffmpeg could not take a frame at ${t.toFixed(2)} s: it found none there`);
1029
+ return output;
1030
+ }
1031
+
1032
+ module.exports = { tools, findFfmpeg, findFfprobe, given, namedNothing, probe, readVideo, packetsOf, frameTimes, rateOf, steadyRate, fillFor, capabilities, burn, frameAt, run, whyFailed, optionNames, onPath, MISSING, colorOf, prepareChain, filterPath, libassFilter, hdrLevel, fineBase, matrixLine, as601, libassFollows, assColors, pixelFormat };