easycaptionkit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,424 @@
1
+ "use strict";
2
+
3
+ // The user's Chrome, headless, over the DevTools protocol on a pipe (file
4
+ // descriptors 3 and 4), so no port is opened. Each run gets a throwaway
5
+ // profile, and the browser stays offline: the captions page carries its
6
+ // own fonts as local files.
7
+ //
8
+ // On a Mac, Chrome asks for the login keychain under a fresh profile
9
+ // unless it gets a mock keychain and a basic password store.
10
+
11
+ const fs = require("node:fs");
12
+ const os = require("node:os");
13
+ const path = require("node:path");
14
+ const { spawn, spawnSync } = require("node:child_process");
15
+ const { onPath, given, namedNothing } = require("./ffmpeg");
16
+ const { track, trackFolder, untrackFolder, kill, sleep } = require("./running");
17
+
18
+ // A Chrome named in the environment is found as FFMPEG_PATH is: a bare
19
+ // name on PATH, a path from the folder the run starts in.
20
+ function findChrome() {
21
+ const named = process.env.CHROME_PATH;
22
+ if (named) return given(named);
23
+ const mac = [
24
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
25
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
26
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
27
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
28
+ path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
29
+ ];
30
+ const win = [
31
+ path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Google\\Chrome\\Application\\chrome.exe"),
32
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google\\Chrome\\Application\\chrome.exe"),
33
+ path.join(process.env.LOCALAPPDATA || "", "Google\\Chrome\\Application\\chrome.exe"),
34
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Microsoft\\Edge\\Application\\msedge.exe"),
35
+ ];
36
+ const listed = process.platform === "darwin" ? mac : process.platform === "win32" ? win : [];
37
+ for (const file of listed) if (file && fs.existsSync(file)) return file;
38
+ for (const name of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome", "microsoft-edge", "brave-browser"]) {
39
+ const found = onPath(name);
40
+ if (found) return found;
41
+ }
42
+ return "";
43
+ }
44
+
45
+ const MISSING = "No Chrome was found to draw the captions with. Install Google Chrome or Chromium, set CHROME_PATH to it, or pass --libass to have ffmpeg draw them.";
46
+
47
+ // The variable that names this run's Chrome, when it names no program:
48
+ // what it names, as a sentence without its end. Empty otherwise.
49
+ function chromeNamedNothing() {
50
+ return namedNothing("CHROME_PATH");
51
+ }
52
+
53
+ // Why there is no Chrome: what the variable names, or that none is on the
54
+ // machine.
55
+ function missingChrome() {
56
+ const nothing = chromeNamedNothing();
57
+ return nothing ? `${nothing}. Set it to the path of Chrome or Chromium, or pass --libass to have ffmpeg draw the captions.` : MISSING;
58
+ }
59
+
60
+ // An error of Chrome's own, not of a file or of ffmpeg: `start` when Chrome
61
+ // stopped before it answered at all, as one without a usable sandbox does,
62
+ // and `signal` when a signal ended it.
63
+ const failed = (message, { start = false, signal = null } = {}) => Object.assign(new Error(message), { chrome: true, start, signal });
64
+
65
+ // What is wrong with the temporary folder Chrome's profile goes in, as a
66
+ // sentence without its end, or "" when it is a folder this run can write:
67
+ // TMPDIR names a folder that is not there, a file, or one that cannot be
68
+ // written.
69
+ function tempProblem(tmp = os.tmpdir()) {
70
+ const variable = process.platform === "win32" ? "TEMP" : "TMPDIR";
71
+ let stat;
72
+ try {
73
+ stat = fs.statSync(tmp);
74
+ } catch {
75
+ return `${variable} is ${tmp}, and there is no folder there`;
76
+ }
77
+ if (!stat.isDirectory()) return `${variable} is ${tmp}, which is a file, not a folder`;
78
+ try {
79
+ fs.accessSync(tmp, fs.constants.W_OK);
80
+ } catch {
81
+ return `${variable} is ${tmp}, a folder this run cannot write to`;
82
+ }
83
+ return "";
84
+ }
85
+
86
+ // Chrome makes a socket in a folder under TMPDIR, and the path of a socket
87
+ // can be only about 100 characters long, so a long TMPDIR stops Chrome at
88
+ // start. It gets a short one then.
89
+ function chromeTemp() {
90
+ const tmp = os.tmpdir();
91
+ if (process.platform === "win32" || tmp.length <= 60) return tmp;
92
+ for (const dir of ["/tmp", "/var/tmp"]) {
93
+ try {
94
+ fs.accessSync(dir, fs.constants.W_OK);
95
+ return dir;
96
+ } catch {
97
+ // Not this one.
98
+ }
99
+ }
100
+ return tmp;
101
+ }
102
+
103
+ // A profile, and the folder of the socket Chrome links from it, which a
104
+ // Chrome that was killed leaves behind.
105
+ function removeProfile(profile) {
106
+ for (const link of ["SingletonSocket", "SingletonCookie"]) {
107
+ try {
108
+ const folder = path.dirname(fs.readlinkSync(path.join(profile, link)));
109
+ if (/^(?:com\.google\.Chrome|org\.chromium\.Chromium|com\.microsoft\.Edge|com\.brave\.Browser)\.\w+$/.test(path.basename(folder))) fs.rmSync(folder, { recursive: true, force: true });
110
+ } catch {
111
+ // No link: Chrome cleaned up after itself, or never got that far.
112
+ }
113
+ }
114
+ fs.rmSync(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
115
+ }
116
+
117
+ // The processes of a Chrome that are still going: each one whose command
118
+ // line names its profile, as Chrome gives it to every helper it starts,
119
+ // and its crash handlers, whose database is in the profile, so they are
120
+ // found once the browser that started them is gone too. Chrome writes its
121
+ // command line over as one line, so it is searched as text: in /proc on
122
+ // Linux, from ps elsewhere.
123
+ function profileProcesses(profile) {
124
+ const base = path.basename(profile);
125
+ const holds = (command) => (command.includes("--user-data-dir=") || command.includes("--database=")) && command.includes(base);
126
+ return commandLines()
127
+ .filter(([, command]) => holds(command))
128
+ .map(([pid]) => pid);
129
+ }
130
+
131
+ // Every process's command line but this one's, [pid, command], as text.
132
+ function commandLines() {
133
+ const found = [];
134
+ if (process.platform === "win32") return found;
135
+ if (process.platform === "linux") {
136
+ let names = [];
137
+ try {
138
+ names = fs.readdirSync("/proc");
139
+ } catch {
140
+ return found;
141
+ }
142
+ for (const name of names) {
143
+ if (!/^\d+$/.test(name) || Number(name) === process.pid) continue;
144
+ try {
145
+ found.push([Number(name), fs.readFileSync(`/proc/${name}/cmdline`, "latin1")]);
146
+ } catch {
147
+ // Gone while it was read.
148
+ }
149
+ }
150
+ return found;
151
+ }
152
+ const r = spawnSync("ps", ["-A", "-ww", "-o", "pid=,command="], { encoding: "utf8", timeout: 5000 });
153
+ for (const line of String(r.stdout || "").split("\n")) {
154
+ const m = /^\s*(\d+)\s+(.*)$/.exec(line);
155
+ if (m && Number(m[1]) !== process.pid) found.push([Number(m[1]), m[2]]);
156
+ }
157
+ return found;
158
+ }
159
+
160
+ // The profiles that runs killed outright (kill -9, or the system as
161
+ // memory ran out) left in the temporary folder, where they stay, since
162
+ // such a run cannot tidy up: each of this user's that no process names
163
+ // and that has not changed for a day goes as the next Chrome starts.
164
+ // Another user's stays, as the system would not let it go. On Windows,
165
+ // where no command lines are read, they stay.
166
+ const DAY = 24 * 3600 * 1000;
167
+ function clearOldProfiles(tmp = os.tmpdir(), now = Date.now()) {
168
+ if (process.platform === "win32") return;
169
+ let names = [];
170
+ try {
171
+ names = fs.readdirSync(tmp);
172
+ } catch {
173
+ return;
174
+ }
175
+ const old = names
176
+ .filter((name) => /^easycaptionkit-chrome-\w{6}$/.test(name))
177
+ .map((name) => path.join(tmp, name))
178
+ .filter((dir) => {
179
+ const stat = fs.lstatSync(dir, { throwIfNoEntry: false });
180
+ return stat && stat.isDirectory() && now - stat.mtimeMs > DAY && (!process.getuid || stat.uid === process.getuid());
181
+ });
182
+ if (!old.length) return;
183
+ const commands = commandLines().map(([, command]) => command);
184
+ for (const dir of old) {
185
+ if (commands.some((command) => command.includes(path.basename(dir)))) continue;
186
+ try {
187
+ removeProfile(dir);
188
+ } catch {
189
+ // Another user's, or gone already.
190
+ }
191
+ }
192
+ }
193
+
194
+ // Ends a Chrome and every helper it started. Chrome and the helpers it
195
+ // draws with run in the process group of the run, as the programs a run
196
+ // starts do, so that whatever keeps the run's group (makersmcp's
197
+ // supervisor does) keeps its Chrome too. Its crash handlers start a
198
+ // session of their own, and end once Chrome is gone. They are all found
199
+ // by its profile. A helper can start another as the first ones go, so
200
+ // they are looked for again.
201
+ function stopChrome(child, profile) {
202
+ kill(child);
203
+ for (let round = 0; round < 3; round++) {
204
+ const left = profileProcesses(profile);
205
+ if (!left.length) return;
206
+ for (const pid of left) {
207
+ try {
208
+ process.kill(pid, "SIGKILL");
209
+ } catch {
210
+ // Gone already.
211
+ }
212
+ }
213
+ sleep(50);
214
+ }
215
+ }
216
+
217
+ // Why Chrome stopped, from what it printed: its last FATAL line, else its
218
+ // last line that is not crashpad's noise about the machine.
219
+ function whyStopped(stderr, temp) {
220
+ const lines = String(stderr).split("\n").map((line) => line.trim()).filter(Boolean);
221
+ const line = lines.filter((l) => /:FATAL:/.test(l)).pop() || lines.filter((l) => !/crashpad/i.test(l)).pop() || "";
222
+ const text = line.replace(/^\[[^\]]*\]\s*/, "");
223
+ if (/Socket path too long/i.test(text)) return `the temporary folder's path is too long for Chrome's socket (${temp}); set TMPDIR to a shorter folder`;
224
+ if (/No usable sandbox/i.test(text)) return `Chrome could not start its sandbox here (${text})`;
225
+ return text;
226
+ }
227
+
228
+ async function launch({ width = 1920, height = 1080 } = {}) {
229
+ const chrome = findChrome();
230
+ if (!chrome) throw failed(missingChrome(), { start: true });
231
+ const temp = chromeTemp();
232
+ clearOldProfiles();
233
+ // A profile Chrome cannot have is a Chrome that cannot start.
234
+ let made;
235
+ try {
236
+ made = fs.mkdtempSync(path.join(os.tmpdir(), "easycaptionkit-chrome-"));
237
+ } catch (err) {
238
+ throw failed(tempProblem() || `Chrome's profile could not be made in ${os.tmpdir()}: ${err.message}`, { start: true });
239
+ }
240
+ const profile = trackFolder(made, removeProfile);
241
+ const drop = () => {
242
+ untrackFolder(profile);
243
+ try {
244
+ removeProfile(profile);
245
+ } catch {
246
+ // Left for the system's temp cleanup.
247
+ }
248
+ };
249
+ const args = [
250
+ "--headless=new", "--hide-scrollbars", "--mute-audio", "--no-first-run", "--no-default-browser-check",
251
+ "--disable-extensions", "--disable-sync", "--disable-default-apps", "--disable-background-networking",
252
+ "--disable-component-update", "--disable-domain-reliability", "--disable-breakpad", "--metrics-recording-only",
253
+ "--disable-features=Translate,MediaRouter,OptimizationHints,AutofillServerCommunication",
254
+ "--use-mock-keychain", "--password-store=basic", "--force-color-profile=srgb", "--font-render-hinting=none",
255
+ "--allow-file-access-from-files",
256
+ // Chrome's sandbox does not start as root; anyone else keeps it.
257
+ ...(process.getuid && process.getuid() === 0 ? ["--no-sandbox"] : []),
258
+ ...(process.platform === "linux" ? ["--disable-dev-shm-usage"] : []),
259
+ "--proxy-server=http://127.0.0.1:9", "--proxy-bypass-list=<-loopback>",
260
+ "--remote-debugging-pipe", `--user-data-dir=${profile}`, `--window-size=${width},${height}`, "about:blank",
261
+ ];
262
+ // A helper process left running writes into the profile after it is
263
+ // removed: each stop ends them all. On Linux, Chrome keeps its crash
264
+ // reports where its own config would be, not in the profile it is given,
265
+ // and a dump of a crash would stay in the user's own Chrome folder for
266
+ // good: that place is in the profile too, so they go with it. The
267
+ // user's fontconfig stays where it is.
268
+ let child;
269
+ const stop = () => stopChrome(child, profile);
270
+ try {
271
+ child = track(spawn(chrome, args, { stdio: ["ignore", "ignore", "pipe", "pipe", "pipe"], env: { ...process.env, TMPDIR: temp, CHROME_CONFIG_HOME: path.join(profile, "config") } }), stop);
272
+ } catch (err) {
273
+ drop();
274
+ throw failed(`Chrome could not start: ${err.message}`, { start: true });
275
+ }
276
+ // Settles when Chrome is gone, whether it exited or never started.
277
+ const gone = new Promise((resolve) => {
278
+ child.once("exit", resolve);
279
+ child.once("error", resolve);
280
+ });
281
+ let stderr = "";
282
+ child.stderr.on("data", (d) => (stderr = (stderr + d).slice(-8000)));
283
+ const told = new Promise((resolve) => child.stderr.once("close", resolve));
284
+ const pending = new Map();
285
+ const listeners = new Set();
286
+ let id = 0;
287
+ let buffer = Buffer.alloc(0);
288
+ let dead = null;
289
+ // Fails with Chrome's own error once Chrome is gone, so that a wait for
290
+ // something Chrome would have sent, as a page's load, ends with it: once
291
+ // Chrome is gone nothing else is left to keep the process going, and it
292
+ // would end in the middle of the run, its code 0, with nothing said.
293
+ let fall = () => {};
294
+ const lost = new Promise((resolve, reject) => (fall = reject));
295
+ lost.catch(() => {});
296
+ // Whether Chrome has answered once: what stops it before that stops it
297
+ // at the start.
298
+ let answered = false;
299
+ child.on("exit", async (code, signal) => {
300
+ // What Chrome printed last can come in after it exits. The wait keeps
301
+ // the process going, so the error is there for what waits on Chrome.
302
+ let wait = null;
303
+ await Promise.race([told, new Promise((resolve) => (wait = setTimeout(resolve, 500)))]);
304
+ clearTimeout(wait);
305
+ const why = whyStopped(stderr, temp);
306
+ // SIGKILL is how the system stops a program when memory runs out, as
307
+ // on a machine running many jobs, and such a program says nothing.
308
+ const how = signal === "SIGKILL" ? "Chrome was stopped by SIGKILL, as the system stops a program when memory runs out" : `Chrome stopped (${signal ? `killed by ${signal}` : `exit ${code}`})${why ? `: ${why}` : ""}`;
309
+ dead = failed(how, { start: !answered, signal });
310
+ for (const waiting of pending.values()) waiting.reject(dead);
311
+ pending.clear();
312
+ fall(dead);
313
+ });
314
+ child.on("error", (err) => {
315
+ dead = failed(`Chrome could not start: ${err.message}`, { start: true });
316
+ for (const waiting of pending.values()) waiting.reject(dead);
317
+ pending.clear();
318
+ fall(dead);
319
+ });
320
+ child.stdio[4].on("data", (chunk) => {
321
+ buffer = Buffer.concat([buffer, chunk]);
322
+ let end;
323
+ while ((end = buffer.indexOf(0)) >= 0) {
324
+ const message = JSON.parse(buffer.subarray(0, end).toString("utf8"));
325
+ buffer = buffer.subarray(end + 1);
326
+ if (message.id && pending.has(message.id)) {
327
+ const waiting = pending.get(message.id);
328
+ pending.delete(message.id);
329
+ if (message.error) waiting.reject(failed(message.error.message));
330
+ else waiting.resolve(message.result || {});
331
+ } else for (const listener of listeners) listener(message);
332
+ }
333
+ });
334
+ child.stdio[3].on("error", () => {});
335
+ // A call with no answer in `wait` ms, 60 s unless the call says, is an
336
+ // error, not a hang.
337
+ const send = (method, params = {}, sessionId, wait = 60_000) =>
338
+ new Promise((resolve, reject) => {
339
+ if (dead) return reject(dead);
340
+ const n = ++id;
341
+ const timer = setTimeout(() => {
342
+ if (pending.delete(n)) reject(failed(`Chrome did not answer ${method} within ${wait / 1000} s`, { start: !answered }));
343
+ }, wait);
344
+ timer.unref();
345
+ pending.set(n, { resolve: (v) => (clearTimeout(timer), resolve(v)), reject: (e) => (clearTimeout(timer), reject(e)) });
346
+ child.stdio[3].write(JSON.stringify({ id: n, method, params, ...(sessionId ? { sessionId } : {}) }) + "\0");
347
+ });
348
+ try {
349
+ await send("Browser.getVersion");
350
+ answered = true;
351
+ } catch (err) {
352
+ stop();
353
+ await gone;
354
+ drop();
355
+ throw err;
356
+ }
357
+ return {
358
+ send,
359
+ lost,
360
+ on: (fn) => listeners.add(fn),
361
+ off: (fn) => listeners.delete(fn),
362
+ async page() {
363
+ const { targetId } = await send("Target.createTarget", { url: "about:blank" });
364
+ const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
365
+ return { sessionId, targetId, call: (method, params, wait) => send(method, params, sessionId, wait) };
366
+ },
367
+ // Asks Chrome to quit, so it clears its own temp files, and kills it
368
+ // when it does not within two seconds. The profile goes after.
369
+ async close() {
370
+ if (!dead) send("Browser.close").catch(() => {});
371
+ const timer = setTimeout(stop, 2000);
372
+ await gone;
373
+ clearTimeout(timer);
374
+ // A helper process still going would write into the profile.
375
+ stop();
376
+ drop();
377
+ },
378
+ };
379
+ }
380
+
381
+ // Runs an expression in a page and gives its value, waiting for a promise
382
+ // it gives: for `timeout` ms, which Chrome holds it to, and a moment more
383
+ // for Chrome's answer, where a call is otherwise given 60 s, so the fit of
384
+ // a hundred thousand words gets the two minutes it is given.
385
+ async function evaluate(page, expression, timeout = 30_000) {
386
+ const r = await page.call("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true, timeout }, timeout + 10_000);
387
+ if (r.exceptionDetails) {
388
+ const d = r.exceptionDetails;
389
+ throw failed(`the captions page failed: ${((d.exception && d.exception.description) || d.text || "error").split("\n")[0]}`);
390
+ }
391
+ return r.result ? r.result.value : undefined;
392
+ }
393
+
394
+ // Opens an HTML file in a page of the given size, with a see-through
395
+ // background when `transparent`, and waits for it to load, for 30 s at
396
+ // most, or until Chrome is gone, with Chrome's own error. The timer keeps
397
+ // the process going while it waits.
398
+ async function openPage(browser, file, { width, height, transparent = false }) {
399
+ const page = await browser.page();
400
+ await page.call("Page.enable");
401
+ await page.call("Runtime.enable");
402
+ await page.call("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor: 1, mobile: false });
403
+ if (transparent) await page.call("Emulation.setDefaultBackgroundColorOverride", { color: { r: 0, g: 0, b: 0, a: 0 } });
404
+ let timer = null;
405
+ let on = null;
406
+ const loaded = new Promise((resolve) => {
407
+ on = (message) => {
408
+ if (message.sessionId === page.sessionId && message.method === "Page.loadEventFired") resolve(true);
409
+ };
410
+ browser.on(on);
411
+ timer = setTimeout(() => resolve(false), 30_000);
412
+ });
413
+ const { pathToFileURL } = require("node:url");
414
+ try {
415
+ await page.call("Page.navigate", { url: pathToFileURL(file).href });
416
+ if (!(await Promise.race([loaded, browser.lost]))) throw failed("the captions page did not load within 30 s");
417
+ } finally {
418
+ clearTimeout(timer);
419
+ browser.off(on);
420
+ }
421
+ return page;
422
+ }
423
+
424
+ module.exports = { findChrome, missingChrome, chromeNamedNothing, tempProblem, launch, evaluate, openPage, whyStopped, chromeTemp, clearOldProfiles, MISSING };