easycaptionkit 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,7 @@
1
1
  MIT License
2
2
 
3
3
  Copyright (c) 2026 Emin Budak
4
+ Copyright (c) 2026 Wiro AI Inc.
4
5
 
5
6
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
7
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -37,25 +37,36 @@ easycaptionkit-demo/
37
37
  - The captions run as long as the video's sound where the sound runs on past its picture, in the subtitle files too, and as long as its frames where it states no length, as a WebM a browser recorded does. A file whose frames and sound all end well before the length it states, as an interrupted download leaves it, is captioned as far as its frames go, and CAPTIONS.md says so.
38
38
  - CAPTIONS.md carries the license facts easyfontkit read for the font that was burned in, and the closest free font when it is not open. Whether to use it is your call. When the font has none of the letters, as a Latin font has no Japanese, CAPTIONS.md says that no letter of it is in the video and names the fonts of the machine that drew them.
39
39
 
40
+ ## Requirements
41
+
42
+ - Node.js 22 or newer.
43
+ - ffmpeg and ffprobe 4.4 or newer, Ubuntu 22.04's 4.4.2 included. To burn the captions, ffmpeg needs an H.264 encoder: libx264, the one every test has used, or VideoToolbox, OpenH264 or Media Foundation, which no test has run with. `--libass` needs it built with libass as well, which Homebrew's default ffmpeg is not; `subtitles` needs neither, and with `--video` it uses ffprobe alone. Apart from libass's ass filter, the filters it uses are ffmpeg's own, and so is the AAC encoder. `brew install ffmpeg` and `winget install ffmpeg` give a new enough one, and so does `sudo apt install ffmpeg` on Ubuntu 22.04, Debian 12 or newer. Set `FFMPEG_PATH` to use another build, as a path or a name on your PATH, and `FFPROBE_PATH` for an ffprobe that is not next to ffmpeg or on your PATH. Before any work, a run checks that ffmpeg and ffprobe start and answer as ffmpeg and ffprobe, ffprobe alone in `subtitles --video`; one that does not stops the run with what the system or the program said, and how to get one. When the system is short of open files, processes or memory to start one, the run says so and what to do. Then, before it draws anything, a run that burns captions checks ffmpeg's version number, its H.264 encoder and, for `--libass`, libass, and says what is missing; an ffmpeg that fails or is stopped when asked for its encoders or its filters stops the run with how it ended. An ffmpeg or ffprobe that gives no answer in two minutes when asked, as on a machine short of memory, stops the run too, and the message says to run it again. It checks no version number of ffprobe's.
44
+ - Google Chrome or Chromium, which draws the captions; `subtitles` starts none. Chrome runs in its sandbox unless easycaptionkit runs as root, where the sandbox does not start: on Linux, Google Chrome's own package sets the sandbox up, and a container has to let it start, as Docker does with `--security-opt seccomp=unconfined`. It has been tested with Google Chrome only: 155 on Ubuntu 26.04, 157 on Ubuntu 22.04 and the ones GitHub's ubuntu-22.04, ubuntu-24.04 and ubuntu-latest runners come with. No test has run with Chromium, or with Edge or Brave, which easycaptionkit also takes. It looks for all four on your PATH and where macOS installs them; on Windows it looks only where Chrome and Edge install themselves. Set `CHROME_PATH` to use another build, as a path or a name on your PATH. Before any work, a run that draws with Chrome checks that it starts and gives a version number when asked with `--version`, except on Windows, where Chrome prints none, and one that does not stops the run in the same way. With `--libass` ffmpeg draws the captions instead, and a run with no browser, or with one that does not start or gives no version number, makes plainer sheet.png and safe-zones.png.
45
+ - A font that has the letters of your captions, on the machine or in `--fonts`. A letter no font has is drawn as a box, as Japanese is on a server with no Japanese font; `fonts-noto-cjk` gives Debian and Ubuntu one.
46
+ - Linux, where it is tested: on Ubuntu 22.04 with its ffmpeg 4.4.2, on Ubuntu 24.04 with its ffmpeg 6.1 and on GitHub's ubuntu-latest runner, on every push and before every release. This release has also run on Ubuntu 26.04 with ffmpeg 8.0. It is written for macOS and Windows as well, but it has not been run on either.
47
+ - Room on the disk for the files of a run and those of the run before it, as the earlier files stay until the new ones are in place. captioned.mp4 is a new encode of the whole video and can come out larger than the video given. Until a run ends, it also keeps the pictures Chrome drew of the captions, a PNG of each state and an animated PNG of them all, in its hidden folder in the out folder. In the karaoke style, at the default letter size and two lines, they take about 75 KB a word on a 1080x1920 video, 150 KB on a 1920x1080 one and 360 KB on a 3840x2160 one, some 1.5 GB for an hour of speech at 1920x1080. With the largest letters and the most lines the options allow, `--size 20` and `--lines 3`, they take about twice as much, and in the line and word styles and with `--box` less. Each Chrome that easycaptionkit starts keeps a profile of 3 to 7 MB in the temp folder.
48
+ - Memory for ffmpeg's encode, more for a video of more pixels, more again for an HDR one, which stays in 10 bits, and on a machine with more cores. For a 1920x1080 or 1080x1920 video, ffmpeg took about 0.6 GB on 2 cores and 1.5 GB on 24, as much for ten minutes of video as for 30 seconds, and 1 GB and 2.4 GB for one in HDR. For a 3840x2160 video it took about 2 GB on 2 cores and 5 GB on 24, and 3.6 GB and 8.6 GB for one in HDR. The cores are the ones ffmpeg may run on, and a CPU quota does not lower their count: in a systemd scope held to 2 CPUs by CPUQuota=200% on a 24 core machine, where `nproc` said 2, the encode of a 1920x1080 video took as much as on all 24 cores, and 0.6 GB once `taskset` held it to 2 cores. A container held to 2 CPUs by `docker run --cpus=2` or by a Kubernetes CPU limit is held by such a quota, while `docker run --cpuset-cpus` holds it to cores, as taskset does. Ubuntu 22.04's ffmpeg 4.4.2 counts every core of the machine for its own decoding, whatever it is held to, and only x264 follows taskset and cpuset there. Chrome takes about 0.3 GB while it draws, before the encode and after it, and Node.js about 0.1 GB.
49
+ - A run needs no network access and no API key: easycaptionkit reads files on your disk, the browser runs offline with the font files there, and nothing leaves your machine. Installing it needs the network, and `npx easycaptionkit@latest` asks npm for the newest version each time it runs. There are no npm dependencies, and `subtitles` without `--video` needs Node.js alone.
50
+
40
51
  ## Use it with your agent
41
52
 
