easycaptionkit 0.0.0-stage → 0.1.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 +21 -0
- package/README.md +175 -2
- package/package.json +42 -4
- package/skills/easycaptionkit/SKILL.md +58 -0
- package/skills/easycaptionkit/scripts/easycaptionkit.js +386 -0
- package/skills/easycaptionkit/scripts/lib/apng.js +136 -0
- package/skills/easycaptionkit/scripts/lib/caption.js +1395 -0
- package/skills/easycaptionkit/scripts/lib/chrome.js +424 -0
- package/skills/easycaptionkit/scripts/lib/cues.js +317 -0
- package/skills/easycaptionkit/scripts/lib/draw.js +288 -0
- package/skills/easycaptionkit/scripts/lib/ffmpeg.js +1032 -0
- package/skills/easycaptionkit/scripts/lib/files.js +249 -0
- package/skills/easycaptionkit/scripts/lib/fontfile.js +355 -0
- package/skills/easycaptionkit/scripts/lib/look.js +571 -0
- package/skills/easycaptionkit/scripts/lib/places.js +337 -0
- package/skills/easycaptionkit/scripts/lib/render.js +374 -0
- package/skills/easycaptionkit/scripts/lib/report.js +604 -0
- package/skills/easycaptionkit/scripts/lib/running.js +62 -0
- package/skills/easycaptionkit/scripts/lib/subtitles.js +489 -0
- package/skills/easycaptionkit/scripts/lib/text.js +115 -0
- package/skills/easycaptionkit/scripts/lib/version.js +5 -0
- package/skills/easycaptionkit/scripts/lib/words.js +1090 -0
|
@@ -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 };
|