easycaptionkit 0.1.1 → 0.1.3

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,478 @@
1
+ "use strict";
2
+
3
+ // The programs a run starts, ffmpeg, ffprobe and Chrome: where each one
4
+ // is, and whether it starts and answers as itself, asked before any work.
5
+ // Of one that does not, a run says only what is certain: what is at its
6
+ // path, as a folder or a file this user may not run, else what the system
7
+ // said when it could not start it, the first line the program printed and
8
+ // how it ended, or that it gave no answer in time. That line is the
9
+ // program's own, and says in its words what is at fault: nothing here
10
+ // guesses which file of a chain it is.
11
+
12
+ const fs = require("node:fs");
13
+ const path = require("node:path");
14
+ const { spawn } = require("node:child_process");
15
+ const { getSystemErrorMap } = require("node:util");
16
+ const { track } = require("./running");
17
+
18
+ const WINDOWS = process.platform === "win32";
19
+
20
+ const exe = (name) => (WINDOWS ? `${name}.exe` : name);
21
+
22
+ // A name with no folder in it, which is looked up on PATH.
23
+ const bare = (value) => !(value.includes("/") || (WINDOWS && value.includes("\\")));
24
+
25
+ // The errors a start can fail with that libuv has no name for, and Node
26
+ // calls "Unknown system error -86": each one's name and the system's
27
+ // words, by system and number. Linux's numbers are those of every
28
+ // processor Node.js runs on but MIPS.
29
+ const UNNAMED = {
30
+ linux: { 80: ["ELIBBAD", "accessing a corrupted shared library"], 116: ["ESTALE", "stale file handle"] },
31
+ darwin: { 70: ["ESTALE", "stale NFS file handle"], 85: ["EBADEXEC", "bad executable (or shared library)"], 86: ["EBADARCH", "bad CPU type in executable"], 87: ["ESHLIBVERS", "shared library version mismatch"], 88: ["EBADMACHO", "malformed Mach-o file"] },
32
+ };
33
+
34
+ // An error's name and the system's words for it, as ["EACCES",
35
+ // "permission denied"], or [] when neither is known.
36
+ const known = (err) => getSystemErrorMap().get(err.errno) || (!/^mips/.test(process.arch) && (UNNAMED[process.platform] || {})[-err.errno]) || [];
37
+
38
+ // The system's name for an error, as EACCES, else Node's.
39
+ const codeOf = (err) => known(err)[0] || err.code;
40
+
41
+ // The system's words for an error, as "permission denied", else its code.
42
+ const worded = (err) => known(err)[1] || err.code || String(err.message || err);
43
+
44
+ // How long a program may take to answer, in milliseconds: on a machine
45
+ // short of memory, with its swap full, ffmpeg 4.4 took more than 30
46
+ // seconds to say its version.
47
+ const LIMIT = 120_000;
48
+
49
+ // ─── where a program is ───────────────────────────────────────────────────
50
+
51
+ // A file this user may run, as a shell takes one: a file, not a folder,
52
+ // with the permission to run it. On Windows that permission is not looked
53
+ // at, and any file passes.
54
+ function runnable(file) {
55
+ try {
56
+ fs.accessSync(file, fs.constants.X_OK);
57
+ return fs.statSync(file).isFile();
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ // Whether a folder is there and this user may enter it, as a program run
64
+ // in it needs.
65
+ function enterable(folder) {
66
+ try {
67
+ fs.accessSync(folder, fs.constants.X_OK);
68
+ return fs.statSync(folder).isDirectory();
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ // The folders of PATH in order, each as a whole path: one given from the
75
+ // current folder is taken from the folder the run started in, so a
76
+ // program found there is found when it runs in another folder. An empty
77
+ // entry is passed over.
78
+ const folders = () =>
79
+ String(process.env.PATH || "")
80
+ .split(path.delimiter)
81
+ .filter(Boolean)
82
+ .map((dir) => path.resolve(dir));
83
+
84
+ // The first program by a name on PATH that this user may run, in a folder
85
+ // `skip` does not pass over, or "".
86
+ function onPath(name, { skip = () => false } = {}) {
87
+ for (const dir of String(process.env.PATH || "").split(path.delimiter)) {
88
+ if (!dir || skip(dir)) continue;
89
+ const file = path.resolve(dir, name);
90
+ if (runnable(file)) return file;
91
+ }
92
+ return "";
93
+ }
94
+
95
+ // Whether a file, not a folder, is at a path, as Windows looks for one.
96
+ function there(file) {
97
+ try {
98
+ return !fs.statSync(file).isDirectory();
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ // Whether Windows runs a file by its own name: one whose name has an
105
+ // extension. It runs a name with none only as that name with .com or .exe
106
+ // added, which libuv looks for next to it.
107
+ function runsAsNamed(file) {
108
+ const name = path.basename(file);
109
+ const dot = name.indexOf(".");
110
+ return dot >= 0 && dot < name.length - 1;
111
+ }
112
+
113
+ // The file a path goes through as if it were a folder, as notes.txt in
114
+ // notes.txt/ffmpeg, or "".
115
+ function fileOnTheWay(file) {
116
+ const parts = file.split(path.sep);
117
+ for (let i = 2; i < parts.length; i++) {
118
+ const part = parts.slice(0, i).join(path.sep);
119
+ try {
120
+ if (!fs.statSync(part).isDirectory()) return part;
121
+ } catch {
122
+ return "";
123
+ }
124
+ }
125
+ return "";
126
+ }
127
+
128
+ // What keeps the system from running the file at a path, as far as can be
129
+ // told for certain before it runs: null when nothing does, else the words
130
+ // for it, and in `on` what the system does with such a file as it looks
131
+ // for a program on PATH. It "passes" over a folder, a named pipe, a
132
+ // socket and a device, none of which it runs, a link to nothing and a
133
+ // file this user may not run, to look in the next folder, and these are
134
+ // named when no program comes after them; it passes over nothing there
135
+ // and a folder this user may not enter "quietly", as these may hold no
136
+ // such program at all; and it "stops" at a path it cannot follow, as a
137
+ // loop of links. On Windows, where no permission to run a file is looked
138
+ // at, a file whose name has no extension passes, as Windows does not run
139
+ // it, unless a .com or an .exe by that name is next to it, which it runs.
140
+ function barred(file) {
141
+ let stat;
142
+ try {
143
+ stat = fs.statSync(file);
144
+ } catch (err) {
145
+ if (err.code === "ENOENT" || err.code === "ENOTDIR") {
146
+ let link = false;
147
+ try {
148
+ link = fs.lstatSync(file).isSymbolicLink();
149
+ } catch {
150
+ // Nothing there at all.
151
+ }
152
+ if (link) return { on: "passes", words: "is a link to a file that is not there" };
153
+ const through = err.code === "ENOTDIR" ? fileOnTheWay(file) : "";
154
+ return { on: "quietly", words: through ? `where there is none, as ${through} is a file, not a folder` : "where there is none" };
155
+ }
156
+ if (err.code === "EACCES" || err.code === "EPERM") return { on: "quietly", words: "may not be run: permission denied" };
157
+ if (err.code === "ELOOP") return { on: "stops", words: "goes through a loop of links" };
158
+ return { on: "stops", words: `cannot be looked at: ${worded(err)}` };
159
+ }
160
+ if (stat.isDirectory()) return { on: "passes", words: "is a folder" };
161
+ if (WINDOWS) return runsAsNamed(file) || there(`${file}.com`) || there(`${file}.exe`) ? null : { on: "passes", words: "has no .exe in its name, so Windows does not run it" };
162
+ if (!stat.isFile()) return { on: "passes", words: stat.isFIFO() ? "is a named pipe" : stat.isSocket() ? "is a socket" : "is a device" };
163
+ try {
164
+ fs.accessSync(file, fs.constants.X_OK);
165
+ } catch {
166
+ return { on: "passes", words: (stat.mode & 0o111) === 0 ? "is not executable" : "may not be run: permission denied" };
167
+ }
168
+ return null;
169
+ }
170
+
171
+ // ─── what a program said ──────────────────────────────────────────────────
172
+
173
+ // What `file` did when run with `args`, given `limit` milliseconds: { code,
174
+ // signal, stdout, stderr }, { late: true } when it gave no answer in that
175
+ // time and was stopped (see late()), or { err } when the system did not
176
+ // start it. What it printed is kept up to `cap` bytes.
177
+ function ask(file, args, { limit = LIMIT, cap = 1 << 20 } = {}) {
178
+ return new Promise((resolve) => {
179
+ let child;
180
+ try {
181
+ child = track(spawn(file, args, { stdio: ["ignore", "pipe", "pipe"] }));
182
+ } catch (err) {
183
+ resolve({ err });
184
+ return;
185
+ }
186
+ let timer = null;
187
+ let done = false;
188
+ const end = (result) => {
189
+ if (done) return;
190
+ done = true;
191
+ clearTimeout(timer);
192
+ resolve(result);
193
+ };
194
+ // A start that fails gives its error before the close.
195
+ child.on("error", (err) => end({ err }));
196
+ // With no file descriptor left Node makes no pipes, and says why a
197
+ // moment later.
198
+ if (!child.stdout || !child.stderr) return;
199
+ const said = { stdout: [], stderr: [] };
200
+ let size = 0;
201
+ for (const name of ["stdout", "stderr"]) {
202
+ child[name].on("data", (chunk) => {
203
+ if (size >= cap) return;
204
+ said[name].push(chunk);
205
+ size += chunk.length;
206
+ });
207
+ }
208
+ // A program that hands its output on to one that goes on running may
209
+ // never close it, so the answer is not waited for past the limit.
210
+ timer = setTimeout(() => {
211
+ try {
212
+ child.kill("SIGKILL");
213
+ } catch {
214
+ // Gone already.
215
+ }
216
+ child.stdout.destroy();
217
+ child.stderr.destroy();
218
+ end({ late: true });
219
+ }, limit);
220
+ child.on("close", (code, signal) => end({ code, signal, stdout: Buffer.concat(said.stdout).toString("utf8"), stderr: Buffer.concat(said.stderr).toString("utf8") }));
221
+ });
222
+ }
223
+
224
+ // A line a program printed, in quotes, as it printed it, with its control
225
+ // characters written out, as \r or \x1b. A line longer than 200 characters
226
+ // keeps its first 120 and its last 80, as its end most often says why.
227
+ function quoted(line) {
228
+ const chars = Array.from(String(line)).map((c) => {
229
+ if (c === "\t") return "\\t";
230
+ if (c === "\r") return "\\r";
231
+ if (!/[\p{Cc}\p{Cf}]/u.test(c)) return c;
232
+ const n = c.codePointAt(0);
233
+ return n < 0x100 ? `\\x${n.toString(16).padStart(2, "0")}` : `\\u${n.toString(16).padStart(4, "0")}`;
234
+ });
235
+ const shown = chars.length > 200 ? [...chars.slice(0, 120), "...", ...chars.slice(-80)] : chars;
236
+ return `"${shown.join("")}"`;
237
+ }
238
+
239
+ // The first line with anything in it of what a program printed, or null.
240
+ const firstLine = (text) =>
241
+ String(text || "")
242
+ .split(/\r?\n/)
243
+ .find((line) => line.trim()) || null;
244
+
245
+ // What a program that ran did, where it did not answer: the first line it
246
+ // printed on its error output, where a program says what is wrong, else on
247
+ // its output, and how it ended. ffmpeg, ffprobe and Chrome give their
248
+ // versions on their output, so a program that ended well and printed on
249
+ // its error output alone is said to.
250
+ function happened(r) {
251
+ const line = firstLine(r.stderr);
252
+ if (line && r.code === 0 && !firstLine(r.stdout)) return `it printed ${quoted(line)} on its error output, nothing on its output, and exited with 0`;
253
+ const ended = r.signal ? `was stopped by ${r.signal}` : `exited with ${r.code}`;
254
+ const said = line || firstLine(r.stdout);
255
+ if (said) return `it printed ${quoted(said)} and ${ended}`;
256
+ return r.signal ? `it was stopped by ${r.signal}` : `it printed nothing and exited with ${r.code}`;
257
+ }
258
+
259
+ // The first line of a file that starts with #!, which names the program
260
+ // the system starts the file with, or null. It is opened without waiting,
261
+ // as a named pipe put in the file's place since it was looked at would
262
+ // wait for a writer for good, and read only when it is a file.
263
+ function bangLine(file) {
264
+ let fd;
265
+ try {
266
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
267
+ if (!fs.fstatSync(fd).isFile()) return null;
268
+ const bytes = Buffer.alloc(256);
269
+ const got = bytes.subarray(0, fs.readSync(fd, bytes, 0, bytes.length, 0));
270
+ if (got.indexOf("#!") !== 0) return null;
271
+ const end = got.indexOf(10);
272
+ return got.subarray(0, end < 0 ? got.length : end).toString("utf8");
273
+ } catch {
274
+ return null;
275
+ } finally {
276
+ if (fd !== undefined) fs.closeSync(fd);
277
+ }
278
+ }
279
+
280
+ // What the system said when it did not start a file the checks found
281
+ // nothing wrong with. Off Windows it says ENOENT of a file that is there
282
+ // when an interpreter or a loader the file needs is not, so a script's
283
+ // first line, which names its interpreter, is shown with it. Windows runs
284
+ // no interpreter a first line names.
285
+ function unstartable(file, err) {
286
+ if (["ENOEXEC", "EFTYPE", "EBADARCH", "EBADMACHO"].includes(codeOf(err))) return "it is not a program this system can run";
287
+ const line = WINDOWS ? null : bangLine(file);
288
+ const first = line === null ? "" : `; its first line is ${quoted(line)}`;
289
+ if (codeOf(err) === "ENOENT" && !WINDOWS) return `the system could not start it, as an interpreter or a loader it needs is not there${first}`;
290
+ return `the system could not start it: ${worded(err)}${first}`;
291
+ }
292
+
293
+ // The errors that say the system is short of something to start a program
294
+ // with, not that the program is wrong: what it is short of, and what to do.
295
+ const SHORT = {
296
+ EMFILE: "too many open files. Raise the limit on open files (ulimit -n) and run it again",
297
+ ENFILE: "the system has too many files open. Run it again once fewer are",
298
+ EAGAIN: "the system starts no more processes for now. Run it again once fewer are running",
299
+ ENOMEM: "not enough memory. Free some and run it again",
300
+ E2BIG: "its environment is too large. Run it with fewer or shorter environment variables",
301
+ };
302
+
303
+ // The errors a start fails with that the system passes over, as glibc's
304
+ // execvp does, to try the next folder of PATH: a file that is not there or
305
+ // may not be run, or one whose interpreter or loader is not there.
306
+ const PASSED = new Set(["ENOENT", "EACCES", "ENOTDIR", "ESTALE", "ENODEV", "ETIMEDOUT"]);
307
+
308
+ // The error for a program the system could not start for want of something
309
+ // it is short of, as open files: no fault of the program's.
310
+ const short = (name, err) => Object.assign(new Error(`easycaptionkit could not start ${name}: ${SHORT[err.code]}.`), { unusable: true, why: `the system could not start ${name}: ${SHORT[err.code].replace(/\. .*$/, "")}` });
311
+
312
+ // The error for a program that gave no answer in `limit` ms when asked
313
+ // `what`, as "its version": ffmpeg on a machine short of memory can be
314
+ // that slow as well as a program that is not ffmpeg, so only that is said,
315
+ // and to run again, with no advice to install another.
316
+ function late(spec, place, what, limit) {
317
+ const words = `gave no answer in ${limit / 1000} s when asked ${what}`;
318
+ return Object.assign(new Error(`easycaptionkit could not check ${spec.name}, ${place.file}: it ${words}, as on a machine short of memory. ${spec.advice.late}`), { unusable: true, why: placed(place, words) });
319
+ }
320
+
321
+ // The error for a program a run checked before any work that the system
322
+ // did not start during the work, `name` being what messages call it: what
323
+ // the system is short of, else the system's words, never its code alone,
324
+ // and what to check, as a program that answered when the run began can
325
+ // have been taken away or changed since.
326
+ function startFailure(file, err, name = path.basename(file)) {
327
+ if (Object.hasOwn(SHORT, err.code)) return short(name, err);
328
+ return new Error(`easycaptionkit could not start ${name}, ${file}, again: ${worded(err)}. Check that it is still there and may be run, and run easycaptionkit again.`);
329
+ }
330
+
331
+ // ─── finding a program and asking it ──────────────────────────────────────
332
+
333
+ // A program a run looks for, as ffmpeg.js and chrome.js give it: { name,
334
+ // variable, args, answers, names, first, known, ask, none, advice }.
335
+ // `name` is what messages call it, `variable` the one that names it,
336
+ // `args` what asks its version and `answers(stdout)` whether what it
337
+ // printed then answers as it does, after it exited with 0. `names` are its
338
+ // names on PATH. Where the variable is not set, `first()` gives places
339
+ // looked at before PATH, each { file, name, where, next }, of which the
340
+ // first that is there is taken, and `known` folders looked in where PATH
341
+ // has nothing by its names, as package managers put it there for an agent
342
+ // or an app whose PATH is short. With `ask` false, a program is taken once
343
+ // nothing is in the way of running it, as Chrome on Windows, which prints
344
+ // no version. `none` is the message when there is none, and `advice` the
345
+ // sentence that ends one about a program the run cannot use: `variable`
346
+ // for one the variable names, `next` for ffprobe next to ffmpeg, `found`
347
+ // for the others, and `late` for one that gave no answer in time.
348
+
349
+ // Where a run found a program, in words that name its place and say what
350
+ // happened there.
351
+ function placed(place, words) {
352
+ if (place.variable && !bare(place.value)) return `${place.variable} points to ${place.file}, ${words.startsWith("where ") ? words : `which ${words}`}`;
353
+ if (place.variable) return `${place.variable} names ${place.value}, found on PATH at ${place.file}, which ${words}`;
354
+ return `the ${place.name} ${place.where}, ${place.file}, ${words}`;
355
+ }
356
+
357
+ // The error for a program a run cannot use: the message, and in `why` the
358
+ // words that name its place and what happened, as a note gives them.
359
+ function unusable(spec, place, words) {
360
+ const why = placed(place, words);
361
+ const advice = place.variable ? spec.advice.variable : place.next ? spec.advice.next : spec.advice.found;
362
+ return Object.assign(new Error(`easycaptionkit needs ${spec.name}, and ${why}. ${advice}`), { unusable: true, why });
363
+ }
364
+
365
+ // Whether what a program did when asked its version answers as `spec`.
366
+ const answered = (spec, r) => !r.err && !r.late && r.code === 0 && !r.signal && spec.answers(r.stdout);
367
+
368
+ // The places in turn: what keeps each from running, then, unless `spec`
369
+ // says not to, what it did when asked its version. The first that answers
370
+ // is { found: place, stdout }. A place `alone`, the one a variable or a
371
+ // place before PATH gives, is the only one, and what is wrong with it is
372
+ // said. Of places on PATH, the system's search passes over some (see
373
+ // barred() and PASSED), and the first of those is given as `passed`; at
374
+ // any other the search stops, with what is wrong there.
375
+ async function walk(spec, places, limit, { alone = false } = {}) {
376
+ let passed = null;
377
+ for (const place of places) {
378
+ let seen = barred(place.file);
379
+ if (!seen) {
380
+ if (spec.ask === false) return { found: place, stdout: "" };
381
+ const r = await ask(place.file, spec.args, { limit, cap: 1 << 16 });
382
+ if (r.err && Object.hasOwn(SHORT, r.err.code)) throw short(spec.name, r.err);
383
+ if (r.late) throw late(spec, place, "its version", limit);
384
+ if (answered(spec, r)) return { found: place, stdout: r.stdout };
385
+ // A file taken away or changed since it was looked at is said to be
386
+ // what it is now.
387
+ if (r.err) seen = barred(place.file) || { on: PASSED.has(codeOf(r.err)) ? "passes" : "stops", words: `did not run as ${spec.name}: ${unstartable(place.file, r.err)}` };
388
+ else seen = { on: "stops", words: `did not run as ${spec.name}: ${happened(r)}` };
389
+ }
390
+ if (alone || seen.on === "stops") throw unusable(spec, place, seen.words);
391
+ if (seen.on === "passes" && !passed) passed = { place, words: seen.words };
392
+ }
393
+ return { passed };
394
+ }
395
+
396
+ // The places a program is looked for on PATH, by each of its names, and
397
+ // in the folders `known` where PATH has nothing by them.
398
+ function onPathPlaces(spec, value) {
399
+ const names = value ? [value, ...(WINDOWS ? [exe(value)] : [])] : spec.names;
400
+ const named = value ? { variable: spec.variable, value } : {};
401
+ return names.flatMap((name) => folders().map((dir) => ({ file: path.join(dir, name), name, where: "on PATH", ...named })));
402
+ }
403
+
404
+ const knownPlaces = (spec) => (WINDOWS ? [] : (spec.known || []).flatMap((dir) => spec.names.map((name) => ({ file: path.join(dir, name), name, where: `in ${dir}` }))));
405
+
406
+ // The program a run uses, found and asked its version before any work, as
407
+ // { file, stdout, place }: the path its variable gives, as it is, or the
408
+ // first program by its name on PATH the system would start; with the
409
+ // variable unset, the first of the places before PATH that is there, else
410
+ // the first on PATH, else one in the known folders. It is taken only when
411
+ // it exits with 0 and its answer is one `spec` takes; otherwise the run
412
+ // stops before any work, with what is wrong with it, what the system said
413
+ // or what it printed (see unusable()), and when there is none, with
414
+ // `spec.none`. Every run checks the programs it uses with this first.
415
+ async function find(spec, limit = LIMIT) {
416
+ const value = process.env[spec.variable] || "";
417
+ const through = async (places, options) => {
418
+ const { found, stdout, passed } = await walk(spec, places, limit, options);
419
+ return found ? { file: found.file, stdout, place: found } : { passed };
420
+ };
421
+ if (value && !bare(value)) return through([{ file: path.resolve(value), variable: spec.variable, value }], { alone: true });
422
+ if (!value) {
423
+ const first = (spec.first ? spec.first() : []).find((place) => fs.existsSync(place.file));
424
+ if (first) return through([first], { alone: true });
425
+ }
426
+ let result = await through(onPathPlaces(spec, value));
427
+ if (!result.file && !result.passed && !value) result = await through(knownPlaces(spec));
428
+ if (result.file) return result;
429
+ if (result.passed) throw unusable(spec, result.passed.place, result.passed.words);
430
+ if (value) throw Object.assign(new Error(`easycaptionkit needs ${spec.name}, and ${spec.variable} names ${value}, which is not on PATH. ${spec.advice.variable}`), { unusable: true, why: `${spec.variable} names ${value}, which is not on PATH` });
431
+ throw Object.assign(new Error(spec.none), { unusable: true, why: `no ${spec.name} was found` });
432
+ }
433
+
434
+ // The file a run would take for a program by what is at each place alone,
435
+ // not run, or "": the path its variable gives, as it is, else as find()
436
+ // looks, where nothing keeps a file from running. ffprobe is looked for
437
+ // next to it, where a run uses ffprobe alone.
438
+ function locate(spec) {
439
+ const value = process.env[spec.variable] || "";
440
+ if (value && !bare(value)) return path.resolve(value);
441
+ if (!value) {
442
+ const first = (spec.first ? spec.first() : []).find((place) => fs.existsSync(place.file));
443
+ if (first) return first.file;
444
+ }
445
+ // The first place nothing keeps from running, as { file }, else whether
446
+ // the search stopped at one or passed over one.
447
+ const look = (places) => {
448
+ let passed = false;
449
+ for (const place of places) {
450
+ const seen = barred(place.file);
451
+ if (!seen) return { file: place.file };
452
+ if (seen.on === "stops") return { stopped: true };
453
+ if (seen.on === "passes") passed = true;
454
+ }
455
+ return { passed };
456
+ };
457
+ const onPath = look(onPathPlaces(spec, value));
458
+ if (onPath.file) return onPath.file;
459
+ if (onPath.stopped || onPath.passed || value) return "";
460
+ return look(knownPlaces(spec)).file || "";
461
+ }
462
+
463
+ // What the program found at `place` printed when run with `args` after it
464
+ // answered its version, as asked about `what` ("its encoders"): its
465
+ // output, read only once it ended well. One that fails or is stopped
466
+ // stops the run with how it ended, so a program that cannot answer is not
467
+ // taken for one without the encoder or the filter asked for, and one that
468
+ // gives no answer in time stops it as late() says.
469
+ async function probe(spec, place, args, what, limit = LIMIT) {
470
+ const r = await ask(place.file, args, { limit });
471
+ const asked = args.filter((arg) => arg !== "-hide_banner").join(" ");
472
+ if (r.err && Object.hasOwn(SHORT, r.err.code)) throw short(spec.name, r.err);
473
+ if (r.late) throw late(spec, place, `${what} (${asked})`, limit);
474
+ if (!r.err && r.code === 0 && !r.signal) return r.stdout;
475
+ throw unusable(spec, place, `failed when asked ${what} (${asked}): ${r.err ? unstartable(place.file, r.err) : happened(r)}`);
476
+ }
477
+
478
+ module.exports = { exe, enterable, onPath, quoted, happened, unstartable, find, locate, probe, startFailure, LIMIT };
@@ -13,6 +13,7 @@ const { launch, openPage, evaluate } = require("./chrome");
13
13
  const { cueLines } = require("./cues");
14
14
  const { inkOf } = require("./places");
15
15
  const { writeApng } = require("./apng");
16
+ const { clusters } = require("./text");
16
17
 
17
18
  // The cue's lines as words, each with whether a space goes before it.
18
19
  const lineWords = cueLines;
@@ -198,11 +199,14 @@ function pageHtml(look, place, style, data) {
198
199
 
199
200
  // Draws every state of the timeline to PNGs in `dir` and puts them in one
200
201
  // animated PNG. Returns { file, y, height, reach, states, fonts, first,
201
- // failedFonts, shrunk, scales }: the band the PNGs cover, the rows the
202
- // captions reach in it, how many states there are, which fonts drew the
203
- // letters and which of them is the first of the list this machine has,
204
- // and how many captions had to be drawn smaller to fit.
205
- async function renderStates({ cues, words, states, style, look, place, duration, dir, log = () => {} }) {
202
+ // failedFonts, shrunk, scales, boxes }: the band the PNGs cover, the rows
203
+ // the captions reach in it, how many states there are, which fonts drew
204
+ // the letters and which of them is the first of the list this machine
205
+ // has, how many captions had to be drawn smaller to fit, and the letters
206
+ // Chrome drew as boxes. `boxed(letters, fonts)` gives those, of the
207
+ // letters of the captions and the fonts Chrome names for them
208
+ // (chromeBoxes); without it, none are.
209
+ async function renderStates({ cues, words, states, style, look, place, duration, dir, log = () => {}, boxed = null }) {
206
210
  const W = place.frame.width;
207
211
  const H = place.frame.height;
208
212
  const ink = place.ink || inkOf(place.fontPx, { style, box: look.box });
@@ -248,7 +252,13 @@ async function renderStates({ cues, words, states, style, look, place, duration,
248
252
  })()`,
249
253
  60_000,
250
254
  );
251
- const fonts = await fontsOf(page, probeText);
255
+ const named = await fontsOf(page, probeText);
256
+ // A letter no font Chrome found has is a box, which Chrome says the
257
+ // first font of the list drew. Those letters are left out when it is
258
+ // asked again, so the fonts it names drew the letters that show.
259
+ const boxes = boxed ? boxed([...new Set(probeText.split("\n").flatMap((word) => clusters(word)))], named) : [];
260
+ const rest = boxes.length ? withoutLetters(probeText, new Set(boxes)) : probeText;
261
+ const fonts = !boxes.length ? named : rest ? await fontsOf(page, rest) : [];
252
262
  // The first font of the list this machine has: the one that draws
253
263
  // plain Latin letters. The others draw only what it lacks.
254
264
  const latin = await fontsOf(page, "Hamburgefonstiv");
@@ -305,12 +315,21 @@ async function renderStates({ cues, words, states, style, look, place, duration,
305
315
  const shrunk = all.filter((s) => s < 1).length;
306
316
  // The rows every state stays in, its outline and shadow included.
307
317
  const reach = Number.isFinite(fit.top) ? { top: Math.max(0, Math.floor(fit.top)), bottom: Math.min(H, Math.ceil(fit.bottom)) } : null;
308
- return { file: animation, y, height, reach, states: made.size + 1, fonts, first: latin.length ? latin[0].family : "", failedFonts: loaded || [], shrunk, smallest: all.reduce((a, b) => Math.min(a, b), 1), scales: fit.scales };
318
+ return { file: animation, y, height, reach, states: made.size + 1, fonts, first: latin.length ? latin[0].family : "", failedFonts: loaded || [], shrunk, smallest: all.reduce((a, b) => Math.min(a, b), 1), scales: fit.scales, boxes };
309
319
  } finally {
310
320
  await browser.close();
311
321
  }
312
322
  }
313
323
 
324
+ // The words of the probe, one a line, without the letters of `left`: a
325
+ // word of those letters alone goes.
326
+ const withoutLetters = (text, left) =>
327
+ text
328
+ .split("\n")
329
+ .map((word) => clusters(word).filter((c) => !left.has(c)).join(""))
330
+ .filter(Boolean)
331
+ .join("\n");
332
+
314
333
  // Which fonts Chrome draws a text with, and how many letters each. Chrome
315
334
  // says so only of text it has laid out, and a text just set is laid out at
316
335
  // the next frame: the probe is laid out in the same step its text is set