easycaptionkit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1395 @@
1
+ "use strict";
2
+
3
+ // A whole run. Everything is made in a work folder inside the output
4
+ // folder and moved into place only once all of it is made, so a run that
5
+ // fails leaves the folder as it was: no half written video, no subtitles
6
+ // from one run next to a video from another.
7
+
8
+ const fs = require("node:fs");
9
+ const os = require("node:os");
10
+ const path = require("node:path");
11
+ const crypto = require("node:crypto");
12
+ const { loadWords, handFixed, wordsSha256, FORMATS, MOST_OPENED, DEEPEST } = require("./words");
13
+ const { cjkLanguage, arabicIn, scriptsIn } = require("./text");
14
+ const { buildCues, states: makeStates, readingSpeed } = require("./cues");
15
+ const { srt, vtt, ass, assFont, estimatedScales, shrunkOf, assLetters, SRT_SPECIAL } = require("./subtitles");
16
+ const { captionLayout, charsThatFit, defaultPlaces, shapeOf } = require("./places");
17
+ const { chooseLook, variableFace, machineFont, fallbackFont } = require("./look");
18
+ const { fontLike } = require("./fontfile");
19
+ const ffmpeg = require("./ffmpeg");
20
+ const { findChrome, missingChrome, chromeNamedNothing, tempProblem } = require("./chrome");
21
+ const { renderStates } = require("./render");
22
+ const { safeZonesPng, sheetPng, plainPictures, shotOf } = require("./draw");
23
+ const { captionsMarkdown, captionsJson, subtitlesMarkdown, arg, subtitlesCommand, shown } = require("./report");
24
+ const { jsonShape, jsonProblem, decodeText } = require("./files");
25
+ const { stopAll } = require("./running");
26
+ const version = require("./version");
27
+
28
+ const TOOL = `easycaptionkit ${version}`;
29
+ // Every file a run writes; anything else in the folder is left alone.
30
+ const OUTPUTS = ["captioned.mp4", "captioned.timeline.json", "captions.srt", "captions.vtt", "captions.ass", "words.json", "captions.json", "sheet.png", "safe-zones.png", "CAPTIONS.md"];
31
+ // The files only a captioning run writes, next to the subtitle files.
32
+ const CAPTION_ONLY = ["captioned.mp4", "captioned.timeline.json", "sheet.png", "safe-zones.png"];
33
+ const SUBTITLE_OUTPUTS = OUTPUTS.filter((name) => !CAPTION_ONLY.includes(name));
34
+
35
+ // A file's name as the name of a folder: its letters and digits in any
36
+ // script, lower case, the rest as dashes. Accents come off Latin letters
37
+ // only (caf\u00e9, \u015fark\u0131); in other scripts the marks on a letter are part of
38
+ // it, as the voiced mark of a Japanese kana and the vowel signs of Hindi
39
+ // are, and a Korean syllable stays one letter, so the name a run prints is
40
+ // the one a person types. A name of more than 60 letters or 180 bytes is
41
+ // cut and ends in a short hash of the whole name, so two such files never
42
+ // share a folder, and easycaptionkit-subtitles- before it and -2 after it
43
+ // stay within the 255 bytes a disk takes for a name.
44
+ function slug(file) {
45
+ const name = path.basename(file).replace(/\.[^.]+$/, "").normalize("NFC");
46
+ const plain = (text) => text.normalize("NFKD").replace(/(\p{Script=Latin})\p{M}+/gu, "$1");
47
+ const words = plain(plain(name).toLowerCase())
48
+ .normalize("NFC")
49
+ // Anything else is a dash, with any mark on it: a mark is kept only on
50
+ // a letter or a digit.
51
+ .replace(/(?:[^\p{L}\p{N}\p{M}]\p{M}*)+/gu, "-")
52
+ .replace(/^\p{M}+/u, "")
53
+ .replace(/^-+|-+$/g, "");
54
+ if (!words) return "video";
55
+ const bytes = (text) => Buffer.byteLength(text, "utf8");
56
+ const letters = Array.from(words);
57
+ if (letters.length <= 60 && bytes(words) <= 180) return words;
58
+ let cut = "";
59
+ for (const letter of letters.slice(0, 53)) {
60
+ if (bytes(cut + letter) > 172) break;
61
+ cut += letter;
62
+ }
63
+ return `${cut.replace(/-+$/, "")}-${crypto.createHash("sha1").update(name).digest("hex").slice(0, 6)}`;
64
+ }
65
+
66
+ // The folder as a message names it: "this folder" for the one the run is
67
+ // in.
68
+ const named = (dir) => (path.resolve(dir) === process.cwd() ? "this folder" : shown(dir));
69
+
70
+ const list = (items) => (items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`);
71
+
72
+ // The folder can be made and written, checked before any work: the
73
+ // nearest folder that exists has to be one, and writable. A link on the
74
+ // way to a folder that is not there is said so, as musicfit says it, and
75
+ // so is a name longer than the disk takes, where making the folder would
76
+ // fail at the end of the work. (`paths`, `exists` and `lstat` stand in for
77
+ // Windows in the tests.)
78
+ function writable(dir, { paths = path, exists = fs.existsSync, lstat = (at) => fs.lstatSync(at, { throwIfNoEntry: false }) } = {}) {
79
+ let at = paths.resolve(dir);
80
+ // A folder on the way, named as the --out names it.
81
+ const named = (folder) => (folder === paths.resolve(dir) ? "it" : paths.isAbsolute(dir) ? folder : paths.relative(process.cwd(), folder) || folder);
82
+ while (!exists(at)) {
83
+ let s = null;
84
+ try {
85
+ s = lstat(at);
86
+ } catch (err) {
87
+ if (err.code === "ENAMETOOLONG") throw new Error(`Cannot write to ${dir}: a name in it is longer than the disk takes`);
88
+ // Else a file on the way, which is told below.
89
+ }
90
+ if (s && s.isSymbolicLink()) throw new Error(`Cannot write to ${dir}: ${named(at)} is a link to ${fs.readlinkSync(at)}, which is not there`);
91
+ const up = paths.dirname(at);
92
+ // A drive or network share that is not there is its own parent.
93
+ if (up === at) throw new Error(`Cannot write to ${dir}: the drive or share is not there`);
94
+ at = up;
95
+ }
96
+ try {
97
+ if (!fs.statSync(at).isDirectory()) throw Object.assign(new Error(`${at} is a file`), { code: "ENOTDIR" });
98
+ fs.accessSync(at, fs.constants.W_OK);
99
+ } catch (err) {
100
+ const why = { EACCES: "permission denied", EPERM: "permission denied", EROFS: "the disk is read-only", ENOTDIR: "a file is in the way" }[err.code] || err.message;
101
+ throw new Error(`Cannot write to ${dir}: ${why}`);
102
+ }
103
+ }
104
+
105
+ function mustExist(file, what) {
106
+ if (!fs.existsSync(file)) throw new Error(`Cannot read ${file}: no such ${what}`);
107
+ }
108
+
109
+ // A name like https://example.com/demo.mp4. easycaptionkit reads files,
110
+ // so an input given as an address that is no file is refused before any
111
+ // work, as musicfit refuses one, where it was taken for a file that is not
112
+ // there. A video or words can be downloaded; a brand kit or a font kit is
113
+ // a folder that easybrandkit or easyfontkit makes from a site, which a
114
+ // download of the address is not, so the refusal names the kit. `inputs`
115
+ // are { video, words, brand, fonts }, in the order they are told; a dry
116
+ // run tells them too, from their names and whether a file has one.
117
+ const ADDRESS = /^[a-z][a-z0-9+.-]*:\/\//i;
118
+ const KIT_OF = { brand: ["--brand", "an easybrandkit folder or its brand.json", "easybrandkit"], fonts: ["--fonts", "an easyfontkit folder or its fonts.css", "easyfontkit"] };
119
+ function notAddresses(inputs) {
120
+ for (const [role, name] of Object.entries(inputs)) {
121
+ if (!name || !ADDRESS.test(name) || fs.existsSync(name)) continue;
122
+ if (Object.hasOwn(KIT_OF, role)) {
123
+ const [flag, takes, kit] = KIT_OF[role];
124
+ throw new Error(`Cannot read ${name}: ${flag} takes ${takes}, so make one from that site with ${kit} first`);
125
+ }
126
+ throw new Error(`Cannot read ${name}: easycaptionkit reads files, not addresses, so download it first`);
127
+ }
128
+ }
129
+
130
+ const statOf = (file) => {
131
+ try {
132
+ return fs.statSync(file, { bigint: true });
133
+ } catch {
134
+ return null;
135
+ }
136
+ };
137
+
138
+ // What a name of the folder is itself: a link is not followed.
139
+ const lstatOf = (file) => {
140
+ try {
141
+ return fs.lstatSync(file, { bigint: true, throwIfNoEntry: false }) || null;
142
+ } catch {
143
+ return null;
144
+ }
145
+ };
146
+
147
+ // A file's SHA-256, read in pieces, so a long video takes no more memory
148
+ // than a short one.
149
+ function sha256Of(file) {
150
+ const hash = crypto.createHash("sha256");
151
+ const fd = fs.openSync(file, "r");
152
+ try {
153
+ const chunk = Buffer.allocUnsafe(1 << 20);
154
+ let n;
155
+ while ((n = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0) hash.update(chunk.subarray(0, n));
156
+ } finally {
157
+ fs.closeSync(fd);
158
+ }
159
+ return hash.digest("hex");
160
+ }
161
+
162
+ // A JSON file of the folder as easycaptionkit wrote it: { data } when it
163
+ // says easycaptionkit wrote it, { problem } when it is no longer JSON (a
164
+ // quote typed by hand can do that), else {}. Only a file of the size such
165
+ // a file has is read, and never a pipe: a run of the most words a run
166
+ // takes (words.js) writes its captions.json in some 40 MB. A text of more
167
+ // lists and objects than a words file may hold, or nested deeper, is no
168
+ // file a run wrote, and is not parsed.
169
+ function readOwn(file) {
170
+ let text;
171
+ try {
172
+ const stat = fs.statSync(file);
173
+ if (!stat.isFile() || stat.size > 64 * 1024 * 1024) return {};
174
+ text = decodeText(fs.readFileSync(file)).replace(/^\ufeff/, "");
175
+ } catch {
176
+ return {};
177
+ }
178
+ if (/^\s*[{[]/.test(text) && jsonShape(text, { most: MOST_OPENED, deepest: DEEPEST }).over) return {};
179
+ let data;
180
+ try {
181
+ data = JSON.parse(text);
182
+ } catch (err) {
183
+ return { problem: jsonProblem(err, text) };
184
+ }
185
+ // A tool named by anything but text is none: an object with a toString of
186
+ // its own would stop the run with an error that names no file.
187
+ return data && typeof data === "object" && typeof data.tool === "string" && /^easycaptionkit\b/.test(data.tool) ? { data } : {};
188
+ }
189
+
190
+ const ours = (file) => Boolean(readOwn(file).data);
191
+
192
+ const timelineOf = (videoFile) => videoFile.replace(/\.[^./\\]+$/, "") + ".timeline.json";
193
+
194
+ // The most a launch-film timeline holds, a few KB for a film of a hundred
195
+ // clips, with room to spare: a file far bigger by that name is something
196
+ // else, and is not copied.
197
+ const MOST_TIMELINE = 4 * 1024 * 1024;
198
+
199
+ // A file's bytes, read only while they are no more than `most`: { bytes },
200
+ // { big: true } when it holds more, or { error } when it cannot be read.
201
+ function bytesOf(file, most) {
202
+ let fd;
203
+ try {
204
+ fd = fs.openSync(file, "r");
205
+ const chunks = [];
206
+ const chunk = Buffer.allocUnsafe(1 << 16);
207
+ let total = 0;
208
+ for (let n; (n = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0; ) {
209
+ total += n;
210
+ if (total > most) return { big: true };
211
+ chunks.push(Buffer.from(chunk.subarray(0, n)));
212
+ }
213
+ return { bytes: Buffer.concat(chunks) };
214
+ } catch (err) {
215
+ return { error: err };
216
+ } finally {
217
+ if (fd !== undefined) fs.closeSync(fd);
218
+ }
219
+ }
220
+
221
+ // A size in bytes as people say it: 4.2 MB, 2.1 GB.
222
+ const bytesText = (size) => (size >= 2 ** 30 ? `${(size / 2 ** 30).toFixed(1)} GB` : `${(size / 2 ** 20).toFixed(1)} MB`);
223
+
224
+ // The files behind each input: a brand or font folder stands for the
225
+ // files read from it.
226
+ function filesOf(role, given) {
227
+ const folder = statOf(given) && statOf(given).isDirectory();
228
+ if (role === "brand") return folder ? ["brand.json", "_brand.yml", "brand/brand.json"].map((name) => path.join(given, name)) : [given];
229
+ if (role === "fonts") {
230
+ const css = folder ? path.join(given, "fonts.css") : given;
231
+ return [css, path.join(path.dirname(css), "fonts.json")];
232
+ }
233
+ return [given];
234
+ }
235
+
236
+ const SHA256 = /^[0-9a-f]{64}$/;
237
+
238
+ // The files an earlier run says it wrote, with their sizes and SHA-256:
239
+ // { name: { size, sha256 } }. captions.json lists every other file its run
240
+ // wrote, words.json every one but captions.json.
241
+ function filesSaid(data) {
242
+ const files = data && data.files;
243
+ if (!files || typeof files !== "object" || Array.isArray(files)) return {};
244
+ return Object.fromEntries(Object.entries(files).filter(([name, f]) => OUTPUTS.includes(name) && f && typeof f === "object" && Number.isSafeInteger(f.size) && f.size >= 0 && typeof f.sha256 === "string" && SHA256.test(f.sha256)));
245
+ }
246
+
247
+ // What a run writes in captions.json and words.json: the files it wrote
248
+ // before them, each with its size and SHA-256, so the next run into the
249
+ // folder knows them, and knows them changed even when a hand edit keeps
250
+ // the size.
251
+ function writtenFiles(dir, names) {
252
+ return Object.fromEntries(names.filter((name) => name !== "captions.json").map((name) => [name, { size: fs.statSync(path.join(dir, name)).size, sha256: sha256Of(path.join(dir, name)) }]));
253
+ }
254
+
255
+ // Checked before any work: a run replaces the files it writes in the
256
+ // folder, so none of them may be a file the run reads (through a link, a
257
+ // hard link or another letter case too), or a file by one of those names
258
+ // that easycaptionkit did not write. captions.json and words.json say
259
+ // easycaptionkit wrote them, and each names the other files its run
260
+ // wrote, with their sizes and SHA-256: a file is an earlier run's only
261
+ // when one of them names it and it is still what that run wrote. The
262
+ // kit writes no links, so a link by one of the names is no earlier run's
263
+ // either, even one to nothing. The one file read and written over is a
264
+ // words.json from an earlier run, fixed by hand; a run that does not read
265
+ // it does not replace the words fixed in it, and says how to keep them. A
266
+ // subtitles run leaves the captioning files alone, but does not go into a
267
+ // folder that holds what an earlier run captioned, whose captions its
268
+ // files would no longer match; when the words there were fixed by hand,
269
+ // it says how to make subtitles of them in a folder of their own.
270
+ // `options` are a subtitles run's, for the commands it gives.
271
+ //
272
+ // Returns what each name of the folder was then, { stat, sha256 } or
273
+ // null, so that the run can see, when it puts its files in place, that
274
+ // none came in or changed since.
275
+ function guard(dir, inputs, { subtitles = false, options = {} } = {}) {
276
+ const at = (name) => path.join(dir, name);
277
+ const where = named(dir);
278
+ const stats = new Map(OUTPUTS.map((name) => [name, lstatOf(at(name))]));
279
+ const existing = OUTPUTS.filter((name) => stats.get(name)).map((name) => ({ name, stat: stats.get(name) }));
280
+ const sums = new Map();
281
+ const view = () => new Map(OUTPUTS.map((name) => [name, stats.get(name) ? { stat: stats.get(name), sha256: sums.get(name) || "" } : null]));
282
+ // The files this run writes over or takes away.
283
+ const touched = existing.filter((o) => !subtitles || SUBTITLE_OUTPUTS.includes(o.name));
284
+ for (const o of touched) {
285
+ if (o.stat.isDirectory()) throw new Error(`${shown(at(o.name))} is a folder, and this run writes a file by that name. Pick another --out`);
286
+ if (o.stat.isSymbolicLink()) throw new Error(`${shown(at(o.name))} is a link, and this run writes a file by that name. Pick another --out, or move ${o.name} away`);
287
+ if (!o.stat.isFile()) throw new Error(`${shown(at(o.name))} is not a file, and this run writes a file by that name. Pick another --out`);
288
+ }
289
+ let readsOwnWords = false;
290
+ for (const [role, given] of Object.entries(inputs)) {
291
+ if (!given) continue;
292
+ for (const file of filesOf(role, given)) {
293
+ const s = statOf(file);
294
+ if (!s) continue;
295
+ const same = touched.find((o) => oneFile(o.stat, at(o.name), s, file));
296
+ if (!same) continue;
297
+ if (role === "words" && same.name === "words.json") {
298
+ const own = readOwn(at("words.json"));
299
+ if (own.problem) throw new Error(`Cannot read ${shown(file)}: it is not valid JSON${own.problem}. Fix it there and run again`);
300
+ if (own.data) {
301
+ readsOwnWords = true;
302
+ continue;
303
+ }
304
+ }
305
+ throw new Error(`${shown(file)} is the ${same.name} this run would write in ${where}. Pick another --out`);
306
+ }
307
+ }
308
+ if (!touched.length) return view();
309
+ // What the earlier run's captions.json and words.json say of each file.
310
+ const said = new Map();
311
+ const own = {};
312
+ for (const name of ["captions.json", "words.json"]) {
313
+ if (!existing.some((o) => o.name === name)) continue;
314
+ own[name] = readOwn(at(name));
315
+ if (own[name].problem) throw new Error(`${where} holds a ${name} that is not valid JSON${own[name].problem}, so it cannot say easycaptionkit wrote it. Fix it or move it away, or pick another --out`);
316
+ if (!own[name].data) throw new Error(`${where} already holds a ${name} that easycaptionkit did not write, and this run would replace it. Pick another --out, or move ${name} away`);
317
+ for (const [file, f] of Object.entries(filesSaid(own[name].data))) said.set(file, [...(said.get(file) || []), f]);
318
+ }
319
+ const sumOf = (o) => {
320
+ if (!sums.has(o.name)) sums.set(o.name, sha256Of(at(o.name)));
321
+ return sums.get(o.name);
322
+ };
323
+ // The SHA-256 is read only of a file whose size is the one written.
324
+ const made = (o) => o.stat.isFile() && (said.get(o.name) || []).some((f) => f.size === Number(o.stat.size) && f.sha256 === sumOf(o));
325
+ const fixedHere = Boolean(own["words.json"]) && handFixed(own["words.json"].data);
326
+ // A subtitles run goes into no folder of what an earlier run captioned,
327
+ // whatever its words: that comes first, and says how to make subtitles
328
+ // of the words fixed there.
329
+ if (subtitles) {
330
+ const captioned = existing.filter((o) => CAPTION_ONLY.includes(o.name) && made(o)).map((o) => o.name);
331
+ if (captioned.length) {
332
+ const words = at("words.json");
333
+ const base = `easycaptionkit-subtitles-${slug(options.video || words)}`;
334
+ const elsewhere = defaultFolder(base, options.video ? { kind: "video", file: options.video } : { kind: "words", file: words }, words).dir;
335
+ // The step in words, then its command, as CAPTIONS.md gives it, so it
336
+ // still reads as a call where the kit's flags are a tool's parameters.
337
+ const keep = fixedHere ? `. To make them from the words fixed by hand there, run subtitles with ${arg(words)} as the words file, the same ${options.video ? "--video and " : ""}options and --out ${arg(elsewhere)}: ${subtitlesCommand(options, words, elsewhere)}` : "";
338
+ if (captioned.includes("captioned.mp4")) throw new Error(`${where} holds the captioned video of an earlier run, and new subtitle files next to it would not match its captions. Pick another --out${keep}`);
339
+ throw new Error(`${where} holds the ${list(captioned)} of an earlier captioning run, and new subtitle files next to ${captioned.length === 1 ? "it" : "them"} would not match its captions. Pick another --out${keep}`);
340
+ }
341
+ }
342
+ // The words of a person's fix are kept unless the run reads them.
343
+ // The command that keeps them ends the message, so all that follows its
344
+ // colon is the command.
345
+ if (fixedHere && !readsOwnWords) {
346
+ const fixed = at("words.json");
347
+ const again = subtitles ? `run subtitles again with ${arg(fixed)} as the words file, the same ${options.video ? "--video and " : ""}options and --out ${arg(dir)}: ${subtitlesCommand(options, fixed, dir)}` : `run the same command with --words ${arg(fixed)} --out ${arg(dir)}`;
348
+ throw new Error(`${shown(fixed)} holds words fixed by hand, and this run would replace them with the words of ${shown(path.basename(inputs.words))}. To start again from ${shown(path.basename(inputs.words))}, move ${shown(fixed)} away. To keep the fixes, ${again}`);
349
+ }
350
+ for (const o of touched) {
351
+ if (o.name === "captions.json" || o.name === "words.json" || made(o)) continue;
352
+ if (said.has(o.name)) throw new Error(`${where} holds a ${o.name} that has changed since an earlier easycaptionkit run wrote it, and this run would replace it. Pick another --out, or move ${o.name} away`);
353
+ throw new Error(`${where} already holds ${o.name}, and no captions.json or words.json from an earlier easycaptionkit run says it made it. This run would replace it, so pick another --out, or move ${o.name} away`);
354
+ }
355
+ // captions.json and words.json were read whole, and are known by their
356
+ // content as the others are.
357
+ for (const o of touched) if ((o.name === "captions.json" || o.name === "words.json") && o.stat.isFile()) sumOf(o);
358
+ return view();
359
+ }
360
+
361
+ // How a name of the folder is not what the guard saw: "came" when a file
362
+ // came in by that name while the run was going, "changed" when the one
363
+ // there changed, else "". A file whose ctime alone moved had its mode, an
364
+ // attribute or a link changed, as a sync client or the Finder does to a
365
+ // file it uploads or opens: its content tells whether it changed.
366
+ function changedSince(view, dir, name) {
367
+ const now = lstatOf(path.join(dir, name));
368
+ if (!now) return "";
369
+ const then = view.get(name);
370
+ if (!then) return "came";
371
+ const s = then.stat;
372
+ if (s.dev !== now.dev || s.ino !== now.ino || s.size !== now.size || s.mtimeNs !== now.mtimeNs) return "changed";
373
+ if (s.ctimeNs === now.ctimeNs) return "";
374
+ return then.sha256 && now.isFile() && sha256Of(path.join(dir, name)) === then.sha256 ? "" : "changed";
375
+ }
376
+
377
+ // ─── which video a folder holds the captions of ───────────────────────────
378
+
379
+ // A file's whole path as the disk has it, links followed.
380
+ function realOf(file) {
381
+ try {
382
+ return fs.realpathSync(file);
383
+ } catch {
384
+ return path.resolve(file);
385
+ }
386
+ }
387
+
388
+ // Whether two files, each a stat and its name, are one: by the disk and
389
+ // the number there, which holds for a link and for another letter case on
390
+ // a disk that does not tell cases apart; by the whole path when the disk
391
+ // gives its files no numbers, as some network shares and FUSE drives do.
392
+ function oneFile(x, a, y, b) {
393
+ if (x.ino !== 0n && y.ino !== 0n) return x.dev === y.dev && x.ino === y.ino;
394
+ return realOf(a) === realOf(b);
395
+ }
396
+
397
+ // Whether two names are one file, as above, or by the whole path when one
398
+ // is gone.
399
+ function sameFile(a, b) {
400
+ const x = statOf(a);
401
+ const y = statOf(b);
402
+ return x && y ? oneFile(x, a, y, b) : realOf(a) === realOf(b);
403
+ }
404
+
405
+ // Where a file is, as captions.json says it: from the output folder, so
406
+ // the two can move together.
407
+ const fromFolder = (dir, file) => path.relative(realOf(dir), realOf(file)) || ".";
408
+
409
+ // What a run made its files from: the video, or for subtitles with no
410
+ // video the words file, where the words first came from when the run read
411
+ // the folder's own words.json. { kind, file } with the file's whole path,
412
+ // or null when an earlier run's captions.json does not say.
413
+ function sourceOf(json, dir) {
414
+ const of = (part) => (part && typeof part.from === "string" ? path.resolve(realOf(dir), part.from) : null);
415
+ if (json.video) return of(json.video) ? { kind: "video", file: of(json.video) } : null;
416
+ const words = json.words && json.words.origin && typeof json.words.origin.from === "string" ? json.words.origin : json.words;
417
+ return of(words) ? { kind: "words", file: of(words) } : null;
418
+ }
419
+
420
+ // Where the words of a run first came from, when it reads the folder's own
421
+ // words.json, fixed by hand: what the earlier run's captions.json says, so
422
+ // the folder keeps saying it. { file, from, format }, from the folder, or
423
+ // null.
424
+ function originOf(dir, wordsFile) {
425
+ if (!sameFile(wordsFile, path.join(dir, "words.json"))) return null;
426
+ const earlier = readOwn(path.join(dir, "captions.json")).data;
427
+ const words = earlier && earlier.words && typeof earlier.words === "object" ? earlier.words : null;
428
+ if (!words) return null;
429
+ const first = words.origin && typeof words.origin === "object" ? words.origin : words;
430
+ if (typeof first.file !== "string" || typeof first.from !== "string" || first.from === "words.json") return null;
431
+ return { file: first.file, from: first.from, ...(typeof first.format === "string" ? { format: first.format } : {}) };
432
+ }
433
+
434
+ // The format the words of a run first came in, by its name in FORMATS:
435
+ // the one a words.json of this kit names as its `from`, else, when the run
436
+ // reads the folder's own words.json, the one the earlier run's
437
+ // captions.json says, else the format of the file read. A demo's steps
438
+ // read again from their words.json are still a demo's steps.
439
+ function firstFormat(read, origin) {
440
+ const said = origin && typeof origin.format === "string" && origin.format !== "easycaptionkit" && Object.hasOwn(FORMATS, origin.format) ? origin.format : "";
441
+ return read.firstFormat || said || read.format;
442
+ }
443
+
444
+ // Whether an earlier run in `dir` made its files from `source` too. A
445
+ // subtitles run with no video from the folder's own words.json, the fixed
446
+ // words of that earlier run, is the same run made again; any other run
447
+ // is told by its video or its words file. An earlier run that does not
448
+ // say is taken to be the same, as it always was.
449
+ function sameSource(dir, source, wordsFile) {
450
+ const earlier = readOwn(path.join(dir, "captions.json")).data;
451
+ if (!earlier) return { same: true };
452
+ if (source.kind === "words" && sameFile(wordsFile, path.join(dir, "words.json"))) return { same: true };
453
+ const before = sourceOf(earlier, dir);
454
+ if (!before) return { same: true };
455
+ return { same: before.kind === source.kind && sameFile(before.file, source.file), before };
456
+ }
457
+
458
+ // The folder a run goes to when no --out is given: easycaptionkit-<name>,
459
+ // or, when that holds the captions of another file by the same name,
460
+ // easycaptionkit-<name>-2, -3 and on, so no run replaces another video's.
461
+ function defaultFolder(base, source, wordsFile) {
462
+ for (let n = 1; n < 100; n++) {
463
+ const dir = n === 1 ? base : `${base}-${n}`;
464
+ if (sameSource(dir, source, wordsFile).same) return { dir, note: n > 1 ? `${base} holds the captions of another ${shown(path.basename(source.file))}, so these are in ${dir}.` : "" };
465
+ }
466
+ return { dir: base, note: "" };
467
+ }
468
+
469
+ // The folder of a run, and a note when it replaces what an earlier run
470
+ // made from another file.
471
+ function outFolder(options, base, source, wordsFile) {
472
+ if (!options.out) return defaultFolder(base, source, wordsFile);
473
+ const { same, before } = sameSource(options.out, source, wordsFile);
474
+ const where = named(options.out) === "this folder" ? "This folder" : named(options.out);
475
+ return { dir: options.out, note: same ? "" : `${where} held the files an earlier run made from ${shown(fromFolder(process.cwd(), before.file))}, and this run replaced them.` };
476
+ }
477
+
478
+ // ─── the work folder ──────────────────────────────────────────────────────
479
+
480
+ const WORK = /^\.easycaptionkit-work-/;
481
+ const DAY = 24 * 3600 * 1000;
482
+ // The signals that stop a run, with the code it ends with: Ctrl+Break
483
+ // sends SIGBREAK on Windows.
484
+ const signalsOf = (platform) => ({ SIGINT: 130, SIGTERM: 143, SIGHUP: 129, ...(platform === "win32" ? { SIGBREAK: 149 } : {}) });
485
+ const SIGNALS = signalsOf(process.platform);
486
+
487
+ // The run that owns a work folder, while it is still going: its process
488
+ // is alive on this machine. A run on another machine (a shared folder)
489
+ // cannot be asked, so its folder counts as live for a day. An owner.json
490
+ // that is not the object a run writes, with its pid, host and start, as
491
+ // null or a host that is no text, names no run.
492
+ function liveOwner(work) {
493
+ let owner;
494
+ try {
495
+ owner = JSON.parse(fs.readFileSync(path.join(work, "owner.json"), "utf8"));
496
+ } catch {
497
+ return null;
498
+ }
499
+ if (!owner || typeof owner !== "object" || typeof owner.host !== "string" || typeof owner.started !== "string") return null;
500
+ if (!Number.isInteger(owner.pid) || !(Date.now() - Date.parse(owner.started) < DAY)) return null;
501
+ if (owner.host !== os.hostname()) return owner;
502
+ try {
503
+ process.kill(owner.pid, 0);
504
+ return owner;
505
+ } catch (err) {
506
+ return err.code === "EPERM" ? owner : null;
507
+ }
508
+ }
509
+
510
+ // What earlier runs left in the folder. A work folder another run is
511
+ // still using stops this one. One a stopped run left goes, once the
512
+ // earlier files it held, which that run could not put back, are back in
513
+ // the folder; one whose name the folder has again stops the run, and the
514
+ // message says where it is. Only a folder is a work folder: a link or a
515
+ // file by such a name is no run's, and stays where it is, as does what a
516
+ // link named .before leads to. Returns a note for each file put back.
517
+ function recover(dir, given) {
518
+ const notes = [];
519
+ if (!fs.existsSync(dir)) return notes;
520
+ const folder = (file) => Boolean(lstatOf(file) && lstatOf(file).isDirectory());
521
+ for (const name of fs.readdirSync(dir)) {
522
+ if (!WORK.test(name)) continue;
523
+ const work = path.join(dir, name);
524
+ if (!folder(work)) continue;
525
+ const owner = liveOwner(work);
526
+ if (owner) throw new Error(`Another easycaptionkit run (process ${owner.pid}${owner.host !== os.hostname() ? ` on ${owner.host}` : ""}) is writing to ${named(given)}. Wait for it to end, or pick another --out. If no run is going, remove ${shown(path.join(given, name))}`);
527
+ const before = path.join(work, ".before");
528
+ let kept = [];
529
+ try {
530
+ if (folder(before)) kept = fs.readdirSync(before).filter((file) => OUTPUTS.includes(file));
531
+ } catch {
532
+ // Nothing was kept.
533
+ }
534
+ const taken = kept.filter((file) => fs.lstatSync(path.join(dir, file), { throwIfNoEntry: false }));
535
+ if (taken.length) throw new Error(`${shown(path.join(given, name, ".before"))} holds the earlier ${list(taken)} that a run could not put back, and ${named(given)} has ${taken.length === 1 ? "a file by that name" : "files by those names"} again. Move ${taken.length === 1 ? "it" : "them"} where you want ${taken.length === 1 ? "it" : "them"}, remove ${shown(path.join(given, name))} and run again`);
536
+ for (const file of kept) {
537
+ fs.renameSync(path.join(before, file), path.join(dir, file));
538
+ notes.push(`The earlier ${file}, which a run could not put back, went back into ${named(given)} before this run started.`);
539
+ }
540
+ fs.rmSync(work, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
541
+ }
542
+ return notes;
543
+ }
544
+
545
+ // ─── a run stopped by a signal ────────────────────────────────────────────
546
+
547
+ // The runs of this process that began and have not ended; the folder, as
548
+ // given, of the last one that began, and whether it ended well; whether a
549
+ // signal has stopped them; and the listeners stopOnSignals added.
550
+ const going = new Set();
551
+ const last = { folder: null, done: false };
552
+ let stopping = false;
553
+ const watching = new Map();
554
+ // Whether the command line asked for JSON, as stopOnSignals was told.
555
+ let asJson = false;
556
+
557
+ // Written at once: a pipe can be asynchronous, and the process ends next.
558
+ function say(fd, text) {
559
+ try {
560
+ fs.writeSync(fd, text);
561
+ } catch {
562
+ // Nobody is reading.
563
+ }
564
+ }
565
+
566
+ // A run stopped with Ctrl-C or a kill ends the programs it started, and
567
+ // its work folder and the folders it made go, so the folder is as it was.
568
+ // It says so, on stdout too as JSON with --json, and ends by that signal,
569
+ // so a shell running the kit in a loop stops the loop too: bash goes on
570
+ // unless its program died by SIGINT. Windows has no such signals; there
571
+ // the code says it.
572
+ function stopBySignal(signal, { json = false } = {}) {
573
+ if (stopping) return;
574
+ stopping = true;
575
+ stopAll();
576
+ for (const run of [...going]) abandon(run);
577
+ const message = last.folder === null ? "stopped" : last.done ? "" : `stopped, so ${named(last.folder)} is as it was`;
578
+ if (message && json) say(1, JSON.stringify({ ok: false, error: message }) + "\n");
579
+ if (message) say(2, `easycaptionkit: ${message}\n`);
580
+ for (const [name, listener] of watching) process.removeListener(name, listener);
581
+ watching.clear();
582
+ if (process.platform !== "win32" && process.listenerCount(signal) === 0) {
583
+ process.kill(process.pid, signal);
584
+ // Only reached when the signal does not end the process.
585
+ setTimeout(() => process.exit(SIGNALS[signal] || 143), 500);
586
+ return;
587
+ }
588
+ process.exit(SIGNALS[signal] || 143);
589
+ }
590
+
591
+ // For the command line: a signal stops the run as above from before the
592
+ // run begins to the end of the process, so a stop that comes a moment
593
+ // after the run failed over it, as when Ctrl-C reaches ffmpeg first, is
594
+ // still said as a stop. Elsewhere each run listens while it goes.
595
+ function stopOnSignals({ json = false } = {}) {
596
+ asJson = json;
597
+ watchExit();
598
+ if (watching.size) return;
599
+ for (const signal of Object.keys(SIGNALS)) {
600
+ const listener = () => stopBySignal(signal, { json });
601
+ watching.set(signal, listener);
602
+ process.on(signal, listener);
603
+ }
604
+ }
605
+
606
+ // Whether a signal stopped this process's runs, and whether a run began.
607
+ const stopped = () => stopping;
608
+ const began = () => last.folder !== null;
609
+
610
+ // A process that ends while a run is going, by an error nothing caught or
611
+ // a process.exit from elsewhere, ends the programs the run started, and
612
+ // its work folder and the folders it made go, as when it fails: no ffmpeg
613
+ // goes on encoding into a folder no run will clear, and no Chrome profile
614
+ // stays. Both are done at once, since a process that exits waits for
615
+ // nothing. A process that ends with code 0 while a run is going had
616
+ // nothing left to wait for, as when a program the run waited on went away
617
+ // and nothing said so: the run did not end well, and that is said, with
618
+ // code 1.
619
+ let watchingExit = false;
620
+ function watchExit() {
621
+ if (watchingExit) return;
622
+ watchingExit = true;
623
+ process.on("exit", (code) => {
624
+ const runs = [...going];
625
+ stopAll();
626
+ for (const run of runs) abandon(run);
627
+ if (!runs.length || code || process.exitCode) return;
628
+ const message = `the run ended before it was done, so ${named(runs[runs.length - 1].given || runs[runs.length - 1].dir)} is as it was`;
629
+ if (asJson) say(1, JSON.stringify({ ok: false, error: message }) + "\n");
630
+ say(2, `easycaptionkit: ${message}\n`);
631
+ process.exitCode = 1;
632
+ });
633
+ }
634
+
635
+ // Starts a run's folder: made when missing, with a work folder in it that
636
+ // says which process owns it. If the run is stopped with Ctrl-C or a
637
+ // kill, the programs it started, its work folder and the folders it made
638
+ // go too, and the run ends by that signal.
639
+ function begin(given) {
640
+ const dir = path.resolve(given);
641
+ watchExit();
642
+ recover(dir, given);
643
+ // The first folder mkdir made, if it made any: a failed run removes
644
+ // what it made, up to there.
645
+ const made = fs.mkdirSync(dir, { recursive: true });
646
+ const work = workFolder(dir);
647
+ fs.writeFileSync(path.join(work, "owner.json"), JSON.stringify({ pid: process.pid, host: os.hostname(), started: new Date().toISOString() }) + "\n");
648
+ const run = { dir, given, made, work, keep: false };
649
+ going.add(run);
650
+ Object.assign(last, { folder: given, done: false });
651
+ if (!watching.size) {
652
+ run.stop = (signal) => stopBySignal(signal);
653
+ for (const signal of Object.keys(SIGNALS)) process.once(signal, run.stop);
654
+ }
655
+ return run;
656
+ }
657
+
658
+ // A run's work folder in `dir`, by a name no other run has: made with the
659
+ // default mode, so it takes the umask and, under a default ACL, the access
660
+ // the folder gives, as the files of the run do. mkdtemp's 0700 would shut
661
+ // out the others the ACL lets in, as a hosted worker that clears what a
662
+ // run killed outright left behind.
663
+ function workFolder(dir) {
664
+ for (let n = 1; ; n++) {
665
+ const work = path.join(dir, `.easycaptionkit-work-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
666
+ try {
667
+ fs.mkdirSync(work);
668
+ return work;
669
+ } catch (err) {
670
+ if (err.code !== "EEXIST" || n >= 20) throw err;
671
+ }
672
+ }
673
+ }
674
+
675
+ function release(run) {
676
+ going.delete(run);
677
+ if (run.stop) for (const signal of Object.keys(SIGNALS)) process.removeListener(signal, run.stop);
678
+ }
679
+
680
+ // The run's files, in a folder of their own next to the work folder, when
681
+ // they cannot go in place: easycaptionkit-new, or -2 and on when that is
682
+ // taken. Made with the folder's own access, as the output folder's files
683
+ // are.
684
+ function setAside(run, names) {
685
+ for (let n = 1; ; n++) {
686
+ const aside = path.join(run.dir, n === 1 ? "easycaptionkit-new" : `easycaptionkit-new-${n}`);
687
+ try {
688
+ fs.mkdirSync(aside);
689
+ } catch (err) {
690
+ if (err.code === "EEXIST" && n < 100) continue;
691
+ throw err;
692
+ }
693
+ for (const name of names) fs.renameSync(path.join(run.work, name), path.join(aside, name));
694
+ return aside;
695
+ }
696
+ }
697
+
698
+ // Moves the made files into place. What an earlier run wrote goes into the
699
+ // work folder first, so a file that cannot be moved (one a player holds
700
+ // open, say) puts everything back as it was. A captioning run also takes
701
+ // away what an earlier one made that this one does not, so nothing in the
702
+ // folder is from another run. An earlier file that cannot be put back
703
+ // stays in the work folder's .before, where the next run into the folder
704
+ // finds it; once the new files are in, .before is .replaced, which the
705
+ // next run only removes.
706
+ //
707
+ // `view` is what the guard saw of each name before the run started. A file
708
+ // that came in by one of them since, or one that changed, from a person,
709
+ // an editor's export or another tool while the encode ran, is no earlier
710
+ // run's: the folder is left as it is, and the new files go in a folder of
711
+ // their own, which the error names.
712
+ function finish(run, names, { stale = true, view = null } = {}) {
713
+ const how = view ? OUTPUTS.filter((name) => stale || names.includes(name)).map((name) => [name, changedSince(view, run.dir, name)]) : [];
714
+ const came = how.filter(([, h]) => h === "came").map(([name]) => name);
715
+ const changed = how.filter(([, h]) => h === "changed").map(([name]) => name);
716
+ if (came.length || changed.length) {
717
+ const aside = setAside(run, names);
718
+ const given = run.given || run.dir;
719
+ const what = [came.length ? `${list(came)} came into ${named(given)}` : "", changed.length ? `${list(changed)} changed ${came.length ? "there" : `in ${named(given)}`}` : ""].filter(Boolean).join(" and ");
720
+ throw new Error(`${what} while this run was going, so the run left the folder as it was. Its files are in ${shown(path.join(given, path.basename(aside)))}: move them where you want them, or run again`);
721
+ }
722
+ const before = path.join(run.work, ".before");
723
+ fs.mkdirSync(before);
724
+ const moved = [];
725
+ const placed = [];
726
+ try {
727
+ for (const name of OUTPUTS) {
728
+ if (!stale && !names.includes(name)) continue;
729
+ if (!fs.lstatSync(path.join(run.dir, name), { throwIfNoEntry: false })) continue;
730
+ fs.renameSync(path.join(run.dir, name), path.join(before, name));
731
+ moved.push(name);
732
+ }
733
+ for (const name of names) {
734
+ fs.renameSync(path.join(run.work, name), path.join(run.dir, name));
735
+ placed.push(name);
736
+ }
737
+ } catch (err) {
738
+ const left = [];
739
+ for (const name of placed.reverse()) {
740
+ try {
741
+ fs.renameSync(path.join(run.dir, name), path.join(run.work, name));
742
+ } catch {
743
+ // Replaced again from `before` below, or left as it is.
744
+ }
745
+ }
746
+ for (const name of moved.reverse()) {
747
+ try {
748
+ fs.rmSync(path.join(run.dir, name), { force: true });
749
+ fs.renameSync(path.join(before, name), path.join(run.dir, name));
750
+ } catch {
751
+ left.push(name);
752
+ }
753
+ }
754
+ // An earlier file that could not go back stays in the work folder.
755
+ if (left.length) run.keep = true;
756
+ throw new Error(`Could not move the new files into ${shown(run.dir)}: ${err.message}. ${left.length ? `The earlier ${left.join(", ")} could not be put back and ${left.length === 1 ? "is" : "are"} in ${shown(before)}, where the next run into the folder puts ${left.length === 1 ? "it" : "them"} back` : "The folder is as it was"}`);
757
+ }
758
+ release(run);
759
+ if (last.folder === run.given) last.done = true;
760
+ try {
761
+ fs.renameSync(before, path.join(run.work, ".replaced"));
762
+ fs.rmSync(run.work, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
763
+ } catch {
764
+ // The files are in place; the next run into this folder removes it.
765
+ }
766
+ }
767
+
768
+ function abandon(run) {
769
+ release(run);
770
+ if (!run.keep) {
771
+ try {
772
+ fs.rmSync(run.work, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
773
+ } catch {
774
+ // Gone with the next run into this folder.
775
+ }
776
+ }
777
+ if (!run.made) return;
778
+ // The folders this run made, deepest first, as long as they are empty:
779
+ // what is in them otherwise is not this run's.
780
+ for (let at = run.dir; ; at = path.dirname(at)) {
781
+ try {
782
+ fs.rmdirSync(at);
783
+ } catch {
784
+ break;
785
+ }
786
+ if (at === run.made || path.dirname(at) === at) break;
787
+ }
788
+ }
789
+
790
+ // Two turns of the event loop, so that a signal that came while the run
791
+ // worked without a pause, as it reads a large words file into captions and
792
+ // writes captions.ass, is heard before the run begins or puts its files in
793
+ // place: the process looks at the signals that came in once a turn, and a
794
+ // run that does not wait goes to its end and leaves the signal unheard.
795
+ const heard = () => new Promise((resolve) => setImmediate(() => setImmediate(resolve)));
796
+
797
+ // A run that fails ends what it started before its work folder goes: an
798
+ // ffmpeg still taking a frame would write into it.
799
+ function fail(run, err) {
800
+ stopAll();
801
+ abandon(run);
802
+ throw err;
803
+ }
804
+
805
+ const round = (value, places = 3) => Math.round(value * 10 ** places) / 10 ** places;
806
+ const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
807
+ // A share in whole percent, rounded down, so a share under 1 never shows
808
+ // as 100%.
809
+ const percentOf = (share) => Math.floor(share * 100 + 1e-9);
810
+ // The encode's progress, logged at each tenth.
811
+ const encodingLine = (share) => `Encoding ${percentOf(share)}%`;
812
+
813
+ // A still picture given as the video, as a thumbnail or a poster frame can
814
+ // be, is refused as musicfit refuses one, where its length of a frame said
815
+ // the words belong to another video: one frame and no sound, read by one
816
+ // of ffmpeg's picture readers (image2 for a JPEG, png_pipe and the other
817
+ // _pipe ones), or lasting no more than a tenth of a second. A picture over
818
+ // sound, or one frame held for seconds, is a video, and is captioned.
819
+ const PICTURE = /^(?:image2|image2pipe|\w+_pipe)$/;
820
+ function notStill(video, name) {
821
+ if (video.frames <= 1 && !video.audio.length && (PICTURE.test(video.container) || !(video.duration > 0.1))) throw new Error(`${name} is a still picture, not a video`);
822
+ }
823
+
824
+ // The words, cut to the video: words after its end are left out, a word
825
+ // that runs past it is cut there.
826
+ function fitWords(read, duration) {
827
+ const notes = [];
828
+ if (!Number.isFinite(duration)) return { words: read.words, notes };
829
+ const words = read.words.filter((w) => w.start < duration - 0.05).map((w) => ({ ...w, end: Math.min(w.end, duration) }));
830
+ const dropped = read.words.length - words.length;
831
+ if (dropped) notes.push(`${dropped} ${dropped === 1 ? "word starts" : "words start"} after the video ends (${duration.toFixed(2)} s) and ${dropped === 1 ? "was" : "were"} left out. Check that the words belong to this video.`);
832
+ if (!words.length) throw new Error(`Every word starts after the video ends (${duration.toFixed(2)} s). The words file belongs to another video, or its times are in another unit`);
833
+ const last = words[words.length - 1].end;
834
+ if (last < duration * 0.5 && duration > 20) notes.push(`The words end at ${last.toFixed(1)} s, before half of the video (${duration.toFixed(1)} s). Check that they belong to this video.`);
835
+ return { words, notes };
836
+ }
837
+
838
+ function cueLayout(options, width, height, places) {
839
+ const style = options.style;
840
+ const lines = style === "word" ? 1 : options.lines;
841
+ const place = captionLayout(width, height, places, { style, size: options.size, position: options.position, lines, box: Boolean(options.box) });
842
+ const maxChars = options.maxChars || charsThatFit(place.glyphWidth, place.fontPx);
843
+ return { place, lines, maxChars };
844
+ }
845
+
846
+ // A color #rrggbb as its red, green and blue, 0 to 255.
847
+ const rgbOf = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
848
+
849
+ // The color matrices a note names, by ffprobe's names for them.
850
+ const MATRICES = { bt709: "BT.709", bt601: "BT.601", bt2020: "BT.2020", bt2020c: "BT.2020 constant luminance", ictcp: "ICtCp", "ipt-c2": "IPT-C2", "ycgco-re": "YCgCo-R", "ycgco-ro": "YCgCo-R", smpte2085: "SMPTE ST 2085", "chroma-derived-nc": "chromaticity derived", "chroma-derived-c": "chromaticity derived constant luminance" };
851
+
852
+ // The captions SRT readers would take for formatting, by number.
853
+ function specialNote(cues) {
854
+ const odd = cues.filter((cue) => cue.lines.some((line) => SRT_SPECIAL.test(line))).map((cue) => cue.n);
855
+ if (!odd.length) return [];
856
+ const which = odd.length > 6 ? `${odd.slice(0, 6).join(", ")} and ${odd.length - 6} more` : list(odd.map(String));
857
+ return [`${odd.length === 1 ? `Caption ${which} has` : `Captions ${which} have`} < > { } or \\ in ${odd.length === 1 ? "it" : "them"}, which SRT readers take for formatting, so captions.srt has ‹ › ( ) and ⧵ in their place, and captions.ass ( ) and ⧵.`];
858
+ }
859
+
860
+ // The letters libass drew boxes for, since this machine has no font it
861
+ // can draw them with, as notes: emoji, which libass draws in no color
862
+ // font, and any other letters. `missing` and `unsure` are assLetters':
863
+ // where nothing says which font an emoji goes in, as on macOS and Windows,
864
+ // the note says it can be a box.
865
+ const EMOJI = /[\p{Extended_Pictographic}\p{Regional_Indicator}]/u;
866
+ function missingNotes(missing, unsure = []) {
867
+ const where = (items) => {
868
+ const cues = [...new Set(items.map((m) => m.cue))];
869
+ return `${cues.length === 1 ? "caption" : "captions"} ${cues.length > 6 ? `${cues.slice(0, 6).join(", ")} and ${cues.length - 6} more` : list(cues.map(String))}`;
870
+ };
871
+ const which = (items) => {
872
+ const letters = [...new Set(items.map((m) => m.letter))];
873
+ return letters.length > 8 ? `${letters.slice(0, 8).join(" ")} and ${letters.length - 8} more` : letters.join(" ");
874
+ };
875
+ const boxes = (n) => (n === 1 ? "a box for it" : "boxes for them");
876
+ const emoji = missing.filter((m) => EMOJI.test(m.letter));
877
+ const other = missing.filter((m) => !EMOJI.test(m.letter));
878
+ const notes = [];
879
+ if (emoji.length) notes.push(`libass draws no color emoji, and this machine has no other font that draws ${emoji.length === 1 ? "the emoji" : `the ${emoji.length} emoji`} in ${where(emoji)} (${which(emoji)}) whole, so the video shows ${boxes(emoji.length)}. Leave out --libass to have Chrome draw ${emoji.length === 1 ? "it" : "them"}.`);
880
+ if (other.length) notes.push(`No font on this machine that libass can use has ${count(other.length, "letter", "letters")} of ${where(other)} (${which(other)}), so the video shows ${boxes(other.length)}.`);
881
+ if (unsure.length) notes.push(`libass draws no color emoji, so ${unsure.length === 1 ? "the emoji" : `the ${unsure.length} emoji`} in ${where(unsure)} (${which(unsure)}) can show as ${unsure.length === 1 ? "a box" : "boxes"}: nothing on this machine says which font libass draws ${unsure.length === 1 ? "it" : "them"} with. Leave out --libass to have Chrome draw ${unsure.length === 1 ? "it" : "them"}.`);
882
+ return notes;
883
+ }
884
+
885
+ // The folder of a fonts.css font's files, which ffmpeg's ass filter finds
886
+ // the font of captions.ass in when it is its fontsdir, from the output
887
+ // folder `dir`, where captions.ass is; else "".
888
+ const fontsFolder = (look, dir) => (look.from === "fonts.css" && look.faces.length ? path.relative(path.resolve(dir), path.dirname(look.faces[0].file)) || "." : "");
889
+
890
+ // The folder ffmpeg's ass filter finds the look's font files in: a folder
891
+ // of the work folder that holds those files alone, each linked, or copied
892
+ // where no link can be made, or "" when the look has none. libass reads
893
+ // every file of the folder it is given into memory, and the folder of a
894
+ // fonts.css made elsewhere can hold any file next to its fonts, which that
895
+ // fonts.css can name too: a file past 64 MB, or one that does not start as
896
+ // a font file does, which the kit's own measuring passes over too, is no
897
+ // font libass can use, and is left out.
898
+ function fontsOfLook(look, work) {
899
+ if (!look.faces.length) return "";
900
+ const dir = path.join(work, "fonts");
901
+ fs.mkdirSync(dir);
902
+ const taken = new Set();
903
+ for (const file of new Set(look.faces.map((f) => f.file))) {
904
+ if (!fontLike(file)) continue;
905
+ let name = path.basename(file);
906
+ for (let n = 2; taken.has(name.toLowerCase()); n++) name = `${n}-${path.basename(file)}`;
907
+ taken.add(name.toLowerCase());
908
+ try {
909
+ fs.linkSync(file, path.join(dir, name));
910
+ } catch {
911
+ try {
912
+ fs.copyFileSync(file, path.join(dir, name));
913
+ } catch {
914
+ // A file that cannot be read is no font libass can use.
915
+ }
916
+ }
917
+ }
918
+ return dir;
919
+ }
920
+
921
+ // What fontconfig gives for a letter the font of captions.ass lacks, for
922
+ // a look: bold as it is, in the language of its words.
923
+ const givenFor = (look) => (code) => fallbackFont(code, look.weight, look.lang);
924
+
925
+ // What the look needs to know of the words' scripts: the language of
926
+ // CJK words, whether there are Arabic letters, and the scripts of India
927
+ // and Southeast Asia they hold letters of.
928
+ const scriptsOf = (words) => {
929
+ const texts = words.map((w) => w.text);
930
+ return { lang: cjkLanguage(texts), arabic: arabicIn(texts), scripts: scriptsIn(texts) };
931
+ };
932
+
933
+ // caption_video. options: { words, style, places, brand, fonts, font,
934
+ // highlight, position, lines, maxChars, size, box, libass, out, log }
935
+ async function captionVideo(videoFile, options) {
936
+ const log = options.log || (() => {});
937
+ notAddresses({ video: videoFile, words: options.words, brand: options.brand, fonts: options.fonts });
938
+ mustExist(videoFile, "file");
939
+ mustExist(options.words, "file");
940
+ if (options.brand) mustExist(options.brand, "file or folder");
941
+ if (options.fonts) mustExist(options.fonts, "file or folder");
942
+ const folder = outFolder(options, `easycaptionkit-${slug(videoFile)}`, { kind: "video", file: videoFile }, options.words);
943
+ const dir = folder.dir;
944
+ const origin = originOf(dir, options.words);
945
+ writable(dir);
946
+ const notes = recover(path.resolve(dir), dir);
947
+ const view = guard(dir, { video: videoFile, timeline: timelineOf(videoFile), words: options.words, brand: options.brand, fonts: options.fonts });
948
+ const bins = ffmpeg.tools();
949
+ const useLibass = Boolean(options.libass);
950
+ if (!useLibass && !findChrome()) throw new Error(missingChrome());
951
+ // Chrome keeps its profile in the temporary folder.
952
+ const temp = useLibass ? "" : tempProblem();
953
+ if (temp) throw new Error(`${temp}. Set it to a folder, or unset it`);
954
+ const [video, caps] = await Promise.all([ffmpeg.readVideo(videoFile, bins), ffmpeg.capabilities(bins)]);
955
+ notStill(video, videoFile);
956
+ if (!caps.encoder) throw new Error("This ffmpeg has no H.264 encoder (libx264, VideoToolbox or OpenH264), so it cannot write the captioned MP4");
957
+ if (useLibass && !caps.libass) throw new Error("This ffmpeg has no libass (its ass filter), so --libass cannot draw the captions. Leave out --libass to draw them with Chrome, or install an ffmpeg built with libass");
958
+ if (!useLibass && !caps.overlay) throw new Error("This ffmpeg has no overlay filter, so it cannot lay the captions over the video");
959
+ const read = await loadWords(options.words);
960
+ const first = firstFormat(read, origin);
961
+ const look = chooseLook({ ...options, ...scriptsOf(read.words) });
962
+ if (folder.note) notes.push(folder.note);
963
+ notes.push(...read.notes, ...look.notes, ...video.notes);
964
+ const { width, height } = video.out;
965
+ const places = options.places && options.places.length ? options.places : defaultPlaces(width, height);
966
+ const { place, lines, maxChars } = cueLayout(options, width, height, places);
967
+ notes.push(...place.notes);
968
+ const fitted = fitWords(read, video.duration);
969
+ notes.push(...fitted.notes);
970
+ if (read.video && Math.abs(read.video.duration - video.duration) > 0.5) notes.push(`steps.json is for a video ${read.video.duration.toFixed(1)} s long, and this one is ${video.duration.toFixed(1)} s. The captions follow the steps' times.`);
971
+ const words = fitted.words;
972
+ log(`Read ${count(words.length, "word", "words")} from ${shown(path.basename(options.words))} (${read.about})`);
973
+ const cues = buildCues(words, { lines, maxChars, hard: read.hard, duration: video.duration });
974
+ const states = makeStates(cues, words, options.style);
975
+ log(`Cut ${words.length === 1 ? "it" : "them"} into ${count(cues.length, "caption", "captions")}`);
976
+ notes.push(...specialNote(cues));
977
+ if (video.rotation) notes.push(`The video is stored turned ${video.rotation} degrees; it was captioned upright, as players show it, and written upright.`);
978
+ if (video.sar !== 1) notes.push(`The video's pixels are not square (${video.sar.toFixed(3)}:1); it was scaled to ${width}x${height} with square pixels first.`);
979
+ if (video.width !== width || video.height !== height) notes.push(`The video is ${video.width}x${video.height}; H.264 needs even sides, so the last ${video.width !== width && video.height !== height ? "column and row" : video.width !== width ? "column" : "row"} of pixels ${video.width !== width && video.height !== height ? "were" : "was"} cut, to ${width}x${height}.`);
980
+ if (video.hdr) notes.push(`The video is HDR (${/arib/.test(video.color.transfer) ? "HLG" : "PQ"}). It ${caps.tenBit ? "stays HDR in 10 bits" : "was written in 8 bits with its HDR tags, since this ffmpeg's x264 writes no 10 bits, so smooth skies can band"}, and the captions' white is the reference white for graphics on HDR, not the brightest the screen shows. Check it on the phone it was shot with.`);
981
+ // What the picture loses on its way to the 8-bit 4:2:0, or 10-bit for HDR, every app takes.
982
+ const format = ffmpeg.pixelFormat(video.pixFmt);
983
+ const bits = video.hdr && caps.tenBit ? 10 : 8;
984
+ if (format.rgb) notes.push(`The video's picture is stored as RGB (${video.pixFmt}); it was written as ${bits}-bit 4:2:0, with the ${MATRICES[ffmpeg.colorOf(video).matrix]} matrix, which every app takes.`);
985
+ else {
986
+ const changed = [...(format.bits > bits && !video.hdr ? [[`${format.bits}-bit`, `${bits}-bit`]] : []), ...(format.chroma && format.chroma !== "4:2:0" ? [[format.chroma, "4:2:0"]] : [])];
987
+ if (changed.length) notes.push(`The video is ${changed.map((c) => c[0]).join(" ")}; it was written as ${changed.map((c) => c[1]).join(" ")}, which every app takes.`);
988
+ }
989
+ if (!video.audio.length) notes.push("The video has no sound.");
990
+ const color = ffmpeg.colorOf(video, caps.colorNames);
991
+ if (color.kept) notes.push(`The video's color matrix is ${MATRICES[color.kept] || color.kept}, which ffmpeg converts no colors into, so captioned.mp4 keeps it, and the captions' colors are coded in ${MATRICES[color.matrix]}: a player that follows the video's matrix shows the captions in other colors.`);
992
+ const lost = leftOut(video);
993
+ if (lost) notes.push(lost);
994
+ // launch-film's timeline next to the film, looked at before any work: a
995
+ // file far bigger than one is passed over, and said.
996
+ const timeline = timelineOf(videoFile);
997
+ const timelineStat = statOf(timeline);
998
+ const tooBig = (size) => `${shown(path.basename(timeline))}, next to the video, is ${bytesText(size)}, far more than a launch-film timeline holds, so it was not copied as captioned.timeline.json.`;
999
+ let copyTimeline = Boolean(timelineStat && timelineStat.isFile());
1000
+ if (copyTimeline && Number(timelineStat.size) > MOST_TIMELINE) {
1001
+ notes.push(tooBig(Number(timelineStat.size)));
1002
+ copyTimeline = false;
1003
+ }
1004
+ // Frames at a steady rate where the video has too few for the captions,
1005
+ // said here once the result shows which captions are on its frames.
1006
+ const fill = ffmpeg.fillFor(video, states);
1007
+ const fillAt = notes.length;
1008
+
1009
+ await heard();
1010
+ const run = begin(dir);
1011
+ try {
1012
+ const out = path.join(run.work, "captioned.mp4");
1013
+ let drawn = null;
1014
+ // How each sound track was written: copied, or made AAC.
1015
+ let sound = [];
1016
+ // The color tags ffmpeg reads captioned.mp4 by for the pictures, and
1017
+ // the ones it leaves out of them.
1018
+ let frameTags = {};
1019
+ const titled = path.basename(videoFile);
1020
+ // captions.ass draws a caption smaller where the burned one is.
1021
+ // The font captions.ass names, as libass and players that read ASS
1022
+ // make it: a font asked for by name is what this machine gives for it.
1023
+ const libassFont = look.from === "fonts.css" ? null : machineFont(look.family || "Arial", look.weight);
1024
+ const font = assFont(look, libassFont, { fallback: givenFor(look) });
1025
+ const letters = assLetters(cues, font);
1026
+ // captions.ass names the video's own matrix and range for players; the
1027
+ // file libass burns can name others, and give other colors (assColors).
1028
+ const assFile = (scales, { level = 1, matrix = color.header, tint = null } = {}) => ass(cues, words, { states, style: options.style, look: { ...look, tool: TOOL }, place, title: titled, level, scales, font, matrix, tint });
1029
+ const progress = (share) => log(encodingLine(share));
1030
+ const unit = options.style === "word" ? ["word", "words"] : ["caption", "captions"];
1031
+ if (useLibass) {
1032
+ log(`Drawing ${count(states.length, "caption state", "caption states")} with libass`);
1033
+ // libass measures nothing it is given: a line that by the estimate is
1034
+ // wider than the safe space is drawn smaller.
1035
+ const scales = estimatedScales(cues, words, { style: options.style, place, font });
1036
+ fs.writeFileSync(path.join(run.work, "captions.ass"), assFile(scales));
1037
+ const fontsDir = fontsOfLook(look, run.work);
1038
+ // libass draws from a copy whose colors are at the reference white for
1039
+ // graphics on HDR, as Chrome's are, and that names the matrix and range
1040
+ // the picture has as the ass filter gets it, or, for a filter that
1041
+ // converts every color as BT.601 does, gives it the colors that come
1042
+ // out as the video's own; captions.ass keeps its own.
1043
+ const level = ffmpeg.hdrLevel(video);
1044
+ const draw = ffmpeg.assColors(color, await ffmpeg.libassFollows(bins, run.work, caps));
1045
+ let burnAss = path.join(run.work, "captions.ass");
1046
+ if (level < 1 || draw.header !== color.header || draw.tint) {
1047
+ burnAss = path.join(run.work, "burn.ass");
1048
+ fs.writeFileSync(burnAss, assFile(scales, { level, matrix: draw.header, tint: draw.tint }));
1049
+ }
1050
+ const made = await ffmpeg.burn({ bins, caps, video, output: out, ass: burnAss, assMatrix: draw.matrix, assFull: draw.full, fontsDir, fill, onProgress: progress });
1051
+ // A lit color such a filter cannot give as the video's matrix codes it
1052
+ // comes out as the nearest it can draw.
1053
+ const reach = draw.tint && level >= 1 && options.style !== "line" ? ffmpeg.as601(look.highlight, color.matrix).shown : "";
1054
+ if (reach && rgbOf(reach).some((c, i) => Math.abs(c - rgbOf(look.highlight)[i]) > 3)) notes.push(`This ffmpeg's ass filter converts every color as BT.601 does, so libass cannot draw ${look.highlight} as the video's ${MATRICES[color.matrix]} holds it: the lit word shows as ${reach}, the nearest it can draw. Leave out --libass to have Chrome draw it.`);
1055
+ notes.push(...made.notes);
1056
+ sound = made.tracks;
1057
+ frameTags = made.frameTags;
1058
+ const small = shrunkOf(scales);
1059
+ if (small.count) notes.push(`${small.count} ${small.count === 1 ? `${unit[0]} has` : `${unit[1]} have`} a line longer than the safe space takes at ${place.fontPx} px, by an estimate of the letters' width, so libass drew ${small.count === 1 ? "it" : "them"} smaller, ${small.count === 1 ? "at" : "the smallest at"} ${percentOf(small.smallest)}% of the size.`);
1060
+ // A variable file draws thin letters only where it draws any.
1061
+ const face = letters.own > 0 ? variableFace(look) : null;
1062
+ notes.push(...missingNotes(letters.missing, letters.unsure));
1063
+ if (face) notes.push(`libass draws ${look.family} from ${face.name}, a file of the weights ${face.weight[0]} to ${face.weight[1]}, at that file's default weight, not at ${look.weight}, so the letters are thinner than Chrome draws them. Leave out --libass to draw the ${look.weight}.`);
1064
+ // libass finds a file of the fonts folder by the names in it.
1065
+ if (look.from === "fonts.css" && !font.family) notes.push(`${look.faces[0].name || path.basename(look.faces[0].file)} gives no family name easycaptionkit can read, so captions.ass names the font ${look.family}, as fonts.css does. libass finds a font file by the names in it, so it may have drawn the captions with another font.`);
1066
+ } else {
1067
+ log(`Drawing ${count(states.length, "caption state", "caption states")} in Chrome`);
1068
+ try {
1069
+ drawn = await renderStates({ cues, words, states, style: options.style, look, place, duration: video.duration, dir: run.work, log });
1070
+ } catch (err) {
1071
+ // A Chrome that cannot start here (no usable sandbox, as in a
1072
+ // container) stops the run before the encode; libass needs none. A
1073
+ // Chrome the system stopped as memory ran out can draw with fewer
1074
+ // jobs at once, or a smaller video.
1075
+ if (err.chrome && err.signal === "SIGKILL") err.message += ". Run again with fewer jobs at once or with a smaller video, or pass --libass to have ffmpeg draw the captions without Chrome";
1076
+ else if (err.chrome && err.start) err.message += ". Pass --libass to have ffmpeg draw the captions without Chrome";
1077
+ throw err;
1078
+ }
1079
+ fs.writeFileSync(path.join(run.work, "captions.ass"), assFile(drawn.scales));
1080
+ log(`Encoding ${shown(titled)} with the captions (${caps.encoder.name})`);
1081
+ const made = await ffmpeg.burn({ bins, caps, video, output: out, overlay: { file: drawn.file, y: drawn.y }, fill, onProgress: progress });
1082
+ notes.push(...made.notes);
1083
+ sound = made.tracks;
1084
+ frameTags = made.frameTags;
1085
+ if (drawn.failedFonts.length) notes.push(`Chrome could not load the font ${[...new Set(drawn.failedFonts)].join(", ")}; the next font in the list drew the captions.`);
1086
+ if (drawn.shrunk) notes.push(`${drawn.shrunk} ${drawn.shrunk === 1 ? `${unit[0]} was` : `${unit[1]} were`} wider than the safe space and drawn smaller to fit, ${drawn.shrunk === 1 ? "at" : "the smallest at"} ${percentOf(drawn.smallest)}% of the size.`);
1087
+ }
1088
+ // launch-film's check finds the cuts in a timeline next to the film. It
1089
+ // is written as the other files are, so it takes the umask and the
1090
+ // folder's default ACL, where a copy would keep the mode of the file it
1091
+ // copies.
1092
+ const names = ["captioned.mp4", "captions.ass"];
1093
+ const found = copyTimeline ? bytesOf(timeline, MOST_TIMELINE) : {};
1094
+ if (found.bytes) {
1095
+ fs.writeFileSync(path.join(run.work, "captioned.timeline.json"), found.bytes);
1096
+ names.push("captioned.timeline.json");
1097
+ } else if (found.big) notes.push(tooBig(Number((statOf(timeline) || { size: 0 }).size)));
1098
+ else if (found.error && found.error.code !== "ENOENT") notes.push(`${shown(path.basename(timeline))}, next to the video, could not be read (${{ EACCES: "permission denied", EPERM: "permission denied" }[found.error.code] || shown(found.error.message)}), so it was not copied as captioned.timeline.json.`);
1099
+ fs.writeFileSync(path.join(run.work, "captions.srt"), srt(cues));
1100
+ fs.writeFileSync(path.join(run.work, "captions.vtt"), vtt(cues, { note: `${TOOL}, from ${path.basename(options.words)}` }));
1101
+ names.push("captions.srt", "captions.vtt");
1102
+
1103
+ // What the result is, measured: its frames' own times give the frame
1104
+ // each change shows on, whatever the frame rate does.
1105
+ const [result, times] = await Promise.all([ffmpeg.probe(out, bins), ffmpeg.frameTimes(out, bins)]);
1106
+ const rate = ffmpeg.rateOf(times, result.fps);
1107
+ if (rate.variable) notes.push(`The video's frame rate varies (${round(rate.fps, 3)} fps on average); every frame keeps its own time.`);
1108
+ // A caption state starts at its time to the millisecond on Chrome's
1109
+ // states, to the hundredth of a second in ASS.
1110
+ const frameStep = useLibass ? 0.01 : 0.001;
1111
+ const unseen = onNoFrame(cues, times, frameStep);
1112
+ if (fill) notes.splice(fillAt, 0, fillNote(video, fill, unseen));
1113
+ if (unseen.length) notes.push(`${unseen.length === 1 ? `Caption ${unseen[0]} shows` : `Captions ${list(unseen.map(String))} show`} on no frame of captioned.mp4, which has no frame while ${unseen.length === 1 ? "it is" : "they are"} up. ${unseen.length === 1 ? "It is" : "They are"} in captions.srt, captions.vtt and captions.ass.`);
1114
+ log("Making sheet.png and safe-zones.png");
1115
+ const widest = cues.reduce((a, b) => (b.lines.length > a.lines.length || (b.lines.length === a.lines.length && b.chars > a.chars) ? b : a), cues[0]);
1116
+ const pictures = { sheet: 0 };
1117
+ const zonesFile = path.join(run.work, "safe-zones.png");
1118
+ const sheetFile = path.join(run.work, "sheet.png");
1119
+ // Chrome lays the pictures out. Where there is none, or it cannot start
1120
+ // or stops, ffmpeg makes plainer ones: the captions are in the video
1121
+ // by now, and the pictures only show them.
1122
+ let plain = findChrome() ? tempProblem() : chromeNamedNothing() || "no Chrome was found";
1123
+ if (!plain) {
1124
+ try {
1125
+ await safeZonesPng({ bins, video, output: out, zones: place.zones, cue: widest, times, file: zonesFile, dir: run.work, frameTags });
1126
+ pictures.sheet = await sheetPng({ bins, video, output: out, cues, times, file: sheetFile, dir: run.work, frameTags });
1127
+ } catch (err) {
1128
+ if (!err.chrome) throw err;
1129
+ plain = `Chrome could not draw them: ${err.message}`;
1130
+ }
1131
+ }
1132
+ if (plain) {
1133
+ pictures.sheet = await plainPictures({ bins, video, output: out, cues, times, zones: place.zones, cue: widest, sheetFile, zonesFile, dir: run.work, frameTags });
1134
+ notes.push(`ffmpeg made sheet.png and safe-zones.png, plainer than Chrome lays them out, since ${plain.replace(/\.$/, "")}. The frames have no caption numbers or times on them, and the margins no app buttons or names.`);
1135
+ }
1136
+ names.push("sheet.png", "safe-zones.png");
1137
+
1138
+ const facts = {
1139
+ tool: TOOL,
1140
+ out: dir,
1141
+ video: { file: path.basename(videoFile), from: fromFolder(run.dir, videoFile), ...describe(video) },
1142
+ // Each sound track as it was written: copied, or made AAC from the codec it had.
1143
+ output: { file: "captioned.mp4", ...withSound(describe(result, rate, times.length), sound), encoder: caps.encoder.name },
1144
+ source: { file: path.basename(options.words), from: fromFolder(run.dir, options.words), format: read.format, about: read.about, ...(first !== read.format ? { firstFormat: first } : {}), count: words.length, byWord: read.byWord, ...(origin ? { origin } : {}) },
1145
+ style: options.style,
1146
+ drawnWith: useLibass ? "libass" : "chrome",
1147
+ look,
1148
+ places: place.zones,
1149
+ layout: { ...place, zones: undefined, maxChars, lines },
1150
+ band: drawn ? { y: drawn.y, height: drawn.height } : null,
1151
+ reach: drawn ? drawn.reach : null,
1152
+ states: drawn ? drawn.states : states.length,
1153
+ fontsDrawn: drawn ? drawn.fonts : [],
1154
+ fontFirst: drawn ? drawn.first : "",
1155
+ libassFont: useLibass ? libassFont : null,
1156
+ // The name captions.ass gives the font, from its file for fonts.css,
1157
+ // and the fonts it sets the letters in that the font lacks.
1158
+ assFamily: font.family || look.family || "Arial",
1159
+ assFonts: fontsFolder(look, dir),
1160
+ assFallbacks: letters.fallbacks,
1161
+ assLetters: { total: letters.total, own: letters.own },
1162
+ cues,
1163
+ wordList: words,
1164
+ reading: readingSpeed(cues),
1165
+ sheet: pictures.sheet,
1166
+ timelineCopied: names.includes("captioned.timeline.json"),
1167
+ moment: round(shotOf(widest, times).time, 2),
1168
+ frameTimes: times,
1169
+ frameStep,
1170
+ notes,
1171
+ };
1172
+ fs.writeFileSync(path.join(run.work, "CAPTIONS.md"), captionsMarkdown(facts));
1173
+ names.push("CAPTIONS.md");
1174
+ const files = writtenFiles(run.work, names);
1175
+ fs.writeFileSync(path.join(run.work, "words.json"), JSON.stringify(wordsJson(words, read, files, first), null, 2) + "\n");
1176
+ const json = captionsJson({ ...facts, files: { ...files, ...writtenFiles(run.work, ["words.json"]) } });
1177
+ fs.writeFileSync(path.join(run.work, "captions.json"), JSON.stringify(json, null, 2) + "\n");
1178
+ names.push("words.json", "captions.json");
1179
+ await heard();
1180
+ finish(run, names, { view });
1181
+ return { dir, captions: json, facts };
1182
+ } catch (err) {
1183
+ return fail(run, err);
1184
+ }
1185
+ }
1186
+
1187
+ // The note for a video given frames at a steady rate, as fillFor says,
1188
+ // and `unseen`, the captions that still show on no frame of the result.
1189
+ function fillNote(video, fill, unseen = []) {
1190
+ const why = [];
1191
+ if (fill.few) why.push(`the video has ${count(fill.few, "frame", "frames")} a second`);
1192
+ else if (fill.held) why.push(`the video holds some frames for up to ${fill.held.toFixed(2)} s, as a recording that writes a frame only when the picture changes does`);
1193
+ if (fill.start) why.push(`its picture starts at ${video.picture.start.toFixed(2)} s, after its sound`);
1194
+ if (fill.past) why.push(`its picture ends at ${video.picture.end.toFixed(2)} s while its sound runs on to ${video.duration.toFixed(2)} s`);
1195
+ const how = [...(fill.start ? ["its first frame shown from the start"] : []), "each frame repeated until the next comes", ...(fill.past ? ["the last until the sound ends"] : [])];
1196
+ return `The captions show on the video's own frames, and ${list(why)}, so ${count(fill.captions, "caption", "captions")} would have shown late or on no frame. captioned.mp4 is written at ${round(fill.fps, 3)} fps instead, ${list(how)}${unseen.length ? "." : ", so every caption shows at its time."}`;
1197
+ }
1198
+
1199
+ // The numbers of the cues no frame of a video shows, from its frames'
1200
+ // times: each frame shows the cue up at its time, a change drawn at its
1201
+ // time to the `step`.
1202
+ function onNoFrame(cues, times, step) {
1203
+ const first = (t) => {
1204
+ const at = Math.round(t / step) * step - 1e-7;
1205
+ let lo = 0;
1206
+ let hi = times.length;
1207
+ while (lo < hi) {
1208
+ const mid = (lo + hi) >> 1;
1209
+ if (times[mid] >= at) hi = mid;
1210
+ else lo = mid + 1;
1211
+ }
1212
+ return lo;
1213
+ };
1214
+ return times.length ? cues.filter((cue) => first(cue.end) <= first(cue.start)).map((cue) => cue.n) : [];
1215
+ }
1216
+
1217
+ // What captioned.mp4 leaves out of the video given, as a note, or "".
1218
+ function leftOut(v) {
1219
+ const { tags = [], covers = 0, videos = 0, subtitles = 0, data = 0, attachments = 0 } = v.left || {};
1220
+ const parts = [];
1221
+ // Each tag once, though MP4 keeps one for each language too (location
1222
+ // and location-eng); the ones people know first, then the rest by name.
1223
+ const names = [...new Set(tags.map((t) => t.replace(/-[a-z]{3}$/i, "")))];
1224
+ if (names.length) {
1225
+ const known = /^(?:title|location.*|com\.apple\.quicktime\.location.*|creation_time|com\.apple\.quicktime\.creationdate|date|artist|comment|description)$/i;
1226
+ const named = [...names.filter((t) => known.test(t)), ...names.filter((t) => !known.test(t))].map(shown);
1227
+ const shownTags = named.length > 4 ? [...named.slice(0, 3), `${named.length - 3} more`] : named;
1228
+ parts.push(`the file's own ${names.length === 1 ? "tag" : "tags"} (${list(shownTags)})`);
1229
+ }
1230
+ if (covers) parts.push(covers === 1 ? "its cover picture" : `its ${covers} cover pictures`);
1231
+ if (videos) parts.push(videos === 1 ? "its other video track" : `its ${videos} other video tracks`);
1232
+ if (subtitles) parts.push(subtitles === 1 ? "its subtitle track" : `its ${subtitles} subtitle tracks`);
1233
+ if (data) parts.push(data === 1 ? "its data track" : `its ${data} data tracks`);
1234
+ if (attachments) parts.push(attachments === 1 ? "the file attached to it" : `the ${attachments} files attached to it`);
1235
+ if (!parts.length) return "";
1236
+ const kept = ["the picture", ...(v.audio.length ? [v.audio.length === 1 ? "the sound with its language" : "each sound track with its language"] : []), ...(v.chapters ? ["the chapters"] : [])];
1237
+ return `captioned.mp4 keeps ${list(kept)}, and leaves out ${list(parts)}.`;
1238
+ }
1239
+
1240
+ // A description of the result, each of its sound tracks with how it was
1241
+ // written: { codec, channels, rate, copied }, and the codec it had, as
1242
+ // from, where it was made AAC.
1243
+ function withSound(d, sound) {
1244
+ return { ...d, audio: d.audio.map((a, i) => (sound[i] && !sound[i].copied ? { ...a, copied: false, from: sound[i].from } : { ...a, copied: true })) };
1245
+ }
1246
+
1247
+ function describe(v, rate = null, frames = 0) {
1248
+ return {
1249
+ width: v.out ? v.out.width : v.width,
1250
+ height: v.out ? v.out.height : v.height,
1251
+ shape: shapeOf(v.width, v.height),
1252
+ fps: round(rate ? rate.fps : v.fps, 3),
1253
+ duration: round(v.duration, 3),
1254
+ frames: frames || v.frames,
1255
+ rotation: v.rotation,
1256
+ codec: v.codec,
1257
+ audio: v.audio.map((a) => ({ codec: a.codec, channels: a.channels, rate: a.rate })),
1258
+ };
1259
+ }
1260
+
1261
+ // words.json: the words as read, in a shape easycaptionkit reads back, so
1262
+ // a misheard word can be fixed there and the run made again from it. It
1263
+ // carries the SHA-256 of its words, so the next run into the folder can
1264
+ // tell they were fixed by hand since, and says so when they came from
1265
+ // words fixed by hand. `from` is the format they first came in (`first`,
1266
+ // firstFormat's), which a run that reads the file again keeps. A word
1267
+ // without a time of its own says how it got one: spread over its line by
1268
+ // length, or placed between its neighbors, as the file gave it none.
1269
+ function wordsJson(words, read, files, first = read.format) {
1270
+ const items = words.map((w) => ({ text: w.text, start: round(w.start), end: round(w.end), ...(w.seg >= 0 ? { seg: w.seg } : {}), ...(w.timed ? {} : w.placed ? { placed: true } : { spread: true }) }));
1271
+ return {
1272
+ tool: TOOL,
1273
+ from: FORMATS[first] || read.about,
1274
+ hard: read.hard,
1275
+ // The other files the run wrote, so a folder whose captions.json went
1276
+ // is still known as the run's.
1277
+ files,
1278
+ ...(read.fixed ? { fixed: true } : {}),
1279
+ wordsSha256: wordsSha256(items),
1280
+ words: items,
1281
+ };
1282
+ }
1283
+
1284
+ // The frame captions.ass is laid out for when no video is given: a phone
1285
+ // when any place named is one of the vertical apps, a square for square
1286
+ // posts alone, else 16:9.
1287
+ function frameFor(places = []) {
1288
+ if (places.some((p) => ["tiktok", "reels", "shorts"].includes(p))) return { width: 1080, height: 1920 };
1289
+ if (places.length && places.every((p) => p === "square")) return { width: 1080, height: 1080 };
1290
+ return { width: 1920, height: 1080 };
1291
+ }
1292
+
1293
+ // make_subtitles: the subtitle files from the words alone, no video
1294
+ // changed. options: { video, places, style, lines, maxChars, size,
1295
+ // position, brand, fonts, font, highlight, box, out, log }
1296
+ async function makeSubtitles(wordsFile, options) {
1297
+ const log = options.log || (() => {});
1298
+ notAddresses({ words: wordsFile, video: options.video, brand: options.brand, fonts: options.fonts });
1299
+ mustExist(wordsFile, "file");
1300
+ if (options.video) mustExist(options.video, "file");
1301
+ if (options.brand) mustExist(options.brand, "file or folder");
1302
+ if (options.fonts) mustExist(options.fonts, "file or folder");
1303
+ const source = options.video ? { kind: "video", file: options.video } : { kind: "words", file: wordsFile };
1304
+ const folder = outFolder(options, `easycaptionkit-subtitles-${slug(options.video || wordsFile)}`, source, wordsFile);
1305
+ const dir = folder.dir;
1306
+ const origin = originOf(dir, wordsFile);
1307
+ writable(dir);
1308
+ const notes = recover(path.resolve(dir), dir);
1309
+ const view = guard(dir, { words: wordsFile, video: options.video, brand: options.brand, fonts: options.fonts }, { subtitles: true, options });
1310
+ const read = await loadWords(wordsFile);
1311
+ const first = firstFormat(read, origin);
1312
+ const look = chooseLook({ ...options, ...scriptsOf(read.words) });
1313
+ if (folder.note) notes.push(folder.note);
1314
+ notes.push(...read.notes, ...look.notes);
1315
+ let video = null;
1316
+ if (options.video) {
1317
+ video = await ffmpeg.readVideo(options.video, ffmpeg.tools());
1318
+ notStill(video, options.video);
1319
+ notes.push(...video.notes);
1320
+ }
1321
+ const { width, height } = video ? video.out : frameFor(options.places);
1322
+ const places = options.places && options.places.length ? options.places : defaultPlaces(width, height);
1323
+ const maxChars = options.maxChars || 42;
1324
+ const { place, lines } = cueLayout({ ...options, maxChars }, width, height, places);
1325
+ notes.push(...place.notes);
1326
+ const fitted = video ? fitWords(read, video.duration) : { words: read.words, notes: [] };
1327
+ notes.push(...fitted.notes);
1328
+ const words = fitted.words;
1329
+ log(`Read ${count(words.length, "word", "words")} from ${shown(path.basename(wordsFile))} (${read.about})`);
1330
+ const shape = { lines: options.style === "word" ? 1 : lines, hard: read.hard, duration: video ? video.duration : Infinity };
1331
+ const cues = buildCues(words, { ...shape, maxChars });
1332
+ // Words said after the picture ends, while the sound runs on, are heard.
1333
+ if (video && video.soundPast && cues.some((cue) => cue.end > video.picture.end + 0.05)) notes.push(`The video's picture ends at ${video.picture.end.toFixed(2)} s, and its sound runs on to ${video.duration.toFixed(2)} s. The captions go on to the end of the sound, over the last frame, which players hold.`);
1334
+ log(`Cut ${words.length === 1 ? "it" : "them"} into ${count(cues.length, "caption", "captions")}`);
1335
+ // captions.ass sets its lines on the frame, at its letter size: when
1336
+ // fewer characters fit across than the subtitle files take, it has
1337
+ // captions of its own, cut to what fits.
1338
+ const fit = charsThatFit(place.glyphWidth, place.fontPx);
1339
+ const assChars = Math.min(maxChars, fit);
1340
+ const narrow = assChars < maxChars ? buildCues(words, { ...shape, maxChars: assChars }) : cues;
1341
+ const assCues = narrow.length === cues.length && narrow.every((cue, i) => cue.lines.join("\n") === cues[i].lines.join("\n")) ? cues : narrow;
1342
+ const assStates = makeStates(assCues, words, options.style);
1343
+ // A font asked for by name is what this machine gives for it.
1344
+ const libassFont = look.from === "fonts.css" ? null : machineFont(look.family || "Arial", look.weight);
1345
+ const font = assFont(look, libassFont, { fallback: givenFor(look) });
1346
+ const assScales = estimatedScales(assCues, words, { style: options.style, place, font });
1347
+ const letters = assLetters(assCues, font);
1348
+ if (assCues !== cues) notes.push(`At ${place.fontPx} px, ${assChars} characters fit across the ${Math.round(place.textWidth)} px of safe space on the ${width}x${height} frame, so captions.ass cuts the words into ${count(assCues.length, "caption", "captions")} of up to ${assChars} characters a line; captions.srt and captions.vtt take ${maxChars}.`);
1349
+ const small = shrunkOf(assScales);
1350
+ if (small.count) notes.push(`${small.count} ${options.style === "word" ? (small.count === 1 ? "word" : "words") : small.count === 1 ? "caption" : "captions"} of captions.ass ${small.count === 1 ? "has" : "have"} a line longer than the safe space takes at ${place.fontPx} px, by an estimate of the letters' width, so captions.ass draws ${small.count === 1 ? "it" : "them"} smaller, ${small.count === 1 ? "at" : "the smallest at"} ${percentOf(small.smallest)}% of the size.`);
1351
+ notes.push(...specialNote(cues));
1352
+ await heard();
1353
+ const run = begin(dir);
1354
+ try {
1355
+ fs.writeFileSync(path.join(run.work, "captions.srt"), srt(cues));
1356
+ fs.writeFileSync(path.join(run.work, "captions.vtt"), vtt(cues, { note: `${TOOL}, from ${path.basename(wordsFile)}` }));
1357
+ // With a video, captions.ass names its matrix and range, which players convert the colors with.
1358
+ fs.writeFileSync(path.join(run.work, "captions.ass"), ass(assCues, words, { states: assStates, style: options.style, look: { ...look, tool: TOOL }, place, title: path.basename(options.video || wordsFile), scales: assScales, font, matrix: video ? ffmpeg.colorOf(video).header : "" }));
1359
+ const facts = {
1360
+ tool: TOOL,
1361
+ out: dir,
1362
+ subtitlesOnly: true,
1363
+ video: video ? { file: path.basename(options.video), from: fromFolder(run.dir, options.video), ...describe(video) } : null,
1364
+ frame: { width, height },
1365
+ source: { file: path.basename(wordsFile), from: fromFolder(run.dir, wordsFile), format: read.format, about: read.about, ...(first !== read.format ? { firstFormat: first } : {}), count: words.length, byWord: read.byWord, ...(origin ? { origin } : {}) },
1366
+ style: options.style,
1367
+ look,
1368
+ places: place.zones,
1369
+ layout: { ...place, zones: undefined, maxChars, lines, assMaxChars: assChars, assCues: assCues.length },
1370
+ libassFont,
1371
+ assFamily: font.family || look.family || "Arial",
1372
+ assFonts: fontsFolder(look, dir),
1373
+ assFallbacks: letters.fallbacks,
1374
+ assLetters: { total: letters.total, own: letters.own },
1375
+ // What the fix-a-word command gives again.
1376
+ rerun: { program: options.program, video: options.video, places: options.places, style: options.style, lines: options.lines, maxChars: options.maxChars, size: options.size, position: options.position, box: options.box, brand: options.brand, fonts: options.fonts, font: options.font, highlight: options.highlight },
1377
+ cues,
1378
+ wordList: words,
1379
+ reading: readingSpeed(cues),
1380
+ notes,
1381
+ };
1382
+ fs.writeFileSync(path.join(run.work, "CAPTIONS.md"), subtitlesMarkdown(facts));
1383
+ const files = writtenFiles(run.work, ["captions.srt", "captions.vtt", "captions.ass", "CAPTIONS.md"]);
1384
+ fs.writeFileSync(path.join(run.work, "words.json"), JSON.stringify(wordsJson(words, read, files, first), null, 2) + "\n");
1385
+ const json = captionsJson({ ...facts, files: { ...files, ...writtenFiles(run.work, ["words.json"]) } });
1386
+ fs.writeFileSync(path.join(run.work, "captions.json"), JSON.stringify(json, null, 2) + "\n");
1387
+ await heard();
1388
+ finish(run, ["captions.srt", "captions.vtt", "captions.ass", "words.json", "captions.json", "CAPTIONS.md"], { stale: false, view });
1389
+ return { dir, captions: json, facts };
1390
+ } catch (err) {
1391
+ return fail(run, err);
1392
+ }
1393
+ }
1394
+
1395
+ module.exports = { captionVideo, makeSubtitles, slug, notAddresses, writable, guard, recover, begin, finish, abandon, frameFor, encodingLine, percentOf, missingNotes, signalsOf, stopOnSignals, stopped, began, OUTPUTS, TOOL, shown, named };