42
53
  easycaptionkit ships as an [Agent Skill](https://agentskills.io). Install it once and your agent runs it by itself when a video needs captions:
43
54
 
44
55
  ```bash
45
- npx skills add EminBudak/easycaptionkit
56
+ npx skills add MakersMCP/easycaptionkit
46
57
  ```
47
58
 
48
59
  That covers Claude Code, Codex, Cursor and the other agents the [`skills`](https://github.com/vercel-labs/skills) CLI supports. In Claude Code you can add it as a plugin instead:
49
60
 
50
61
  ```
51
- /plugin marketplace add EminBudak/easycaptionkit
62
+ /plugin marketplace add MakersMCP/easycaptionkit
52
63
  /plugin install easycaptionkit@easycaptionkit
53
64
  ```
54
65
 
55
66
  In GitHub Copilot CLI it takes the same two steps:
56
67
 
57
68
  ```bash
58
- copilot plugin marketplace add EminBudak/easycaptionkit
69
+ copilot plugin marketplace add MakersMCP/easycaptionkit
59
70
  copilot plugin install easycaptionkit@easycaptionkit
60
71
  ```
61
72
 
@@ -111,8 +122,6 @@ npx easycaptionkit@latest subtitles transcript.json --video film.mp4
111
122
 
112
123
  Installed globally (`npm install -g easycaptionkit`), the command is `easycaptionkit`. npx keeps the copy an earlier run fetched, so `npx easycaptionkit@latest` is how you get a newer version.
113
124
 
114
- It needs Node 22 or newer, ffmpeg 4.4 or newer and Chrome, Chromium, Edge or Brave. `brew install ffmpeg` and `winget install ffmpeg` give a new enough ffmpeg, and so does `sudo apt install ffmpeg` on Ubuntu 22.04, Debian 12 or newer; the tests run on Ubuntu 22.04's ffmpeg 4.4 and on the latest Ubuntu's, before every release too. Set `FFMPEG_PATH` and `CHROME_PATH` to pick them, as a path or a name on your PATH, and `FFPROBE_PATH` for an ffprobe that is not next to ffmpeg or on your PATH. There are no npm dependencies, and nothing leaves your machine: the browser runs offline with the font files on your disk.
115
-
116
125
  ## Where the words come from
117
126
 
118
127
  `--words` takes the words with their times from any of these, told apart by what is in the file, saved as UTF-8 or UTF-16:
@@ -165,11 +174,11 @@ With `--libass`, ffmpeg draws the captions itself from captions.ass with its ass
165
174
 
166
175
  ## See also
167
176
 
168
- - [easyfontkit](https://github.com/EminBudak/easyfontkit) saves the fonts a website really uses, with their licenses.
169
- - [easybrandkit](https://github.com/EminBudak/easybrandkit) saves a site's real logo files, colors and fonts as a brand kit.
170
- - [easyscreenshotkit](https://github.com/EminBudak/easyscreenshotkit) records a demo video of a site, with the steps.json captions can follow.
171
- - [launch-film](https://github.com/EminBudak/launch-film) turns a site into a short launch film, and its `check` measures any video.
172
- - [musicfit](https://github.com/EminBudak/musicfit) fits a music track to a video cut and ducks it under a voice.
177
+ - [easyfontkit](https://github.com/MakersMCP/easyfontkit) saves the fonts a website really uses, with their licenses.
178
+ - [easybrandkit](https://github.com/MakersMCP/easybrandkit) saves a site's real logo files, colors and fonts as a brand kit.
179
+ - [easyscreenshotkit](https://github.com/MakersMCP/easyscreenshotkit) records a demo video of a site, with the steps.json captions can follow.
180
+ - [launch-film](https://github.com/MakersMCP/launch-film) turns a site into a short launch film, and its `check` measures any video.
181
+ - [musicfit](https://github.com/MakersMCP/musicfit) fits a music track to a video cut and ducks it under a voice.
173
182
 
174
183
  ## License
175
184
 
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "easycaptionkit",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Burn captions into a video in the product's own fonts and colors, word by word or line by line, kept clear of each app's buttons, with the SRT, VTT and ASS files to go with it.",
5
5
  "license": "MIT",
6
6
  "author": "Emin Budak",
7
- "homepage": "https://github.com/EminBudak/easycaptionkit#readme",
7
+ "homepage": "https://github.com/MakersMCP/easycaptionkit#readme",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/EminBudak/easycaptionkit.git"
10
+ "url": "git+https://github.com/MakersMCP/easycaptionkit.git"
11
11
  },
12
- "bugs": "https://github.com/EminBudak/easycaptionkit/issues",
12
+ "bugs": "https://github.com/MakersMCP/easycaptionkit/issues",
13
13
  "bin": {
14
14
  "easycaptionkit": "skills/easycaptionkit/scripts/easycaptionkit.js"
15
15
  },
@@ -53,6 +53,10 @@ node <skill>/scripts/easycaptionkit.js subtitles transcript.json --video video.m
53
53
  - With `--libass`, CAPTIONS.md can say the video shows boxes for some emoji: libass draws no color emoji. Run again without `--libass` to have Chrome draw them.
54
54
  - CAPTIONS.md says Chrome found no font for some letters, or that no font libass can use has them: the video shows a box for each, as it does for Japanese on a server with no Japanese font. Install a font that has them, such as Noto Sans CJK (`fonts-noto-cjk` on Debian and Ubuntu), or give `--fonts` an easyfontkit folder whose font has them, and run again. Do not hand over a video with boxes in its captions.
55
55
  - No Chrome on the machine: the run stops. `--libass` draws the captions with ffmpeg's libass instead, which needs an ffmpeg built with libass (Homebrew's default ffmpeg is not).
56
+ - "easycaptionkit needs ffmpeg", "easycaptionkit needs ffprobe" or "easycaptionkit needs Chrome": the run stopped before any work, as it found none, or the one it found or the variable names could not be started, did not answer as one when asked its version or, for ffmpeg, failed when asked its encoders or its filters. The message names that file, by `FFMPEG_PATH`, `FFPROBE_PATH`, `CHROME_PATH` or PATH, and gives what is at its path, as a folder, or what the system said, or the first line the program printed and how it ended: tell the user, and ask them to install the program or to point the variable at one that runs, and never guess a path. Where it is Chrome, `--libass` draws the captions without it.
57
+ - "easycaptionkit could not start ffmpeg:", or ffprobe or Chrome, and what the system is short of: the system could not start a program then, as when too many files are open, and the message says what to do, such as raising the limit on open files or running it again once fewer processes run. The program is not at fault, so do not ask the user to install another.
58
+ - "easycaptionkit could not start ffmpeg, <its path>, again", or ffprobe or Chrome: the program answered when the run began, and the system could not start it later in the run, as when it was taken away or changed in the meantime. Tell the user what the system said, and run again: a program that is still wrong then stops the run before any work, with what is wrong with it.
59
+ - "easycaptionkit could not check ffmpeg", or ffprobe or Chrome: the program gave no answer in two minutes when asked its version or, for ffmpeg, its encoders or its filters, as on a machine short of memory. Run again, and if it gives no answer again, tell the user, with the path the message names.
56
60
  - "Chrome could not start its sandbox here", or another reason Chrome stopped as it started (in a container, or on a CI runner without user namespaces): the run stops before the encode. `--libass` draws the captions without Chrome, and ffmpeg makes plainer sheet.png and safe-zones.png, which CAPTIONS.md notes.
57
61
  - A run killed outright (`kill -9`, or the system when memory runs out) cannot tidy up: its hidden `.easycaptionkit-work-` folder stays in the out folder, where an ffmpeg it started can go on writing captioned.mp4, and its Chrome profile stays in the temporary folder. The captioned.mp4 in that folder is no result to hand over: run again. The next run into the folder removes the work folder, and a later run of the same user that starts Chrome removes the profile once no process names it and it has not changed for a day, except on Windows, where it stays in the temporary folder until Windows or the user clears it.
58
62
  - The safe zones are the apps' ad guides or estimates, each with its source and date. Tell the user to check one post on a phone.
@@ -18,7 +18,7 @@ const { captionLayout, charsThatFit, defaultPlaces, shapeOf } = require("./place
18
18
  const { chooseLook, variableFace, machineFont, fallbackFont } = require("./look");
19
19
  const { fontLike } = require("./fontfile");
20
20
  const ffmpeg = require("./ffmpeg");
21
- const { findChrome, missingChrome, chromeNamedNothing, tempProblem } = require("./chrome");
21
+ const { chromeReady, chromeProblem, tempProblem } = require("./chrome");
22
22
  const { renderStates } = require("./render");
23
23
  const { safeZonesPng, sheetPng, plainPictures, shotOf } = require("./draw");
24
24
  const { captionsMarkdown, captionsJson, subtitlesMarkdown, arg, subtitlesCommand, shown, captionsOf, lettersOf, boxesText, installText } = require("./report");
@@ -945,15 +945,22 @@ async function captionVideo(videoFile, options) {
945
945
  writable(dir);
946
946
  const notes = recover(path.resolve(dir), dir);
947
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
948
  const useLibass = Boolean(options.libass);
950
- if (!useLibass && !findChrome()) throw new Error(missingChrome());
951
949
  // Chrome keeps its profile in the temporary folder.
952
950
  const temp = useLibass ? "" : tempProblem();
953
951
  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)]);
952
+ // Each program the run uses is asked its version before the video is
953
+ // read, Chrome while ffmpeg is: one that cannot be started or does not
954
+ // answer as itself stops the run with what the system or the program
955
+ // said, ffmpeg's first.
956
+ const chrome = useLibass ? null : chromeReady();
957
+ if (chrome) chrome.catch(() => {});
958
+ const tools = await ffmpeg.ready();
959
+ if (chrome) await chrome;
960
+ const bins = { ffmpeg: tools.ffmpeg, ffprobe: tools.ffprobe, places: tools.places };
961
+ const [video, caps] = await Promise.all([ffmpeg.readVideo(videoFile, bins), ffmpeg.capabilities(bins, tools.version)]);
955
962
  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");
963
+ if (!caps.encoder) throw new Error("This ffmpeg has no H.264 encoder (libx264, VideoToolbox, OpenH264 or Media Foundation), so it cannot write the captioned MP4");
957
964
  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
965
  if (!useLibass && !caps.overlay) throw new Error("This ffmpeg has no overlay filter, so it cannot lay the captions over the video");
959
966
  const read = await loadWords(options.words);
@@ -1077,9 +1084,11 @@ async function captionVideo(videoFile, options) {
1077
1084
  // A Chrome that cannot start here (no usable sandbox, as in a
1078
1085
  // container) stops the run before the encode; libass needs none. A
1079
1086
  // Chrome the system stopped as memory ran out can draw with fewer
1080
- // jobs at once, or a smaller video.
1081
- 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";
1082
- else if (err.chrome && err.start) err.message += ". Pass --libass to have ffmpeg draw the captions without Chrome";
1087
+ // jobs at once, or a smaller video. A message that ends with a full
1088
+ // stop, as one of what to do does, keeps only that one.
1089
+ const said = err.message.replace(/\.$/, "");
1090
+ if (err.chrome && err.signal === "SIGKILL") err.message = `${said}. Run again with fewer jobs at once or with a smaller video, or pass --libass to have ffmpeg draw the captions without Chrome`;
1091
+ else if (err.chrome && err.start) err.message = `${said}. Pass --libass to have ffmpeg draw the captions without Chrome`;
1083
1092
  throw err;
1084
1093
  }
1085
1094
  fs.writeFileSync(path.join(run.work, "captions.ass"), assFile(drawn.scales));
@@ -1126,8 +1135,10 @@ async function captionVideo(videoFile, options) {
1126
1135
  const sheetFile = path.join(run.work, "sheet.png");
1127
1136
  // Chrome lays the pictures out. Where there is none, or it cannot start
1128
1137
  // or stops, ffmpeg makes plainer ones: the captions are in the video
1129
- // by now, and the pictures only show them.
1130
- let plain = findChrome() ? tempProblem() : chromeNamedNothing() || "no Chrome was found";
1138
+ // by now, and the pictures only show them. A run that drew them with
1139
+ // libass asks Chrome its version here, which a run that drew them with
1140
+ // Chrome did before any work, with the temporary folder.
1141
+ let plain = useLibass ? (await chromeProblem()) || tempProblem() : "";
1131
1142
  if (!plain) {
1132
1143
  try {
1133
1144
  await safeZonesPng({ bins, video, output: out, zones: place.zones, cue: widest, times, file: zonesFile, dir: run.work, frameTags });
@@ -1321,6 +1332,9 @@ async function makeSubtitles(wordsFile, options) {
1321
1332
  writable(dir);
1322
1333
  const notes = recover(path.resolve(dir), dir);
1323
1334
  const view = guard(dir, { words: wordsFile, video: options.video, brand: options.brand, fonts: options.fonts }, { subtitles: true, options });
1335
+ // The video is read with ffprobe alone, which is asked its version first;
1336
+ // no ffmpeg is started, so nothing at ffmpeg's path stops the run.
1337
+ const bins = options.video ? await ffmpeg.readyProbe() : null;
1324
1338
  const read = await loadWords(wordsFile);
1325
1339
  const first = firstFormat(read, origin);
1326
1340
  const look = chooseLook({ ...options, ...scriptsOf(read.words) });
@@ -1328,7 +1342,7 @@ async function makeSubtitles(wordsFile, options) {
1328
1342
  notes.push(...read.notes, ...look.notes);
1329
1343
  let video = null;
1330
1344
  if (options.video) {
1331
- video = await ffmpeg.readVideo(options.video, ffmpeg.tools());
1345
+ video = await ffmpeg.readVideo(options.video, bins);
1332
1346
  notStill(video, options.video);
1333
1347
  notes.push(...video.notes);
1334
1348
  }
@@ -12,49 +12,86 @@ const fs = require("node:fs");
12
12
  const os = require("node:os");
13
13
  const path = require("node:path");
14
14
  const { spawn, spawnSync } = require("node:child_process");
15
- const { onPath, given, namedNothing } = require("./ffmpeg");
15
+ const programs = require("./programs");
16
16
  const { track, trackFolder, untrackFolder, kill, sleep } = require("./running");
17
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
- }
18
+ const LIBASS = "--libass has ffmpeg draw the captions without Chrome.";
44
19
 
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.";
20
+ // Chrome as a run looks for it and asks it its version (see
21
+ // programs.find()): the path CHROME_PATH gives, or a name on PATH, as
22
+ // FFMPEG_PATH is read; else where macOS and Windows install it, and on
23
+ // PATH by each of its names. It answers with a version number, as "Google
24
+ // Chrome 155.0.8059.84". On Windows, where Chrome prints no version, it is
25
+ // not asked, and taken once nothing keeps it from running.
26
+ const CHROME = {
27
+ name: "Chrome",
28
+ variable: "CHROME_PATH",
29
+ args: ["--version"],
30
+ answers: (stdout) => /\d+\.\d+/.test(stdout),
31
+ names: ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome", "microsoft-edge", "brave-browser"],
32
+ first: () => {
33
+ const mac = [
34
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
35
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
36
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
37
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
38
+ path.join(os.homedir(), "Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
39
+ ];
40
+ const win = [
41
+ path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Google\\Chrome\\Application\\chrome.exe"),
42
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google\\Chrome\\Application\\chrome.exe"),
43
+ path.join(process.env.LOCALAPPDATA || "", "Google\\Chrome\\Application\\chrome.exe"),
44
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Microsoft\\Edge\\Application\\msedge.exe"),
45
+ ];
46
+ const listed = process.platform === "darwin" ? mac : process.platform === "win32" ? win : [];
47
+ return listed.filter(Boolean).map((file) => ({ file, name: "Chrome", where: "installed on this machine" }));
48
+ },
49
+ ask: process.platform !== "win32",
50
+ none: `easycaptionkit needs Chrome to draw the captions, and none was found. Install Google Chrome or Chromium, or set CHROME_PATH to one. ${LIBASS}`,
51
+ advice: {
52
+ variable: `Set CHROME_PATH to a Chrome that runs, or unset it and install Google Chrome or Chromium. ${LIBASS}`,
53
+ found: `Install Google Chrome or Chromium, or set CHROME_PATH to a Chrome that runs. ${LIBASS}`,
54
+ late: `Run easycaptionkit again. ${LIBASS}`,
55
+ },
56
+ };
46
57
 
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");
58
+ // The Chrome a run would take, by what is at its place alone, not run, or
59
+ // "" where there is none.
60
+ const findChrome = () => programs.locate(CHROME);
61
+
62
+ // The Chrome that answered, for the CHROME_PATH, the PATH and the folder
63
+ // the run works with now: a run asks it once, and every Chrome it starts
64
+ // to draw is that one. A failure is not kept, so a later call asks again.
65
+ let checked = null;
66
+
67
+ // The Chrome a run draws with, found and asked its version before any work,
68
+ // so one that cannot be started or does not answer as Chrome stops the run
69
+ // with what the system or the program said, not once it begins to draw.
70
+ async function chromeReady(limit = programs.LIMIT) {
71
+ let here = "";
72
+ try {
73
+ here = process.cwd();
74
+ } catch {
75
+ // A folder taken away since: only PATH's relative entries read from it.
76
+ }
77
+ const key = JSON.stringify([process.env.CHROME_PATH, process.env.PATH, here]);
78
+ if (checked && checked.key === key) return checked.file;
79
+ const { file } = await programs.find(CHROME, limit);
80
+ checked = { key, file };
81
+ return file;
51
82
  }
52
83
 
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;
84
+ // For a run that draws without Chrome and uses it for its pictures alone:
85
+ // why there is no Chrome to use, as words that follow "since", or "" when
86
+ // it answers.
87
+ async function chromeProblem() {
88
+ try {
89
+ await chromeReady();
90
+ return "";
91
+ } catch (err) {
92
+ if (!err.unusable) throw err;
93
+ return err.why;
94
+ }
58
95
  }
59
96
 
60
97
  // An error of Chrome's own, not of a file or of ffmpeg: `start` when Chrome
@@ -225,9 +262,17 @@ function whyStopped(stderr, temp) {
225
262
  return text;
226
263
  }
227
264
 
265
+ // Starts the Chrome a run checked before any work (see chromeReady()),
266
+ // asking it first where it was not. A start the system refuses says what
267
+ // the system said, never its code alone.
228
268
  async function launch({ width = 1920, height = 1080 } = {}) {
229
- const chrome = findChrome();
230
- if (!chrome) throw failed(missingChrome(), { start: true });
269
+ let chrome;
270
+ try {
271
+ chrome = await chromeReady();
272
+ } catch (err) {
273
+ throw failed(err.message, { start: true });
274
+ }
275
+ const refused = (err) => failed(programs.startFailure(chrome, err, "Chrome").message, { start: true });
231
276
  const temp = chromeTemp();
232
277
  clearOldProfiles();
233
278
  // A profile Chrome cannot have is a Chrome that cannot start.
@@ -271,13 +316,20 @@ async function launch({ width = 1920, height = 1080 } = {}) {
271
316
  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
317
  } catch (err) {
273
318
  drop();
274
- throw failed(`Chrome could not start: ${err.message}`, { start: true });
319
+ throw refused(err);
275
320
  }
276
321
  // Settles when Chrome is gone, whether it exited or never started.
277
322
  const gone = new Promise((resolve) => {
278
323
  child.once("exit", resolve);
279
324
  child.once("error", resolve);
280
325
  });
326
+ // With no file descriptor left for them Node makes no pipes, and says
327
+ // why a moment later.
328
+ if (!child.stdio || !child.stderr || !child.stdio[3] || !child.stdio[4]) {
329
+ const err = await new Promise((resolve) => child.once("error", resolve));
330
+ drop();
331
+ throw refused(err);
332
+ }
281
333
  let stderr = "";
282
334
  child.stderr.on("data", (d) => (stderr = (stderr + d).slice(-8000)));
283
335
  const told = new Promise((resolve) => child.stderr.once("close", resolve));
@@ -312,7 +364,7 @@ async function launch({ width = 1920, height = 1080 } = {}) {
312
364
  fall(dead);
313
365
  });
314
366
  child.on("error", (err) => {
315
- dead = failed(`Chrome could not start: ${err.message}`, { start: true });
367
+ dead = refused(err);
316
368
  for (const waiting of pending.values()) waiting.reject(dead);
317
369
  pending.clear();
318
370
  fall(dead);
@@ -421,4 +473,4 @@ async function openPage(browser, file, { width, height, transparent = false }) {
421
473
  return page;
422
474
  }
423
475
 
424
- module.exports = { findChrome, missingChrome, chromeNamedNothing, tempProblem, launch, evaluate, openPage, whyStopped, chromeTemp, clearOldProfiles, MISSING };
476
+ module.exports = { CHROME, findChrome, chromeReady, chromeProblem, tempProblem, launch, evaluate, openPage, whyStopped, chromeTemp, clearOldProfiles };
@@ -8,82 +8,69 @@ const fs = require("node:fs");
8
8
  const path = require("node:path");
9
9
  const { spawn } = require("node:child_process");
10
10
  const { track } = require("./running");
11
+ const programs = require("./programs");
12
+
13
+ const { exe } = programs;
11
14
 
12
15
  // Where package managers put it, for an agent or app whose PATH is short.
13
16
  const KNOWN = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/snap/bin"];
14
17
 
15
- // The first program by a name on PATH, in a folder `skip` does not pass
16
- // over.
17
- function onPath(name, { skip = () => false } = {}) {
18
- for (const dir of String(process.env.PATH || "").split(path.delimiter)) {
19
- if (!dir || skip(dir)) continue;
20
- const file = path.join(dir, name);
21
- try {
22
- fs.accessSync(file, fs.constants.X_OK);
23
- if (fs.statSync(file).isFile()) return file;
24
- } catch {
25
- // Not here.
26
- }
27
- }
28
- return "";
29
- }
30
-
31
- const exe = (name) => (process.platform === "win32" ? `${name}.exe` : name);
18
+ const INSTALL = "brew install ffmpeg, sudo apt install ffmpeg or winget install ffmpeg";
32
19
 
33
- const bare = (value) => !(value.includes("/") || (process.platform === "win32" && value.includes("\\")));
34
- const isFile = (file) => {
35
- try {
36
- return fs.statSync(file).isFile();
37
- } catch {
38
- return false;
39
- }
20
+ // ffmpeg as a run looks for it and asks it its version (see programs.find()).
21
+ const FFMPEG = {
22
+ name: "ffmpeg",
23
+ variable: "FFMPEG_PATH",
24
+ args: ["-version"],
25
+ answers: (stdout) => /^ffmpeg version\b/m.test(stdout),
26
+ names: [exe("ffmpeg")],
27
+ known: KNOWN,
28
+ none: `easycaptionkit needs ffmpeg, and none was found. Install it (${INSTALL}) or set FFMPEG_PATH to one.`,
29
+ advice: {
30
+ variable: `Set FFMPEG_PATH to an ffmpeg that runs, or unset it and install one (${INSTALL}).`,
31
+ found: `Install ffmpeg (${INSTALL}) or set FFMPEG_PATH to an ffmpeg that runs.`,
32
+ late: "Run easycaptionkit again.",
33
+ },
40
34
  };
41
35
 
42
- // A program named in the environment, as a whole path: a path is taken
43
- // from the folder the run started in, so a program run in another folder
44
- // (the encode runs in its work folder) still finds it, and a bare name is
45
- // looked up on PATH, as a shell would. Empty when it is not there.
46
- function given(value) {
47
- if (bare(value)) return onPath(value) || onPath(exe(value));
48
- const file = path.resolve(value);
49
- return isFile(file) ? file : "";
50
- }
51
-
52
- // What an environment variable that names a program names, when that is
53
- // no program: "FFMPEG_PATH is /opt/ffmpeg, and there is no file there".
54
- // Empty when it is not set or names one.
55
- function namedNothing(variable) {
56
- const value = process.env[variable];
57
- if (!value || given(value)) return "";
58
- const what = bare(value) ? "and no program by that name is on PATH" : fs.existsSync(path.resolve(value)) ? "a folder, not a program" : "and there is no file there";
59
- return `${variable} is ${value}, ${what}`;
60
- }
61
-
62
- function findFfmpeg() {
63
- if (process.env.FFMPEG_PATH) return given(process.env.FFMPEG_PATH);
64
- return onPath(exe("ffmpeg")) || (process.platform === "win32" ? "" : KNOWN.map((d) => path.join(d, "ffmpeg")).find((f) => fs.existsSync(f)) || "");
65
- }
36
+ // ffprobe, which sits next to ffmpeg in every package: the one next to the
37
+ // ffmpeg at `ffmpeg`, when that is not ffmpeg's own file, else as ffmpeg is
38
+ // looked for.
39
+ const FFPROBE = (ffmpeg) => ({
40
+ name: "ffprobe",
41
+ variable: "FFPROBE_PATH",
42
+ args: ["-version"],
43
+ answers: (stdout) => /^ffprobe version\b/m.test(stdout),
44
+ names: [exe("ffprobe")],
45
+ known: KNOWN,
46
+ first: () => (ffmpeg ? [{ file: path.join(path.dirname(ffmpeg), exe("ffprobe")), name: "ffprobe", where: `next to ${ffmpeg}`, next: true }].filter((place) => place.file !== ffmpeg) : []),
47
+ none: `easycaptionkit needs ffprobe, and none was found${ffmpeg ? ` next to ${ffmpeg} or` : ""} on PATH. Install ffmpeg, which comes with ffprobe (${INSTALL}), or set FFPROBE_PATH to one.`,
48
+ advice: {
49
+ variable: `Set FFPROBE_PATH to an ffprobe that runs, or unset it and install ffmpeg, which comes with ffprobe (${INSTALL}).`,
50
+ next: "Set FFPROBE_PATH to an ffprobe that runs.",
51
+ found: `Install ffmpeg, which comes with ffprobe (${INSTALL}), or set FFPROBE_PATH to an ffprobe that runs.`,
52
+ late: "Run easycaptionkit again.",
53
+ },
54
+ });
66
55
 
67
- // ffprobe sits next to ffmpeg in every package.
68
- function findFfprobe(ffmpeg) {
69
- if (process.env.FFPROBE_PATH) return given(process.env.FFPROBE_PATH);
70
- const next = ffmpeg ? path.join(path.dirname(ffmpeg), exe("ffprobe")) : "";
71
- if (next && fs.existsSync(next)) return next;
72
- return onPath(exe("ffprobe")) || (process.platform === "win32" ? "" : KNOWN.map((d) => path.join(d, "ffprobe")).find((f) => fs.existsSync(f)) || "");
56
+ // ffmpeg and ffprobe, each found and asked its version before any work,
57
+ // as { ffmpeg, ffprobe, version, places }: their files, what ffmpeg said,
58
+ // which capabilities() reads, and where each was found, for the words of
59
+ // a later failure. ffprobe is looked for next to the ffmpeg that answered.
60
+ // A program that cannot be started or does not answer as itself stops the
61
+ // run there, as programs.find() says, ffmpeg's first.
62
+ async function ready(limit = programs.LIMIT) {
63
+ const ffmpeg = await programs.find(FFMPEG, limit);
64
+ const ffprobe = await programs.find(FFPROBE(ffmpeg.file), limit);
65
+ return { ffmpeg: ffmpeg.file, ffprobe: ffprobe.file, version: ffmpeg.stdout, places: { ffmpeg: ffmpeg.place, ffprobe: ffprobe.place } };
73
66
  }
74
67
 
75
- const MISSING = "ffmpeg and ffprobe were not found. Install them (brew install ffmpeg, sudo apt install ffmpeg or winget install ffmpeg) or set FFMPEG_PATH and FFPROBE_PATH to them.";
76
-
77
- function tools() {
78
- for (const [variable, name] of [["FFMPEG_PATH", "ffmpeg"], ["FFPROBE_PATH", "ffprobe"]]) {
79
- const nothing = namedNothing(variable);
80
- if (nothing) throw new Error(`${nothing}. Set it to the path of ${name}, or unset it to use the one on PATH.`);
81
- }
82
- const ffmpeg = findFfmpeg();
83
- const ffprobe = findFfprobe(ffmpeg);
84
- if (!ffmpeg) throw new Error(MISSING);
85
- if (!ffprobe) throw new Error(`ffprobe was not found next to ${ffmpeg} or on PATH. It comes with ffmpeg: install it, or set FFPROBE_PATH to it.`);
86
- return { ffmpeg, ffprobe };
68
+ // ffprobe alone, for a run that reads a video and starts no ffmpeg: found
69
+ // and asked as ready() does, next to the ffmpeg the run would take, which
70
+ // is not run, so nothing at ffmpeg's path stops it.
71
+ async function readyProbe(limit = programs.LIMIT) {
72
+ const ffprobe = await programs.find(FFPROBE(programs.locate(FFMPEG)), limit);
73
+ return { ffprobe: ffprobe.file, places: { ffprobe: ffprobe.place } };
87
74
  }
88
75
 
89
76
  // What ffmpeg prints once it has given up, after the lines that say why:
@@ -92,7 +79,9 @@ function tools() {
92
79
  // ffmpeg 7 and newer with the others, and ffmpeg 8 says last that its
93
80
  // encoder never opened and that it wrote nothing, after a filter that
94
81
  // could not start, or that filtering failed, after the scaler said which
95
- // frame it could not convert.
82
+ // frame it could not convert. ffmpeg 6.1 says it could not start a simple
83
+ // filtergraph, the one -vf gives, after the line that names the option
84
+ // its filter refused.
96
85
  const CLOSING = [
97
86
  /^Could not open encoder before EOF/,
98
87
  /^Error while filtering\b/,
@@ -104,6 +93,7 @@ const CLOSING = [
104
93
  /^Error initializing complex filters\.?$/,
105
94
  /^Error opening filters!?$/,
106
95
  /^Exiting normally/,
96
+ /^Error initializing a simple filtergraph$/,
107
97
  /^(?:Invalid argument|Operation not permitted|Cannot allocate memory|Invalid data found when processing input|Unknown error occurred)$/,
108
98
  /^Error opening (?:input|output) files?\b/,
109
99
  /^Error selecting an encoder$/,
@@ -149,20 +139,42 @@ function whyFailed({ code, signal, stderr }, who = "it") {
149
139
  // error, not a hang, unless what `busy` gives, as the size and the time of
150
140
  // the file it writes, changed in that time: ffmpeg prints nothing while it
151
141
  // writes a whole MP4 again to move its index to the front. With `binary`,
152
- // stdout is the bytes it wrote, not text.
142
+ // stdout is the bytes it wrote, not text. The programs are the ones a run
143
+ // checked before any work, so one that cannot be started now says what
144
+ // the system said, or that the folder it was to run in is gone, never the
145
+ // system's code alone: Node throws some of those errors at once and gives
146
+ // the others a moment later.
153
147
  function run(binary, args, { quiet = 120_000, onStdout = null, cwd = undefined, busy = null, binary: bytes = false } = {}) {
154
148
  return new Promise((resolve, reject) => {
149
+ const failed = (err) => {
150
+ if (!/^spawn\b/.test(err.syscall || "")) return err;
151
+ // A start that fails for want of the folder it runs in is no fault of
152
+ // the program's.
153
+ if (cwd && !programs.enterable(cwd)) return new Error(`${path.basename(binary)} could not be started in ${cwd}, a folder that is not there any more or may not be entered`);
154
+ return programs.startFailure(binary, err);
155
+ };
155
156
  let child;
156
157
  try {
157
158
  child = track(spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"], cwd }));
158
159
  } catch (err) {
159
- reject(err);
160
+ reject(failed(err));
160
161
  return;
161
162
  }
162
163
  let stdout = "";
163
164
  const chunks = [];
164
165
  let stderr = "";
165
166
  let timer = null;
167
+ child.on("error", (err) => {
168
+ clearTimeout(timer);
169
+ reject(failed(err));
170
+ });
171
+ child.on("close", (code, signal) => {
172
+ clearTimeout(timer);
173
+ resolve({ code, signal, stdout: bytes ? Buffer.concat(chunks) : stdout, stderr });
174
+ });
175
+ // With no file descriptor left for them Node makes no pipes, and says
176
+ // why a moment later.
177
+ if (!child.stdout || !child.stderr) return;
166
178
  const arm = () => {
167
179
  clearTimeout(timer);
168
180
  const before = busy ? busy() : null;
@@ -184,14 +196,6 @@ function run(binary, args, { quiet = 120_000, onStdout = null, cwd = undefined,
184
196
  arm();
185
197
  stderr = (stderr + d).slice(-40_000);
186
198
  });
187
- child.on("error", (err) => {
188
- clearTimeout(timer);
189
- reject(err.code === "ENOENT" ? new Error(MISSING) : err);
190
- });
191
- child.on("close", (code, signal) => {
192
- clearTimeout(timer);
193
- resolve({ code, signal, stdout: bytes ? Buffer.concat(chunks) : stdout, stderr });
194
- });
195
199
  });
196
200
  }
197
201
 
@@ -564,15 +568,21 @@ const round3 = (value) => Math.round(value * 1000) / 1000;
564
568
 
565
569
  // ─── what this ffmpeg can do ──────────────────────────────────────────────
566
570
 
567
- async function capabilities(bins) {
568
- const [about, filters, encoders] = await Promise.all([run(bins.ffmpeg, ["-hide_banner", "-version"], { quiet: 20_000 }), run(bins.ffmpeg, ["-hide_banner", "-filters"], { quiet: 20_000 }), run(bins.ffmpeg, ["-hide_banner", "-encoders"], { quiet: 20_000 })]);
571
+ // What this ffmpeg can do: its version number, from what it said when
572
+ // ready() asked it, and its filters and encoders, each asked of it and
573
+ // read only once it ended well (see programs.probe()), so an ffmpeg that
574
+ // fails or is stopped when asked is said to, not taken for one with no
575
+ // H.264 encoder, no overlay filter or no libass.
576
+ async function capabilities(bins, version) {
577
+ const asked = (args, what) => programs.probe(FFMPEG, bins.places.ffmpeg, ["-hide_banner", ...args], what);
578
+ const [filters, encoders] = await Promise.all([asked(["-filters"], "its filters"), asked(["-encoders"], "its encoders")]);
569
579
  // 4.4 is the oldest ffmpeg the kit is tried on: Ubuntu 22.04's. A build
570
580
  // from git says no number and is newer.
571
- const v = /ffmpeg version n?(\d+)\.(\d+)/.exec(about.stdout);
581
+ const v = /ffmpeg version n?(\d+)\.(\d+)/.exec(version);
572
582
  const atLeast = (major, minor) => !v || Number(v[1]) > major || (Number(v[1]) === major && Number(v[2]) >= minor);
573
583
  if (!atLeast(4, 4)) throw new Error(`ffmpeg 4.4 or newer is needed, and this one is ${v[1]}.${v[2]}. Install a newer one (brew install ffmpeg, or a static build) or set FFMPEG_PATH to it.`);
574
- const hasFilter = (name) => new RegExp(`^\\s*\\S+\\s+${name}\\s`, "m").test(filters.stdout);
575
- const hasEncoder = (name) => new RegExp(`^\\s*V\\S*\\s+${name}\\s`, "m").test(encoders.stdout);
584
+ const hasFilter = (name) => new RegExp(`^\\s*\\S+\\s+${name}\\s`, "m").test(filters);
585
+ const hasEncoder = (name) => new RegExp(`^\\s*V\\S*\\s+${name}\\s`, "m").test(encoders);
576
586
  let encoder = null;
577
587
  // No B-frames: with them the file starts behind an edit list, and a tool
578
588
  // that reads frames without it is two frames off the captions' times.
@@ -581,11 +591,7 @@ async function capabilities(bins) {
581
591
  else if (hasEncoder("libopenh264")) encoder = { name: "libopenh264", args: ["-c:v", "libopenh264", "-b:v", "12M"] };
582
592
  else if (hasEncoder("h264_mf")) encoder = { name: "h264_mf", args: ["-c:v", "h264_mf", "-b:v", "12M"] };
583
593
  // Whether x264 here writes 10 bits, which an HDR video needs to stay HDR.
584
- let tenBit = false;
585
- if (encoder && encoder.name === "libx264") {
586
- const help = await run(bins.ffmpeg, ["-hide_banner", "-h", "encoder=libx264"], { quiet: 20_000 });
587
- tenBit = /yuv420p10le/.test(help.stdout);
588
- }
594
+ const tenBit = Boolean(encoder && encoder.name === "libx264" && /yuv420p10le/.test(await asked(["-h", "encoder=libx264"], "about its libx264")));
589
595
  const setparams = hasFilter("setparams");
590
596
  return {
591
597
  // -fps_mode came in 5.1, in the place of -vsync.
@@ -594,10 +600,10 @@ async function capabilities(bins) {
594
600
  libass: hasFilter("ass"),
595
601
  setparams,
596
602
  // The names setparams takes for each of its options.
597
- colorNames: setparams ? optionNames((await run(bins.ffmpeg, ["-hide_banner", "-h", "filter=setparams"], { quiet: 20_000 })).stdout) : {},
603
+ colorNames: setparams ? optionNames(await asked(["-h", "filter=setparams"], "about its setparams filter")) : {},
598
604
  encoder,
599
605
  tenBit,
600
- aac: /^\s*A\S*\s+aac\s/m.test(encoders.stdout),
606
+ aac: /^\s*A\S*\s+aac\s/m.test(encoders),
601
607
  };
602
608
  }
603
609
 
@@ -925,7 +931,7 @@ const rateBase = (rate) => {
925
931
  // The captions show on the video's own frames. Where it has too few for
926
932
  // them, as fillFor says, `fill` gives it frames at a steady rate first.
927
933
  async function burn({ bins, caps, video: v, output, overlay = null, ass = null, assMatrix = "", assFull = false, fontsDir = "", fill = null, onProgress = null, quiet = 180_000 }) {
928
- if (!caps.encoder) throw new Error("This ffmpeg has no H.264 encoder (libx264, VideoToolbox or OpenH264), so it cannot write the captioned MP4");
934
+ if (!caps.encoder) throw new Error("This ffmpeg has no H.264 encoder (libx264, VideoToolbox, OpenH264 or Media Foundation), so it cannot write the captioned MP4");
929
935
  const color = colorOf(v, caps.colorNames);
930
936
  // An HDR video stays HDR where x264 writes 10 bits.
931
937
  const deep = v.hdr && caps.tenBit;
@@ -1029,4 +1035,4 @@ async function frameAt(bins, file, t, output, { width = 0, retag = "", untag = "
1029
1035
  return output;
1030
1036
  }
1031
1037
 
1032
- module.exports = { tools, findFfmpeg, findFfprobe, given, namedNothing, probe, readVideo, packetsOf, frameTimes, rateOf, steadyRate, fillFor, capabilities, burn, frameAt, run, whyFailed, optionNames, onPath, MISSING, colorOf, prepareChain, filterPath, libassFilter, hdrLevel, fineBase, matrixLine, as601, libassFollows, assColors, pixelFormat };
1038
+ module.exports = { FFMPEG, FFPROBE, ready, readyProbe, onPath: programs.onPath, probe, readVideo, packetsOf, frameTimes, rateOf, steadyRate, fillFor, capabilities, burn, frameAt, run, whyFailed, optionNames, colorOf, prepareChain, filterPath, libassFilter, hdrLevel, fineBase, matrixLine, as601, libassFollows, assColors, pixelFormat };
@@ -0,0 +1,478 @@
1
+ "use strict";
2
+
3
+ // The programs a run starts, ffmpeg, ffprobe and Chrome: where each one
4
+ // is, and whether it starts and answers as itself, asked before any work.
5
+ // Of one that does not, a run says only what is certain: what is at its
6
+ // path, as a folder or a file this user may not run, else what the system
7
+ // said when it could not start it, the first line the program printed and
8
+ // how it ended, or that it gave no answer in time. That line is the
9
+ // program's own, and says in its words what is at fault: nothing here
10
+ // guesses which file of a chain it is.
11
+
12
+ const fs = require("node:fs");
13
+ const path = require("node:path");
14
+ const { spawn } = require("node:child_process");
15
+ const { getSystemErrorMap } = require("node:util");
16
+ const { track } = require("./running");
17
+
18
+ const WINDOWS = process.platform === "win32";
19
+
20
+ const exe = (name) => (WINDOWS ? `${name}.exe` : name);
21
+
22
+ // A name with no folder in it, which is looked up on PATH.
23
+ const bare = (value) => !(value.includes("/") || (WINDOWS && value.includes("\\")));
24
+
25
+ // The errors a start can fail with that libuv has no name for, and Node
26
+ // calls "Unknown system error -86": each one's name and the system's
27
+ // words, by system and number. Linux's numbers are those of every
28
+ // processor Node.js runs on but MIPS.
29
+ const UNNAMED = {
30
+ linux: { 80: ["ELIBBAD", "accessing a corrupted shared library"], 116: ["ESTALE", "stale file handle"] },
31
+ darwin: { 70: ["ESTALE", "stale NFS file handle"], 85: ["EBADEXEC", "bad executable (or shared library)"], 86: ["EBADARCH", "bad CPU type in executable"], 87: ["ESHLIBVERS", "shared library version mismatch"], 88: ["EBADMACHO", "malformed Mach-o file"] },
32
+ };
33
+
34
+ // An error's name and the system's words for it, as ["EACCES",
35
+ // "permission denied"], or [] when neither is known.
36
+ const known = (err) => getSystemErrorMap().get(err.errno) || (!/^mips/.test(process.arch) && (UNNAMED[process.platform] || {})[-err.errno]) || [];
37
+
38
+ // The system's name for an error, as EACCES, else Node's.
39
+ const codeOf = (err) => known(err)[0] || err.code;
40
+
41
+ // The system's words for an error, as "permission denied", else its code.
42
+ const worded = (err) => known(err)[1] || err.code || String(err.message || err);
43
+
44
+ // How long a program may take to answer, in milliseconds: on a machine
45
+ // short of memory, with its swap full, ffmpeg 4.4 took more than 30
46
+ // seconds to say its version.
47
+ const LIMIT = 120_000;
48
+
49
+ // ─── where a program is ───────────────────────────────────────────────────
50
+
51
+ // A file this user may run, as a shell takes one: a file, not a folder,
52
+ // with the permission to run it. On Windows that permission is not looked
53
+ // at, and any file passes.
54
+ function runnable(file) {
55
+ try {
56
+ fs.accessSync(file, fs.constants.X_OK);
57
+ return fs.statSync(file).isFile();
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ // Whether a folder is there and this user may enter it, as a program run
64
+ // in it needs.
65
+ function enterable(folder) {
66
+ try {
67
+ fs.accessSync(folder, fs.constants.X_OK);
68
+ return fs.statSync(folder).isDirectory();
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ // The folders of PATH in order, each as a whole path: one given from the
75
+ // current folder is taken from the folder the run started in, so a
76
+ // program found there is found when it runs in another folder. An empty
77
+ // entry is passed over.
78
+ const folders = () =>
79
+ String(process.env.PATH || "")
80
+ .split(path.delimiter)
81
+ .filter(Boolean)
82
+ .map((dir) => path.resolve(dir));
83
+
84
+ // The first program by a name on PATH that this user may run, in a folder
85
+ // `skip` does not pass over, or "".
86
+ function onPath(name, { skip = () => false } = {}) {
87
+ for (const dir of String(process.env.PATH || "").split(path.delimiter)) {
88
+ if (!dir || skip(dir)) continue;
89
+ const file = path.resolve(dir, name);
90
+ if (runnable(file)) return file;
91
+ }
92
+ return "";
93
+ }
94
+
95
+ // Whether a file, not a folder, is at a path, as Windows looks for one.
96
+ function there(file) {
97
+ try {
98
+ return !fs.statSync(file).isDirectory();
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ // Whether Windows runs a file by its own name: one whose name has an
105
+ // extension. It runs a name with none only as that name with .com or .exe
106
+ // added, which libuv looks for next to it.
107
+ function runsAsNamed(file) {
108
+ const name = path.basename(file);
109
+ const dot = name.indexOf(".");
110
+ return dot >= 0 && dot < name.length - 1;
111
+ }
112
+
113
+ // The file a path goes through as if it were a folder, as notes.txt in
114
+ // notes.txt/ffmpeg, or "".
115
+ function fileOnTheWay(file) {
116
+ const parts = file.split(path.sep);
117
+ for (let i = 2; i < parts.length; i++) {
118
+ const part = parts.slice(0, i).join(path.sep);
119
+ try {
120
+ if (!fs.statSync(part).isDirectory()) return part;
121
+ } catch {
122
+ return "";
123
+ }
124
+ }
125
+ return "";
126
+ }
127
+
128
+ // What keeps the system from running the file at a path, as far as can be
129
+ // told for certain before it runs: null when nothing does, else the words
130
+ // for it, and in `on` what the system does with such a file as it looks
131
+ // for a program on PATH. It "passes" over a folder, a named pipe, a
132
+ // socket and a device, none of which it runs, a link to nothing and a
133
+ // file this user may not run, to look in the next folder, and these are
134
+ // named when no program comes after them; it passes over nothing there
135
+ // and a folder this user may not enter "quietly", as these may hold no
136
+ // such program at all; and it "stops" at a path it cannot follow, as a
137
+ // loop of links. On Windows, where no permission to run a file is looked
138
+ // at, a file whose name has no extension passes, as Windows does not run
139
+ // it, unless a .com or an .exe by that name is next to it, which it runs.
140
+ function barred(file) {
141
+ let stat;
142
+ try {
143
+ stat = fs.statSync(file);
144
+ } catch (err) {
145
+ if (err.code === "ENOENT" || err.code === "ENOTDIR") {
146
+ let link = false;
147
+ try {
148
+ link = fs.lstatSync(file).isSymbolicLink();
149
+ } catch {
150
+ // Nothing there at all.
151
+ }
152
+ if (link) return { on: "passes", words: "is a link to a file that is not there" };
153
+ const through = err.code === "ENOTDIR" ? fileOnTheWay(file) : "";
154
+ return { on: "quietly", words: through ? `where there is none, as ${through} is a file, not a folder` : "where there is none" };
155
+ }
156
+ if (err.code === "EACCES" || err.code === "EPERM") return { on: "quietly", words: "may not be run: permission denied" };
157
+ if (err.code === "ELOOP") return { on: "stops", words: "goes through a loop of links" };
158
+ return { on: "stops", words: `cannot be looked at: ${worded(err)}` };
159
+ }
160
+ if (stat.isDirectory()) return { on: "passes", words: "is a folder" };
161
+ if (WINDOWS) return runsAsNamed(file) || there(`${file}.com`) || there(`${file}.exe`) ? null : { on: "passes", words: "has no .exe in its name, so Windows does not run it" };
162
+ if (!stat.isFile()) return { on: "passes", words: stat.isFIFO() ? "is a named pipe" : stat.isSocket() ? "is a socket" : "is a device" };
163
+ try {
164
+ fs.accessSync(file, fs.constants.X_OK);
165
+ } catch {
166
+ return { on: "passes", words: (stat.mode & 0o111) === 0 ? "is not executable" : "may not be run: permission denied" };
167
+ }
168
+ return null;
169
+ }
170
+
171
+ // ─── what a program said ──────────────────────────────────────────────────
172
+
173
+ // What `file` did when run with `args`, given `limit` milliseconds: { code,
174
+ // signal, stdout, stderr }, { late: true } when it gave no answer in that
175
+ // time and was stopped (see late()), or { err } when the system did not
176
+ // start it. What it printed is kept up to `cap` bytes.
177
+ function ask(file, args, { limit = LIMIT, cap = 1 << 20 } = {}) {
178
+ return new Promise((resolve) => {
179
+ let child;
180
+ try {
181
+ child = track(spawn(file, args, { stdio: ["ignore", "pipe", "pipe"] }));
182
+ } catch (err) {
183
+ resolve({ err });
184
+ return;
185
+ }
186
+ let timer = null;
187
+ let done = false;
188
+ const end = (result) => {
189
+ if (done) return;
190
+ done = true;
191
+ clearTimeout(timer);
192
+ resolve(result);
193
+ };
194
+ // A start that fails gives its error before the close.
195
+ child.on("error", (err) => end({ err }));
196
+ // With no file descriptor left Node makes no pipes, and says why a
197
+ // moment later.
198
+ if (!child.stdout || !child.stderr) return;
199
+ const said = { stdout: [], stderr: [] };
200
+ let size = 0;
201
+ for (const name of ["stdout", "stderr"]) {
202
+ child[name].on("data", (chunk) => {
203
+ if (size >= cap) return;
204
+ said[name].push(chunk);
205
+ size += chunk.length;
206
+ });
207
+ }
208
+ // A program that hands its output on to one that goes on running may
209
+ // never close it, so the answer is not waited for past the limit.
210
+ timer = setTimeout(() => {
211
+ try {
212
+ child.kill("SIGKILL");
213
+ } catch {
214
+ // Gone already.
215
+ }
216
+ child.stdout.destroy();
217
+ child.stderr.destroy();
218
+ end({ late: true });
219
+ }, limit);
220
+ child.on("close", (code, signal) => end({ code, signal, stdout: Buffer.concat(said.stdout).toString("utf8"), stderr: Buffer.concat(said.stderr).toString("utf8") }));
221
+ });
222
+ }
223
+
224
+ // A line a program printed, in quotes, as it printed it, with its control
225
+ // characters written out, as \r or \x1b. A line longer than 200 characters
226
+ // keeps its first 120 and its last 80, as its end most often says why.
227
+ function quoted(line) {
228
+ const chars = Array.from(String(line)).map((c) => {
229
+ if (c === "\t") return "\\t";
230
+ if (c === "\r") return "\\r";
231
+ if (!/[\p{Cc}\p{Cf}]/u.test(c)) return c;
232
+ const n = c.codePointAt(0);
233
+ return n < 0x100 ? `\\x${n.toString(16).padStart(2, "0")}` : `\\u${n.toString(16).padStart(4, "0")}`;
234
+ });
235
+ const shown = chars.length > 200 ? [...chars.slice(0, 120), "...", ...chars.slice(-80)] : chars;
236
+ return `"${shown.join("")}"`;
237
+ }
238
+
239
+ // The first line with anything in it of what a program printed, or null.
240
+ const firstLine = (text) =>
241
+ String(text || "")
242
+ .split(/\r?\n/)
243
+ .find((line) => line.trim()) || null;
244
+
245
+ // What a program that ran did, where it did not answer: the first line it
246
+ // printed on its error output, where a program says what is wrong, else on
247
+ // its output, and how it ended. ffmpeg, ffprobe and Chrome give their
248
+ // versions on their output, so a program that ended well and printed on
249
+ // its error output alone is said to.
250
+ function happened(r) {
251
+ const line = firstLine(r.stderr);
252
+ if (line && r.code === 0 && !firstLine(r.stdout)) return `it printed ${quoted(line)} on its error output, nothing on its output, and exited with 0`;
253
+ const ended = r.signal ? `was stopped by ${r.signal}` : `exited with ${r.code}`;
254
+ const said = line || firstLine(r.stdout);
255
+ if (said) return `it printed ${quoted(said)} and ${ended}`;
256
+ return r.signal ? `it was stopped by ${r.signal}` : `it printed nothing and exited with ${r.code}`;
257
+ }
258
+
259
+ // The first line of a file that starts with #!, which names the program
260
+ // the system starts the file with, or null. It is opened without waiting,
261
+ // as a named pipe put in the file's place since it was looked at would
262
+ // wait for a writer for good, and read only when it is a file.
263
+ function bangLine(file) {
264
+ let fd;
265
+ try {
266
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
267
+ if (!fs.fstatSync(fd).isFile()) return null;
268
+ const bytes = Buffer.alloc(256);
269
+ const got = bytes.subarray(0, fs.readSync(fd, bytes, 0, bytes.length, 0));
270
+ if (got.indexOf("#!") !== 0) return null;
271
+ const end = got.indexOf(10);
272
+ return got.subarray(0, end < 0 ? got.length : end).toString("utf8");
273
+ } catch {
274
+ return null;
275
+ } finally {
276
+ if (fd !== undefined) fs.closeSync(fd);
277
+ }
278
+ }
279
+
280
+ // What the system said when it did not start a file the checks found
281
+ // nothing wrong with. Off Windows it says ENOENT of a file that is there
282
+ // when an interpreter or a loader the file needs is not, so a script's
283
+ // first line, which names its interpreter, is shown with it. Windows runs
284
+ // no interpreter a first line names.
285
+ function unstartable(file, err) {
286
+ if (["ENOEXEC", "EFTYPE", "EBADARCH", "EBADMACHO"].includes(codeOf(err))) return "it is not a program this system can run";
287
+ const line = WINDOWS ? null : bangLine(file);
288
+ const first = line === null ? "" : `; its first line is ${quoted(line)}`;
289
+ if (codeOf(err) === "ENOENT" && !WINDOWS) return `the system could not start it, as an interpreter or a loader it needs is not there${first}`;
290
+ return `the system could not start it: ${worded(err)}${first}`;
291
+ }
292
+
293
+ // The errors that say the system is short of something to start a program
294
+ // with, not that the program is wrong: what it is short of, and what to do.
295
+ const SHORT = {
296
+ EMFILE: "too many open files. Raise the limit on open files (ulimit -n) and run it again",
297
+ ENFILE: "the system has too many files open. Run it again once fewer are",
298
+ EAGAIN: "the system starts no more processes for now. Run it again once fewer are running",
299
+ ENOMEM: "not enough memory. Free some and run it again",
300
+ E2BIG: "its environment is too large. Run it with fewer or shorter environment variables",
301
+ };
302
+
303
+ // The errors a start fails with that the system passes over, as glibc's
304
+ // execvp does, to try the next folder of PATH: a file that is not there or
305
+ // may not be run, or one whose interpreter or loader is not there.
306
+ const PASSED = new Set(["ENOENT", "EACCES", "ENOTDIR", "ESTALE", "ENODEV", "ETIMEDOUT"]);
307
+
308
+ // The error for a program the system could not start for want of something
309
+ // it is short of, as open files: no fault of the program's.
310
+ const short = (name, err) => Object.assign(new Error(`easycaptionkit could not start ${name}: ${SHORT[err.code]}.`), { unusable: true, why: `the system could not start ${name}: ${SHORT[err.code].replace(/\. .*$/, "")}` });
311
+
312
+ // The error for a program that gave no answer in `limit` ms when asked
313
+ // `what`, as "its version": ffmpeg on a machine short of memory can be
314
+ // that slow as well as a program that is not ffmpeg, so only that is said,
315
+ // and to run again, with no advice to install another.
316
+ function late(spec, place, what, limit) {
317
+ const words = `gave no answer in ${limit / 1000} s when asked ${what}`;
318
+ return Object.assign(new Error(`easycaptionkit could not check ${spec.name}, ${place.file}: it ${words}, as on a machine short of memory. ${spec.advice.late}`), { unusable: true, why: placed(place, words) });
319
+ }
320
+
321
+ // The error for a program a run checked before any work that the system
322
+ // did not start during the work, `name` being what messages call it: what
323
+ // the system is short of, else the system's words, never its code alone,
324
+ // and what to check, as a program that answered when the run began can
325
+ // have been taken away or changed since.
326
+ function startFailure(file, err, name = path.basename(file)) {
327
+ if (Object.hasOwn(SHORT, err.code)) return short(name, err);
328
+ return new Error(`easycaptionkit could not start ${name}, ${file}, again: ${worded(err)}. Check that it is still there and may be run, and run easycaptionkit again.`);
329
+ }
330
+
331
+ // ─── finding a program and asking it ──────────────────────────────────────
332
+
333
+ // A program a run looks for, as ffmpeg.js and chrome.js give it: { name,
334
+ // variable, args, answers, names, first, known, ask, none, advice }.
335
+ // `name` is what messages call it, `variable` the one that names it,
336
+ // `args` what asks its version and `answers(stdout)` whether what it
337
+ // printed then answers as it does, after it exited with 0. `names` are its
338
+ // names on PATH. Where the variable is not set, `first()` gives places
339
+ // looked at before PATH, each { file, name, where, next }, of which the
340
+ // first that is there is taken, and `known` folders looked in where PATH
341
+ // has nothing by its names, as package managers put it there for an agent
342
+ // or an app whose PATH is short. With `ask` false, a program is taken once
343
+ // nothing is in the way of running it, as Chrome on Windows, which prints
344
+ // no version. `none` is the message when there is none, and `advice` the
345
+ // sentence that ends one about a program the run cannot use: `variable`
346
+ // for one the variable names, `next` for ffprobe next to ffmpeg, `found`
347
+ // for the others, and `late` for one that gave no answer in time.
348
+
349
+ // Where a run found a program, in words that name its place and say what
350
+ // happened there.
351
+ function placed(place, words) {
352
+ if (place.variable && !bare(place.value)) return `${place.variable} points to ${place.file}, ${words.startsWith("where ") ? words : `which ${words}`}`;
353
+ if (place.variable) return `${place.variable} names ${place.value}, found on PATH at ${place.file}, which ${words}`;
354
+ return `the ${place.name} ${place.where}, ${place.file}, ${words}`;
355
+ }
356
+
357
+ // The error for a program a run cannot use: the message, and in `why` the
358
+ // words that name its place and what happened, as a note gives them.
359
+ function unusable(spec, place, words) {
360
+ const why = placed(place, words);
361
+ const advice = place.variable ? spec.advice.variable : place.next ? spec.advice.next : spec.advice.found;
362
+ return Object.assign(new Error(`easycaptionkit needs ${spec.name}, and ${why}. ${advice}`), { unusable: true, why });
363
+ }
364
+
365
+ // Whether what a program did when asked its version answers as `spec`.
366
+ const answered = (spec, r) => !r.err && !r.late && r.code === 0 && !r.signal && spec.answers(r.stdout);
367
+
368
+ // The places in turn: what keeps each from running, then, unless `spec`
369
+ // says not to, what it did when asked its version. The first that answers
370
+ // is { found: place, stdout }. A place `alone`, the one a variable or a
371
+ // place before PATH gives, is the only one, and what is wrong with it is
372
+ // said. Of places on PATH, the system's search passes over some (see
373
+ // barred() and PASSED), and the first of those is given as `passed`; at
374
+ // any other the search stops, with what is wrong there.
375
+ async function walk(spec, places, limit, { alone = false } = {}) {
376
+ let passed = null;
377
+ for (const place of places) {
378
+ let seen = barred(place.file);
379
+ if (!seen) {
380
+ if (spec.ask === false) return { found: place, stdout: "" };
381
+ const r = await ask(place.file, spec.args, { limit, cap: 1 << 16 });
382
+ if (r.err && Object.hasOwn(SHORT, r.err.code)) throw short(spec.name, r.err);
383
+ if (r.late) throw late(spec, place, "its version", limit);
384
+ if (answered(spec, r)) return { found: place, stdout: r.stdout };
385
+ // A file taken away or changed since it was looked at is said to be
386
+ // what it is now.
387
+ if (r.err) seen = barred(place.file) || { on: PASSED.has(codeOf(r.err)) ? "passes" : "stops", words: `did not run as ${spec.name}: ${unstartable(place.file, r.err)}` };
388
+ else seen = { on: "stops", words: `did not run as ${spec.name}: ${happened(r)}` };
389
+ }
390
+ if (alone || seen.on === "stops") throw unusable(spec, place, seen.words);
391
+ if (seen.on === "passes" && !passed) passed = { place, words: seen.words };
392
+ }
393
+ return { passed };
394
+ }
395
+
396
+ // The places a program is looked for on PATH, by each of its names, and
397
+ // in the folders `known` where PATH has nothing by them.
398
+ function onPathPlaces(spec, value) {
399
+ const names = value ? [value, ...(WINDOWS ? [exe(value)] : [])] : spec.names;
400
+ const named = value ? { variable: spec.variable, value } : {};
401
+ return names.flatMap((name) => folders().map((dir) => ({ file: path.join(dir, name), name, where: "on PATH", ...named })));
402
+ }
403
+
404
+ const knownPlaces = (spec) => (WINDOWS ? [] : (spec.known || []).flatMap((dir) => spec.names.map((name) => ({ file: path.join(dir, name), name, where: `in ${dir}` }))));
405
+
406
+ // The program a run uses, found and asked its version before any work, as
407
+ // { file, stdout, place }: the path its variable gives, as it is, or the
408
+ // first program by its name on PATH the system would start; with the
409
+ // variable unset, the first of the places before PATH that is there, else
410
+ // the first on PATH, else one in the known folders. It is taken only when
411
+ // it exits with 0 and its answer is one `spec` takes; otherwise the run
412
+ // stops before any work, with what is wrong with it, what the system said
413
+ // or what it printed (see unusable()), and when there is none, with
414
+ // `spec.none`. Every run checks the programs it uses with this first.
415
+ async function find(spec, limit = LIMIT) {
416
+ const value = process.env[spec.variable] || "";
417
+ const through = async (places, options) => {
418
+ const { found, stdout, passed } = await walk(spec, places, limit, options);
419
+ return found ? { file: found.file, stdout, place: found } : { passed };
420
+ };
421
+ if (value && !bare(value)) return through([{ file: path.resolve(value), variable: spec.variable, value }], { alone: true });
422
+ if (!value) {
423
+ const first = (spec.first ? spec.first() : []).find((place) => fs.existsSync(place.file));
424
+ if (first) return through([first], { alone: true });
425
+ }
426
+ let result = await through(onPathPlaces(spec, value));
427
+ if (!result.file && !result.passed && !value) result = await through(knownPlaces(spec));
428
+ if (result.file) return result;
429
+ if (result.passed) throw unusable(spec, result.passed.place, result.passed.words);
430
+ if (value) throw Object.assign(new Error(`easycaptionkit needs ${spec.name}, and ${spec.variable} names ${value}, which is not on PATH. ${spec.advice.variable}`), { unusable: true, why: `${spec.variable} names ${value}, which is not on PATH` });
431
+ throw Object.assign(new Error(spec.none), { unusable: true, why: `no ${spec.name} was found` });
432
+ }
433
+
434
+ // The file a run would take for a program by what is at each place alone,
435
+ // not run, or "": the path its variable gives, as it is, else as find()
436
+ // looks, where nothing keeps a file from running. ffprobe is looked for
437
+ // next to it, where a run uses ffprobe alone.
438
+ function locate(spec) {
439
+ const value = process.env[spec.variable] || "";
440
+ if (value && !bare(value)) return path.resolve(value);
441
+ if (!value) {
442
+ const first = (spec.first ? spec.first() : []).find((place) => fs.existsSync(place.file));
443
+ if (first) return first.file;
444
+ }
445
+ // The first place nothing keeps from running, as { file }, else whether
446
+ // the search stopped at one or passed over one.
447
+ const look = (places) => {
448
+ let passed = false;
449
+ for (const place of places) {
450
+ const seen = barred(place.file);
451
+ if (!seen) return { file: place.file };
452
+ if (seen.on === "stops") return { stopped: true };
453
+ if (seen.on === "passes") passed = true;
454
+ }
455
+ return { passed };
456
+ };
457
+ const onPath = look(onPathPlaces(spec, value));
458
+ if (onPath.file) return onPath.file;
459
+ if (onPath.stopped || onPath.passed || value) return "";
460
+ return look(knownPlaces(spec)).file || "";
461
+ }
462
+
463
+ // What the program found at `place` printed when run with `args` after it
464
+ // answered its version, as asked about `what` ("its encoders"): its
465
+ // output, read only once it ended well. One that fails or is stopped
466
+ // stops the run with how it ended, so a program that cannot answer is not
467
+ // taken for one without the encoder or the filter asked for, and one that
468
+ // gives no answer in time stops it as late() says.
469
+ async function probe(spec, place, args, what, limit = LIMIT) {
470
+ const r = await ask(place.file, args, { limit });
471
+ const asked = args.filter((arg) => arg !== "-hide_banner").join(" ");
472
+ if (r.err && Object.hasOwn(SHORT, r.err.code)) throw short(spec.name, r.err);
473
+ if (r.late) throw late(spec, place, `${what} (${asked})`, limit);
474
+ if (!r.err && r.code === 0 && !r.signal) return r.stdout;
475
+ throw unusable(spec, place, `failed when asked ${what} (${asked}): ${r.err ? unstartable(place.file, r.err) : happened(r)}`);
476
+ }
477
+
478
+ module.exports = { exe, enterable, onPath, quoted, happened, unstartable, find, locate, probe, startFailure, LIMIT };
@@ -2,4 +2,4 @@
2
2
 
3
3
  // Kept here, not read from package.json: a skill install copies this folder
4
4
  // alone.
5
- module.exports = "0.1.2";
5
+ module.exports = "0.1.4";