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,386 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const path = require("node:path");
5
+ const version = require("./lib/version");
6
+ const { PLACES, PLACE_NAMES } = require("./lib/places");
7
+ const { drewWith, setsAny, setsNone, machineHas, shown, soundText } = require("./lib/report");
8
+ const { variableFace, sameName } = require("./lib/look");
9
+
10
+ const USAGE = `Usage: easycaptionkit <video> --words <file> [options]
11
+ easycaptionkit subtitles <words> [options]
12
+ easycaptionkit styles [--json]
13
+
14
+ Burns captions into a video in the product's own fonts and colors, word by
15
+ word or line by line, clear of each app's buttons, and writes captions.srt,
16
+ captions.vtt and captions.ass to go with it. CAPTIONS.md says what was
17
+ made, how fast the captions read and how the font is licensed; sheet.png
18
+ and safe-zones.png show the result.
19
+
20
+ Options:
21
+ --words <file> the words and their times: a speech-to-text JSON
22
+ (OpenAI, whisper.cpp, WhisperX, ElevenLabs, Deepgram,
23
+ AssemblyAI, NeMo, Parakeet and others), an SRT or VTT
24
+ file, or easyscreenshotkit's steps.json
25
+ --style <name> karaoke (the spoken word lit), line or word
26
+ (default: karaoke)
27
+ --for <places> tiktok, reels, shorts, youtube, x, linkedin or square,
28
+ several with commas; the captions keep clear of the
29
+ buttons of all of them (default: from the video's shape)
30
+ --brand <dir|file> an easybrandkit folder or its brand.json: the spoken
31
+ word in its color, and its heading font by name when
32
+ there is no --fonts or --font
33
+ --fonts <dir|file> easyfontkit's folder or its fonts.css: the site's own
34
+ font files
35
+ --font <family> the font family to use, from fonts.css or this machine
36
+ --highlight <color> the color of the spoken word, like #ffd23f (karaoke
37
+ and word styles)
38
+ --position <where> bottom, middle or top of the safe space (default:
39
+ bottom)
40
+ --lines <n> lines a caption can take, 1 to 3 (default: 2)
41
+ --max-chars <n> characters a line can take, 8 to 80 (default: what
42
+ fits the safe width, at most 42; 42 for subtitles)
43
+ --size <n> letter size in percent of the width of the phone or the
44
+ short side of the frame, 2 to 20 (default: 6.5, or 9.5
45
+ for the word style)
46
+ --box a dark box behind the words instead of an outline
47
+ --libass draw the captions with ffmpeg's libass instead of Chrome
48
+ --out <dir> where to save (default: ./easycaptionkit-<video name>,
49
+ with -2 and on when that holds another video's captions)
50
+ --dry-run check the options and say what would be made; reads and
51
+ writes nothing
52
+ --json print captions.json instead of a summary
53
+ -h, --help show this help
54
+ -v, --version show the version
55
+
56
+ Subtitles writes captions.srt, captions.vtt, captions.ass, words.json,
57
+ captions.json and CAPTIONS.md, and changes no video. captions.ass is laid
58
+ out for the video given (else a phone when tiktok, reels or shorts is
59
+ named, a square for square alone, and a 16:9 frame for the rest), its
60
+ lines cut to what fits across it. Without --out it goes to
61
+ easycaptionkit-subtitles-<video name>, or <words name> with no video,
62
+ with -2 and on in the same way. Its options:
63
+ --video <file> the video, for its size and length
64
+ --for, --style, --lines, --max-chars (default 42), --size, --position,
65
+ --brand, --fonts, --font, --highlight, --box, --out, --dry-run, --json
66
+
67
+ The captions are drawn by Chrome (or ffmpeg's libass with --libass) and
68
+ laid over the video by ffmpeg in one encode; the sound is copied, or made
69
+ AAC when MP4 players do not take it. A run replaces the files an earlier
70
+ run wrote in --out, and stops before any work when the folder holds a file
71
+ by one of those names that easycaptionkit did not write or that changed
72
+ since, or a words.json fixed by hand that the run does not read.
73
+ A subtitles run also stops at a folder that holds the captioned video of
74
+ an earlier run.
75
+
76
+ The video and the words are files on this machine, not addresses, so
77
+ download them first, and --brand and --fonts take what easybrandkit and
78
+ easyfontkit make from a site. Needs Node 22 or newer, ffmpeg 4.4 or newer
79
+ and Chrome, Chromium, Edge or Brave. Set FFMPEG_PATH and CHROME_PATH to
80
+ pick them, and FFPROBE_PATH for an ffprobe that is not next to ffmpeg or
81
+ on PATH. Nothing leaves your machine.`;
82
+
83
+ const VALUED = new Set(["--words", "--style", "--for", "--brand", "--fonts", "--font", "--highlight", "--position", "--lines", "--max-chars", "--size", "--out", "--video"]);
84
+ const STYLES = ["karaoke", "line", "word"];
85
+ const POSITIONS = ["bottom", "middle", "top"];
86
+
87
+ function parse(argv) {
88
+ const raw = {};
89
+ const flags = { help: false, version: false, json: false, dryRun: false, box: false, libass: false };
90
+ const rest = [];
91
+ let command = "caption";
92
+ for (let i = 0; i < argv.length; i++) {
93
+ const arg = argv[i];
94
+ const eq = arg.indexOf("=");
95
+ const name = arg.startsWith("--") && eq > 0 ? arg.slice(0, eq) : arg;
96
+ if (arg === "-h" || arg === "--help") flags.help = true;
97
+ else if (arg === "-v" || arg === "--version") flags.version = true;
98
+ else if (arg === "--json") flags.json = true;
99
+ else if (arg === "--dry-run") flags.dryRun = true;
100
+ else if (arg === "--box") flags.box = true;
101
+ else if (arg === "--libass") flags.libass = true;
102
+ else if (VALUED.has(name)) {
103
+ const value = name !== arg ? arg.slice(eq + 1) : argv[++i];
104
+ if (value === undefined || value === "" || (name === arg && /^--[a-z]/.test(value))) throw new Error(`${name} needs a value`);
105
+ raw[name.slice(2)] = value;
106
+ } else if (arg.startsWith("-") && arg.length > 1) throw new Error(`Unknown option: ${arg}`);
107
+ else if (!rest.length && (arg === "subtitles" || arg === "styles")) {
108
+ command = arg;
109
+ rest.push(null);
110
+ } else rest.push(arg);
111
+ }
112
+ const given = rest.filter((r) => r !== null);
113
+ const options = { ...flags, command, style: "karaoke", position: "bottom", lines: 2 };
114
+ if (raw.style !== undefined) {
115
+ options.style = raw.style.toLowerCase();
116
+ if (!STYLES.includes(options.style)) throw new Error("--style takes karaoke, line or word");
117
+ }
118
+ if (raw.position !== undefined) {
119
+ options.position = raw.position.toLowerCase();
120
+ if (!POSITIONS.includes(options.position)) throw new Error("--position takes bottom, middle or top");
121
+ }
122
+ if (raw.for !== undefined) {
123
+ const places = raw.for.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
124
+ const unknown = places.filter((p) => !PLACE_NAMES.includes(p));
125
+ if (!places.length || unknown.length) throw new Error(`--for takes ${PLACE_NAMES.join(", ")}, several with commas${unknown.length ? `, not ${unknown.join(", ")}` : ""}`);
126
+ options.places = [...new Set(places)];
127
+ }
128
+ if (raw.lines !== undefined) {
129
+ options.lines = Number(raw.lines);
130
+ if (!Number.isInteger(options.lines) || options.lines < 1 || options.lines > 3) throw new Error("--lines takes 1, 2 or 3");
131
+ }
132
+ if (raw["max-chars"] !== undefined) {
133
+ options.maxChars = Number(raw["max-chars"]);
134
+ if (!Number.isInteger(options.maxChars) || options.maxChars < 8 || options.maxChars > 80) throw new Error("--max-chars takes a whole number from 8 to 80");
135
+ }
136
+ options.size = options.style === "word" ? 9.5 : 6.5;
137
+ if (raw.size !== undefined) {
138
+ options.size = Number(raw.size);
139
+ if (!(options.size >= 2 && options.size <= 20)) throw new Error("--size takes 2 to 20, a percent of the phone's width or the frame's short side");
140
+ }
141
+ if (raw.highlight !== undefined) {
142
+ if (!/^#?(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(raw.highlight.trim())) throw new Error(`--highlight takes a color like #ffd23f, not ${raw.highlight}`);
143
+ options.highlight = raw.highlight.trim();
144
+ }
145
+ if (raw.font !== undefined) {
146
+ options.font = raw.font.trim().replace(/^["']|["']$/g, "");
147
+ if (!options.font || options.font.length > 100 || /[\u0000-\u001f\u007f;{}<>]/.test(options.font)) throw new Error("--font takes a font family name, like Inter");
148
+ }
149
+ for (const key of ["words", "brand", "fonts", "out", "video"]) if (raw[key] !== undefined) options[key] = raw[key];
150
+ if (options.help || options.version) return options;
151
+ if (command === "styles") {
152
+ if (given.length) throw new Error("styles takes no file");
153
+ return options;
154
+ }
155
+ if (command === "subtitles") {
156
+ if (given.length !== 1) throw new Error("Give one words file, for example: easycaptionkit subtitles transcript.json");
157
+ if (options.words !== undefined) throw new Error("subtitles takes the words file as its argument, without --words");
158
+ if (options.libass) throw new Error("--libass is for burning captions into a video; subtitles changes no video");
159
+ options.words = given[0];
160
+ if (options.maxChars === undefined) options.maxChars = 42;
161
+ return options;
162
+ }
163
+ if (options.video !== undefined) throw new Error("--video is for subtitles; give the video as the first argument");
164
+ if (given.length !== 1) throw new Error("Give one video, for example: easycaptionkit demo.mp4 --words transcript.json");
165
+ if (options.words === undefined) throw new Error("Give --words, the words and their times: a speech-to-text JSON, an SRT or VTT file, or steps.json");
166
+ options.video = given[0];
167
+ return options;
168
+ }
169
+
170
+ const sec = (t) => `${t.toFixed(2)} s`;
171
+ const list = (items) => (items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`);
172
+
173
+ // The places a dry run names: those given, or those of the video's shape.
174
+ // Subtitles with no video lay captions.ass out on a frame of their own,
175
+ // and its places are known: the frame's, each once.
176
+ function placesOf(options) {
177
+ if (options.command === "subtitles" && !options.video) {
178
+ const { frameFor } = require("./lib/caption");
179
+ const { defaultPlaces, captionLayout } = require("./lib/places");
180
+ const frame = frameFor(options.places);
181
+ const keys = options.places || defaultPlaces(frame.width, frame.height);
182
+ const zones = captionLayout(frame.width, frame.height, keys, { style: options.style, size: options.size, lines: options.lines }).zones;
183
+ return { keys: zones.map((z) => z.key), text: `${list(zones.map((z) => z.name))}, on a ${frame.width}x${frame.height} frame`, frame };
184
+ }
185
+ return { keys: options.places || "from the video's shape", text: options.places ? list(options.places.map((p) => PLACES[p].name)) : "the places that fit the video's shape", frame: null };
186
+ }
187
+
188
+ function dryRun(options) {
189
+ // The folder's name comes from the file's name alone, and an address is
190
+ // told by its name and whether a file has it, as the run tells it, so the
191
+ // call checked here is not refused there: nothing is read.
192
+ const { slug, notAddresses } = require("./lib/caption");
193
+ const { video, words, brand, fonts } = options;
194
+ notAddresses(options.command === "subtitles" ? { words, video, brand, fonts } : { video, words, brand, fonts });
195
+ const places = placesOf(options);
196
+ const out = options.out || (options.command === "subtitles" ? `easycaptionkit-subtitles-${slug(options.video || options.words)}` : `easycaptionkit-${slug(options.video)}`);
197
+ const plan = {
198
+ ok: true,
199
+ dryRun: true,
200
+ command: options.command,
201
+ video: options.video || null,
202
+ words: options.words,
203
+ style: options.style,
204
+ places: places.keys,
205
+ ...(places.frame ? { frame: places.frame } : {}),
206
+ position: options.position,
207
+ lines: options.style === "word" ? 1 : options.lines,
208
+ maxChars: options.maxChars || "what fits the safe width, at most 42",
209
+ size: options.size,
210
+ brand: options.brand || null,
211
+ fonts: options.fonts || null,
212
+ font: options.font || null,
213
+ highlight: options.highlight || null,
214
+ box: options.box,
215
+ drawWith: options.command === "subtitles" ? null : options.libass ? "libass" : "chrome",
216
+ out,
217
+ };
218
+ if (options.json) return JSON.stringify(plan, null, 2);
219
+ const lines = [];
220
+ // The names given, each on the line it is said on.
221
+ const where = shown(out);
222
+ const also = options.out ? "" : ` (or ${where}-2 and on, when that folder holds the captions of another file by that name)`;
223
+ if (options.command === "subtitles") lines.push(`Would write captions.srt, captions.vtt, captions.ass, words.json, captions.json and CAPTIONS.md from ${shown(options.words)} into ${where}${also}.`);
224
+ else lines.push(`Would caption ${shown(options.video)} with the words in ${shown(options.words)} into ${where}/captioned.mp4${also}, with captions.srt, captions.vtt, captions.ass, words.json, captions.json, sheet.png, safe-zones.png and CAPTIONS.md.`);
225
+ const width = options.maxChars ? `of up to ${options.maxChars} characters` : "of what fits the safe space, up to 42 characters";
226
+ lines.push(` Style ${options.style}${options.style === "word" ? ", one word at a time" : `, up to ${options.lines} ${options.lines === 1 ? "line" : "lines"} ${width}`}, ${options.size}% letters`);
227
+ lines.push(` Places ${places.text}, at the ${options.position}`);
228
+ const lit = options.style === "line" ? "" : options.highlight ? `the spoken word in ${options.highlight}` : options.brand ? `the spoken word in a color from ${shown(options.brand)}` : "the spoken word in yellow";
229
+ const font = options.font ? `in ${options.font}` : options.fonts ? `in the fonts from ${shown(options.fonts)}` : "";
230
+ lines.push(` Look ${["white", "bold", options.box ? "on a dark box" : "outlined", font, lit].filter(Boolean).join(", ")}`);
231
+ lines.push("", "Nothing was read or written: this was a dry run.");
232
+ return lines.join("\n");
233
+ }
234
+
235
+ function stylesText(json) {
236
+ const styles = {
237
+ karaoke: "Each caption whole, one or more lines, with the word being spoken lit in the highlight color. The usual look on TikTok, Reels and Shorts.",
238
+ line: "Each caption whole, in one color. For YouTube, LinkedIn and anything people watch with the sound off for longer.",
239
+ word: "One word at a time, bigger, lit in the highlight color. For short, quick clips; --position middle puts it in the middle of the safe space.",
240
+ };
241
+ const fields = {
242
+ "--style": "karaoke, line or word",
243
+ "--for": `${PLACE_NAMES.join(", ")}: the apps whose buttons the captions keep clear of`,
244
+ "--position": "bottom, middle or top of the space every place leaves free",
245
+ "--lines": "1 to 3 lines a caption (karaoke and line)",
246
+ "--max-chars": "characters a line, 8 to 80; by default what fits the width at the size, at most 42",
247
+ "--size": "letters as a percent of the phone's width, or of the frame's short side, 2 to 20",
248
+ "--brand": "an easybrandkit folder or its brand.json: its button, link or logo color lights the spoken word, and its heading font is asked for by name when there is no --fonts or --font",
249
+ "--fonts": "easyfontkit's folder or its fonts.css: the site's own font files, its heading font first",
250
+ "--font": "a family from fonts.css or this machine",
251
+ "--highlight": "the spoken word's color; a dark one lights the word on a patch of it, or in a thick border of it with --libass",
252
+ "--box": "a dark box behind each line instead of the outline",
253
+ };
254
+ const places = PLACE_NAMES.map((key) => {
255
+ const p = PLACES[key];
256
+ const entries = p.screen ? [["screen", p]] : Object.entries(p.shapes).filter(([, v]) => typeof v !== "string");
257
+ return { place: key, name: p.name, zones: entries.map(([shape, z]) => ({ shape, size: z.screen ? `${z.screen.width}x${z.screen.height}` : `${z.frame.width}x${z.frame.height}`, margins: z.margins, column: z.column || null, source: z.source, ...(z.note ? { note: z.note } : {}), url: z.url, read: z.read })) };
258
+ });
259
+ if (json) return JSON.stringify({ styles, fields, places }, null, 2);
260
+ const lines = ["Styles", ""];
261
+ for (const [name, about] of Object.entries(styles)) lines.push(` ${name.padEnd(8)} ${about}`);
262
+ lines.push("", "Fields", "");
263
+ for (const [name, about] of Object.entries(fields)) lines.push(` ${name.padEnd(12)} ${about}`);
264
+ lines.push("", "Places and their margins, in pixels of the screen or frame named", "");
265
+ for (const p of places) {
266
+ for (const z of p.zones) {
267
+ const m = z.margins;
268
+ lines.push(` ${`${p.place}${p.zones.length > 1 ? ` (${z.shape})` : ""}`.padEnd(22)} ${z.size}: top ${m.top}, bottom ${m.bottom}, left ${m.left}, right ${m.right}${z.column ? `, buttons ${z.column.width} wide from ${z.column.from} down` : ""}`);
269
+ lines.push(` ${"".padEnd(22)} from ${z.source}, read ${z.read}`);
270
+ if (z.note) lines.push(` ${"".padEnd(22)} ${z.note}`);
271
+ }
272
+ }
273
+ return lines.join("\n");
274
+ }
275
+
276
+ function summary({ dir, facts: f }) {
277
+ const lines = [];
278
+ const L = f.layout;
279
+ if (f.subtitlesOnly) {
280
+ lines.push(`Wrote ${f.cues.length} ${f.cues.length === 1 ? "caption" : "captions"} from ${shown(f.source.file)} to ${shown(dir)}: captions.srt, captions.vtt, captions.ass, words.json, captions.json and CAPTIONS.md`);
281
+ } else {
282
+ const o = f.output;
283
+ lines.push(`Captioned ${shown(f.video.file)} in ${shown(dir)}`);
284
+ lines.push(` Captions ${f.cues.length} in the ${f.style} style${f.style === "word" ? "" : `, up to ${L.lines} ${L.lines === 1 ? "line" : "lines"} of ${L.maxChars} characters`}, ${L.fontPx} px`);
285
+ lines.push(` Places ${list(f.places.map((z) => z.name))}, at the ${L.position}`);
286
+ const drawn = (f.fontsDrawn || []).filter((x) => x.glyphs > 0);
287
+ const libass = f.drawnWith === "libass";
288
+ // libass draws a name with what this machine gives for it, which
289
+ // fontconfig can say.
290
+ const m = libass ? f.libassFont || null : null;
291
+ let font;
292
+ // The first font of the list, or the fonts that drew the captions
293
+ // where it has none of their letters, as a Latin one has none of
294
+ // Japanese.
295
+ const firstDrew = drawn.some((x) => x.family === f.fontFirst);
296
+ // With libass, the letters the font lacks are in the fonts captions.ass
297
+ // sets them in, which can be all of them, as Japanese over a Latin font.
298
+ const fallbacks = libass ? f.assFallbacks || [] : [];
299
+ const none = libass && setsNone(f);
300
+ if (!f.look.family) font = libass ? `Arial, asked for by name${m && !sameName(m.family, "Arial") ? ` (fontconfig gives ${m.family})` : ""}` : f.fontFirst ? (drawn.length && !firstDrew ? `${f.fontFirst}, which has none of these letters: ${list(drawn.map((x) => x.family))} drew them` : f.fontFirst) : drawn.length ? `a common sans (${list(drawn.map((x) => x.family))} drew it)` : "a common sans";
301
+ else if (libass && variableFace(f.look) && setsAny(f)) font = `${f.look.family} from fonts.css, which libass draws at its variable file's default weight, not at ${f.look.weight}`;
302
+ else if (libass && f.look.from === "machine") font = `${f.look.family} ${f.look.weight}, asked for by name${!m ? "; libass draws whatever this machine gives for that name" : sameName(m.family, f.look.family) ? "" : ` (not on this machine: fontconfig gives ${m.family})`}`;
303
+ else font = `${f.look.family} ${f.look.weight}${f.look.from === "fonts.css" ? " from fonts.css" : ""}`;
304
+ // libass found it by the name captions.ass gives it, the one in its file.
305
+ const renamed = libass && f.look.from === "fonts.css" && f.assFamily && !sameName(f.assFamily, f.look.family);
306
+ if (renamed) font += `, which captions.ass names ${f.assFamily}, as its file does`;
307
+ const families = list(fallbacks.map((x) => x.family));
308
+ const set = fallbacks.reduce((a, b) => a + b.letters, 0);
309
+ if (none) font += `, ${renamed ? "and which" : "which"} has none of these letters: captions.ass sets them in ${families}`;
310
+ else if (fallbacks.length) font += `; captions.ass sets the ${set} ${set === 1 ? "letter" : "letters"} it lacks in ${families}`;
311
+ const missing = drawn.length && f.look.family && !drawn.some((x) => drewWith(x, f.look));
312
+ const why = f.look.from !== "machine" ? "its file drew nothing" : machineHas(f) ? "it has none of these letters" : "not on this machine";
313
+ // With Chrome, the fonts that drew the letters the font lacks, where
314
+ // it drew some itself, as CAPTIONS.md counts them.
315
+ const own = libass ? [] : drawn.filter((x) => (f.look.family ? drewWith(x, f.look) : x.family === f.fontFirst));
316
+ const others = own.length ? drawn.filter((x) => !own.includes(x)) : [];
317
+ const rest = others.reduce((a, b) => a + b.glyphs, 0);
318
+ const filled = others.length ? `; Chrome drew the ${rest} ${rest === 1 ? "letter" : "letters"} it lacks with ${list(others.map((x) => (others.length > 1 ? `${x.family} (${x.glyphs})` : x.family)))}` : "";
319
+ lines.push(` Font ${font}${missing ? ` (${why}: ${list(drawn.map((x) => x.family))} drew them)` : filled}`);
320
+ if (f.style !== "line") lines.push(` Lit word ${f.look.highlightMode !== "patch" ? f.look.highlight : libass ? `in a thick border of ${f.look.highlight}` : `on a patch of ${f.look.highlight}`}`);
321
+ lines.push(` Video captioned.mp4, ${o.width}x${o.height}, ${o.fps} fps, ${sec(o.duration)}, ${soundText(o.audio)}`);
322
+ }
323
+ const r = f.reading;
324
+ lines.push(` Reading median ${r.median} characters a second${r.fast.length ? `, ${r.fast.length} ${r.fast.length === 1 ? "caption" : "captions"} faster than ${r.limit}` : `, none faster than ${r.limit}`}`);
325
+ for (const note of f.notes) lines.push(` Note ${shown(note)}`);
326
+ const md = shown(path.join(dir, "CAPTIONS.md"));
327
+ lines.push("", f.subtitlesOnly ? `Read ${md} for the details.` : `Read ${md}, then look at sheet.png and safe-zones.png.`);
328
+ // Each line one line, as CAPTIONS.md's are, whatever the font names in it.
329
+ return lines.map(shown).join("\n");
330
+ }
331
+
332
+ async function main() {
333
+ // Whatever reads the output can go away while the run goes on, as head
334
+ // does once it has its lines: what is written after that is lost, and
335
+ // the run still ends as it would, its files in place.
336
+ for (const stream of [process.stdout, process.stderr]) stream.on("error", () => {});
337
+ let options;
338
+ try {
339
+ options = parse(process.argv.slice(2));
340
+ } catch (err) {
341
+ process.stderr.write(`easycaptionkit: ${shown(err.message)}\n\n${USAGE}\n`);
342
+ process.exit(2);
343
+ }
344
+ if (options.help) return void process.stdout.write(`${USAGE}\n`);
345
+ if (options.version) return void process.stdout.write(`${version}\n`);
346
+ if (options.command === "styles") return void process.stdout.write(`${stylesText(options.json)}\n`);
347
+ // A call the kit refuses, or a run that fails, says why with code 1, as
348
+ // JSON on stdout too with --json, which writes a control character out;
349
+ // the terminal gets the message on one line.
350
+ const refuse = (err) => {
351
+ if (options.json) process.stdout.write(JSON.stringify({ ok: false, error: err.message }) + "\n");
352
+ process.stderr.write(`easycaptionkit: ${shown(err.message)}\n`);
353
+ process.exit(1);
354
+ };
355
+ if (options.dryRun) {
356
+ try {
357
+ return void process.stdout.write(`${dryRun(options)}\n`);
358
+ } catch (err) {
359
+ return refuse(err);
360
+ }
361
+ }
362
+ const log = options.json ? () => {} : (line) => process.stderr.write(`${line}\n`);
363
+ const { captionVideo, makeSubtitles, stopOnSignals, stopped, began } = require("./lib/caption");
364
+ stopOnSignals({ json: options.json });
365
+ try {
366
+ const { programOf } = require("./lib/report");
367
+ // The commands the run prints start with the program as it was started.
368
+ const common = { ...options, log, program: programOf() };
369
+ const result = options.command === "subtitles" ? await makeSubtitles(options.words, common) : await captionVideo(options.video, common);
370
+ process.stdout.write(options.json ? JSON.stringify(result.captions, null, 2) + "\n" : summary(result) + "\n");
371
+ } catch (err) {
372
+ // Ctrl-C stops the whole process group, and ffmpeg or Chrome can end
373
+ // before this process hears its own signal: a run that failed after it
374
+ // began gives that signal a moment to come, and a stopped run has said
375
+ // so and ends by it. A signal that came while the run waited for its
376
+ // words is heard once the process turns to what came in.
377
+ await new Promise((resolve) => setImmediate(resolve));
378
+ if (began()) await new Promise((resolve) => setTimeout(resolve, 200));
379
+ if (stopped()) return;
380
+ refuse(err);
381
+ }
382
+ }
383
+
384
+ if (require.main === module) main();
385
+
386
+ module.exports = { parse, summary, dryRun, stylesText, USAGE };
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+
3
+ // One animated PNG from PNG files of the same size and kind, each shown
4
+ // for its own whole number of milliseconds. ffmpeg reads it as one stream
5
+ // with every frame at its time, in a time base of 1/100000 s, through its
6
+ // apng demuxer, which opens nothing but the file it is given and reads it
7
+ // the same way from ffmpeg 4.4 on. A concat list of the PNGs would name
8
+ // other files, which a guard that keeps ffmpeg to its inputs refuses, and
9
+ // only ffmpeg 5.1 and newer time its pictures to the millisecond.
10
+
11
+ const fs = require("node:fs");
12
+
13
+ const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
14
+ // A frame's time is a 16-bit count of its delay's unit: a longer one is
15
+ // written as the same picture again.
16
+ const LONGEST = 60_000;
17
+
18
+ const TABLE = (() => {
19
+ const table = new Int32Array(256);
20
+ for (let n = 0; n < 256; n++) {
21
+ let c = n;
22
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
23
+ table[n] = c;
24
+ }
25
+ return table;
26
+ })();
27
+
28
+ function crc32(parts) {
29
+ let c = -1;
30
+ for (const part of parts) for (let i = 0; i < part.length; i++) c = TABLE[(c ^ part[i]) & 0xff] ^ (c >>> 8);
31
+ return (c ^ -1) >>> 0;
32
+ }
33
+
34
+ // A PNG as its parts: the header (IHDR's contents), the chunks between the
35
+ // header and the picture that say how to read it (a palette, its
36
+ // transparency, the color space), and the picture's compressed data, the
37
+ // contents of its IDAT chunks in order.
38
+ function readPng(file) {
39
+ const buf = fs.readFileSync(file);
40
+ if (buf.length < 8 || !buf.subarray(0, 8).equals(SIGNATURE)) throw new Error(`${file} is not a PNG`);
41
+ let header = null;
42
+ const before = [];
43
+ const data = [];
44
+ for (let at = 8; at + 12 <= buf.length; ) {
45
+ const length = buf.readUInt32BE(at);
46
+ const type = buf.toString("latin1", at + 4, at + 8);
47
+ if (at + 12 + length > buf.length) break;
48
+ const body = buf.subarray(at + 8, at + 8 + length);
49
+ at += 12 + length;
50
+ if (type === "IEND") break;
51
+ if (type === "IHDR") header = body;
52
+ else if (type === "IDAT") data.push(body);
53
+ else if (!data.length && !["acTL", "fcTL", "fdAT"].includes(type)) before.push({ type, body });
54
+ }
55
+ if (!header || header.length !== 13 || !data.length) throw new Error(`${file} is not a whole PNG`);
56
+ return { header, before, data };
57
+ }
58
+
59
+ // The chunks a frame has to share with the first one to be read the same
60
+ // way: its size and pixel format, and its palette.
61
+ const kindOf = (png) => [png.header, ...png.before.filter((c) => c.type === "PLTE" || c.type === "tRNS").map((c) => Buffer.concat([Buffer.from(c.type, "latin1"), c.body]))].map((b) => b.toString("hex")).join(":");
62
+
63
+ // Writes `file` from frames: [{ png, ms }], each PNG shown for `ms`
64
+ // milliseconds, a whole number above 0. Every PNG has the size and pixel
65
+ // format of the first, and covers the whole picture.
66
+ function writeApng(file, frames) {
67
+ const entries = [];
68
+ for (const frame of frames) {
69
+ if (!(Number.isInteger(frame.ms) && frame.ms > 0)) throw new Error(`a frame of ${file} lasts ${frame.ms} ms`);
70
+ for (let left = frame.ms; left > 0; left -= LONGEST) entries.push({ png: frame.png, ms: Math.min(left, LONGEST) });
71
+ }
72
+ if (!entries.length) throw new Error(`${file} would have no frames`);
73
+ // A picture shown more than once (the empty one, between captions) is
74
+ // read once; the others are read when their turn comes.
75
+ const uses = new Map();
76
+ for (const e of entries) uses.set(e.png, (uses.get(e.png) || 0) + 1);
77
+ const kept = new Map();
78
+ const read = (png) => {
79
+ if (kept.has(png)) return kept.get(png);
80
+ const parts = readPng(png);
81
+ if (uses.get(png) > 1) kept.set(png, parts);
82
+ return parts;
83
+ };
84
+ const first = read(entries[0].png);
85
+ const kind = kindOf(first);
86
+ const width = first.header.readUInt32BE(0);
87
+ const height = first.header.readUInt32BE(4);
88
+ const fd = fs.openSync(file, "w");
89
+ try {
90
+ const put = (type, ...parts) => {
91
+ const head = Buffer.alloc(8);
92
+ head.writeUInt32BE(parts.reduce((n, p) => n + p.length, 0), 0);
93
+ head.write(type, 4, "latin1");
94
+ const crc = Buffer.alloc(4);
95
+ crc.writeUInt32BE(crc32([head.subarray(4), ...parts]), 0);
96
+ fs.writeSync(fd, Buffer.concat([head, ...parts, crc]));
97
+ };
98
+ fs.writeSync(fd, SIGNATURE);
99
+ put("IHDR", first.header);
100
+ const actl = Buffer.alloc(8);
101
+ actl.writeUInt32BE(entries.length, 0);
102
+ // Played once.
103
+ actl.writeUInt32BE(1, 4);
104
+ put("acTL", actl);
105
+ for (const chunk of first.before) put(chunk.type, chunk.body);
106
+ let sequence = 0;
107
+ entries.forEach((entry, i) => {
108
+ const png = i === 0 ? first : read(entry.png);
109
+ if (kindOf(png) !== kind) throw new Error(`${entry.png} is not the size or pixel format of ${entries[0].png}`);
110
+ const fctl = Buffer.alloc(26);
111
+ fctl.writeUInt32BE(sequence++, 0);
112
+ fctl.writeUInt32BE(width, 4);
113
+ fctl.writeUInt32BE(height, 8);
114
+ // At the top left corner; the delay in milliseconds; the picture
115
+ // left as it is after the frame, and the frame put over it whole,
116
+ // its transparent pixels too.
117
+ fctl.writeUInt16BE(entry.ms, 20);
118
+ fctl.writeUInt16BE(1000, 22);
119
+ put("fcTL", fctl);
120
+ for (const data of png.data) {
121
+ if (i === 0) put("IDAT", data);
122
+ else {
123
+ const number = Buffer.alloc(4);
124
+ number.writeUInt32BE(sequence++, 0);
125
+ put("fdAT", number, data);
126
+ }
127
+ }
128
+ });
129
+ put("IEND");
130
+ } finally {
131
+ fs.closeSync(fd);
132
+ }
133
+ return entries.length;
134
+ }
135
+
136
+ module.exports = { writeApng, readPng, crc32 };