easycaptionkit 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+
3
+ // What a run has going at a time: the programs it started (ffmpeg,
4
+ // ffprobe, Chrome) and the temporary folders they use. A run that is
5
+ // stopped with Ctrl-C or a kill ends the programs and removes the folders,
6
+ // so no ffmpeg keeps encoding into a deleted file and no Chrome profile is
7
+ // left in the temporary folder.
8
+
9
+ const children = new Map();
10
+ const folders = new Map();
11
+
12
+ function kill(child) {
13
+ try {
14
+ child.kill("SIGKILL");
15
+ } catch {
16
+ // Already gone.
17
+ }
18
+ }
19
+
20
+ // `stop` ends the child and what it started that would outlive it, as
21
+ // Chrome's helper processes would; by default the child alone is killed.
22
+ // It runs at once, since a run that is stopped ends right after.
23
+ function track(child, stop = () => kill(child)) {
24
+ children.set(child, stop);
25
+ const done = () => children.delete(child);
26
+ child.once("exit", done);
27
+ child.once("error", done);
28
+ return child;
29
+ }
30
+
31
+ // `remove` takes the folder away; it runs when the run is stopped.
32
+ function trackFolder(dir, remove) {
33
+ folders.set(dir, remove);
34
+ return dir;
35
+ }
36
+
37
+ function untrackFolder(dir) {
38
+ folders.delete(dir);
39
+ }
40
+
41
+ // Waits without letting anything else run, as a run that is stopped has
42
+ // to before it ends.
43
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
44
+
45
+ function stopAll() {
46
+ const killed = children.size > 0;
47
+ for (const [, stop] of children) stop();
48
+ children.clear();
49
+ // A moment for the killed programs to be gone, so none of them writes
50
+ // into a folder after it is removed.
51
+ if (killed && folders.size) sleep(200);
52
+ for (const [dir, remove] of folders) {
53
+ try {
54
+ remove(dir);
55
+ } catch {
56
+ // Left for the system's temp cleanup.
57
+ }
58
+ }
59
+ folders.clear();
60
+ }
61
+
62
+ module.exports = { track, trackFolder, untrackFolder, stopAll, kill, sleep };
@@ -0,0 +1,489 @@
1
+ "use strict";
2
+
3
+ // captions.srt, captions.vtt and captions.ass from the cues. SRT and VTT
4
+ // carry the text and the times, for the places that take a captions file
5
+ // (YouTube, LinkedIn, X, Vimeo, a web page's <track>). The ASS file also
6
+ // carries the look: the font, the colors, the outline or the box and
7
+ // where the captions sit, so a player that reads ASS (VLC, mpv) or
8
+ // ffmpeg's ass filter draws them close to the burned ones.
9
+
10
+ const { cueLines } = require("./cues");
11
+ const { width, clusters, ARABIC, RTL } = require("./text");
12
+ const { readFont, loadFont, assScale } = require("./fontfile");
13
+
14
+ const pad = (n, size = 2) => String(n).padStart(size, "0");
15
+
16
+ function clock(t, separator) {
17
+ const ms = Math.max(0, Math.round(t * 1000));
18
+ const h = Math.floor(ms / 3600000);
19
+ const m = Math.floor((ms % 3600000) / 60000);
20
+ const s = Math.floor((ms % 60000) / 1000);
21
+ return `${pad(h)}:${pad(m)}:${pad(s)}${separator}${pad(ms % 1000, 3)}`;
22
+ }
23
+
24
+ // SRT has no way to escape: its readers take <...> for a tag and {...}
25
+ // for an ASS override, and those that turn it into ASS (ffmpeg, mpv) take
26
+ // \N for a line break. These stand in for them, as in captions.ass, so the
27
+ // words read the same in every player.
28
+ const escapeSrt = (text) => text.replace(/</g, "‹").replace(/>/g, "›").replace(/\{/g, "(").replace(/\}/g, ")").replace(/\\/g, "⧵");
29
+ const SRT_SPECIAL = /[<>{}\\]/;
30
+
31
+ function srt(cues) {
32
+ return cues.map((cue, i) => `${i + 1}\n${clock(cue.start, ",")} --> ${clock(cue.end, ",")}\n${cue.lines.map(escapeSrt).join("\n")}\n`).join("\n");
33
+ }
34
+
35
+ const escapeVtt = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
36
+ // A line break ends a NOTE, so none goes in one.
37
+ const oneLine = (text) => String(text).replace(/[\u0000-\u001f\u007f]+/g, " ");
38
+
39
+ function vtt(cues, { note = "" } = {}) {
40
+ const head = note ? `WEBVTT\n\nNOTE ${oneLine(note).replace(/-->/g, "->")}\n` : "WEBVTT\n";
41
+ return `${head}\n${cues.map((cue, i) => `${i + 1}\n${clock(cue.start, ".")} --> ${clock(cue.end, ".")}\n${cue.lines.map(escapeVtt).join("\n")}\n`).join("\n")}`;
42
+ }
43
+
44
+ // ─── ASS ──────────────────────────────────────────────────────────────────
45
+
46
+ // ASS times are centiseconds: H:MM:SS.cc.
47
+ function assClock(t) {
48
+ const cs = Math.max(0, Math.round(t * 100));
49
+ const h = Math.floor(cs / 360000);
50
+ const m = Math.floor((cs % 360000) / 6000);
51
+ const s = Math.floor((cs % 6000) / 100);
52
+ return `${h}:${pad(m)}:${pad(s)}.${pad(cs % 100)}`;
53
+ }
54
+
55
+ // #rrggbb with an opacity 0 to 1 as ASS writes colors: &HAABBGGRR, where
56
+ // AA is transparency.
57
+ function assColor(hex, opacity = 1) {
58
+ const [r, g, b] = rgbHex(hex);
59
+ const a = Math.round((1 - opacity) * 255).toString(16).padStart(2, "0");
60
+ return `&H${a}${b}${g}${r}`.toUpperCase();
61
+ }
62
+
63
+ // The same color in an override tag, which takes no transparency:
64
+ // &HBBGGRR&.
65
+ function assTag(hex) {
66
+ const [r, g, b] = rgbHex(hex);
67
+ return `&H${b}${g}${r}&`.toUpperCase();
68
+ }
69
+
70
+ function rgbHex(hex) {
71
+ const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
72
+ return m ? [m[1], m[2], m[3]] : ["ff", "ff", "ff"];
73
+ }
74
+
75
+ // A color at a share of its signal: what the captions drawn by Chrome get
76
+ // on an HDR video, where white would otherwise be the brightest white the
77
+ // screen can show.
78
+ function dimmed(hex, level) {
79
+ if (level >= 1) return hex;
80
+ return `#${rgbHex(hex).map((c) => Math.round(parseInt(c, 16) * level).toString(16).padStart(2, "0")).join("")}`;
81
+ }
82
+
83
+ // ASS has no way to show a literal brace or backslash in all players;
84
+ // these stand in for them.
85
+ const escapeAss = (text) => text.replace(/\\/g, "⧵").replace(/\{/g, "(").replace(/\}/g, ")");
86
+
87
+ // ─── the font captions.ass names ─────────────────────────────────────────
88
+
89
+ // Arial Bold's widths in thousandths of an em, from the space to the
90
+ // tilde: Helvetica Bold's, which Liberation Sans Bold shares too.
91
+ const ARIAL_BOLD = [
92
+ 278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611,
93
+ 975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556,
94
+ 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
95
+ ];
96
+ // Arial's OS/2 winAscent and winDescent over its em, as Liberation Sans has
97
+ // them too: libass makes a font that tall for its size.
98
+ const arialAdvance = (c) => (c.length === 1 && c >= " " && c <= "~" ? ARIAL_BOLD[c.charCodeAt(0) - 32] / 1000 : NaN);
99
+ const ARIAL = { scale: (1854 + 434) / 2048, advance: arialAdvance, has: (c) => Number.isFinite(arialAdvance(c)), from: "Arial", family: "" };
100
+
101
+ // A name a Style line can carry: it ends at a line break and its fields
102
+ // at commas.
103
+ const styleName = (name) => Boolean(name) && !/[\u0000-\u001f\u007f,]/.test(name);
104
+
105
+ // The font captions.ass names, as libass will make it: { scale, advance,
106
+ // has, from, family }. scale is the ASS size whose em is one pixel,
107
+ // advance(c) a letter's width in ems, NaN for one the font does not have
108
+ // or whose file was not read, and has(c) whether the letter is the font's.
109
+ // From the font's own files where they are known: the faces of fonts.css
110
+ // nearest the weight, each file for the letters it has, as a site splits a
111
+ // family by script; else the file fontconfig gives for the name,
112
+ // `machine`. Else Arial's, which libass draws when no font is named.
113
+ // family is the name captions.ass gives a fonts.css font: the one in its
114
+ // file, which libass finds a file of its fonts folder by, and which
115
+ // fonts.css can give otherwise (easyfontkit calls InterVariable Inter).
116
+ // Empty when the file gives no name a Style line can carry, or the font is
117
+ // not from fonts.css.
118
+ //
119
+ // `fallback(codePoint)` gives the font fontconfig gives for a letter the
120
+ // font lacks, { family, file, index, outline }, which libass draws it
121
+ // with. libass sizes each font by its own heights, so captions.ass sets
122
+ // those letters in that font at a size of its own: the font then has
123
+ // `fallback(c)`, the font a letter is set in, { family, scale, advance },
124
+ // or null.
125
+ function assFont(look, machine = null, { fallback = null, budget = BUDGET } = {}) {
126
+ const upright = (look.faces || []).filter((f) => !f.italic);
127
+ const faces = upright.length ? upright : look.faces || [];
128
+ const nearest = faces.length ? Math.min(...faces.map((f) => distance(f, look.weight))) : 0;
129
+ const files = faces.filter((f) => distance(f, look.weight) === nearest);
130
+ const kit = files.length ? familyOf(files, budget) : null;
131
+ const first = kit ? kit.first : machine ? readFont(machine.file, machine.index) : null;
132
+ const scale = first ? assScale(first) : 0;
133
+ if (!(scale > 0.5 && scale < 3)) return ARIAL;
134
+ const advance = kit ? kit.advance : first.advance;
135
+ const has = kit ? kit.has : (c) => Number.isFinite(first.advance(c));
136
+ const own = kit ? kit.family : "";
137
+ const font = { scale, advance, has, from: files.length ? files[0].name || files[0].file : machine.file, family: styleName(own) ? own : "" };
138
+ if (fallback) font.fallback = fallbacksOf(fallback);
139
+ return font;
140
+ }
141
+
142
+ // What is read of a fonts.css family's files in all, at most, counting
143
+ // what WOFF and WOFF2 files unpack to: a site splits a CJK family into a
144
+ // hundred files and more, each small, and a file of a few hundred bytes
145
+ // can say it unpacks to many megabytes.
146
+ const BUDGET = 32 * 1024 * 1024;
147
+
148
+ // A unicode-range as [first, last] pairs, or null for none, which covers
149
+ // every letter: U+0025-00FF, U+4??, U+3000.
150
+ function rangesOf(text) {
151
+ if (!text) return null;
152
+ return String(text)
153
+ .split(",")
154
+ .map((part) => /^\s*U\+([0-9a-f?]{1,6})(?:-([0-9a-f]{1,6}))?\s*$/i.exec(part))
155
+ .filter(Boolean)
156
+ .map((m) => [parseInt(m[1].replace(/\?/g, "0"), 16), m[2] ? parseInt(m[2], 16) : parseInt(m[1].replace(/\?/g, "f"), 16)]);
157
+ }
158
+
159
+ // The files of a fonts.css family at one weight, each read once a letter
160
+ // in its unicode-range is asked for, as Chrome loads them, and no more than
161
+ // `budget` bytes of them in all, files and what they unpack to. { first,
162
+ // family, advance, has }: the first file that reads, which gives the size,
163
+ // and the family name in it or in the next file that has one. A letter in
164
+ // the range of a file that was not read, or is no font this kit reads, is
165
+ // the family's still: libass finds that file by its name.
166
+ function familyOf(faces, budget) {
167
+ const list = faces.map((face) => ({ face, ranges: rangesOf(face.unicodeRange), font: undefined }));
168
+ let left = budget;
169
+ const fontOf = (item) => {
170
+ if (item.font !== undefined) return item.font;
171
+ const { font, size } = loadFont(item.face.file, 0, left);
172
+ left -= size;
173
+ item.font = font;
174
+ return item.font;
175
+ };
176
+ const inRange = (item, code) => !item.ranges || item.ranges.some(([a, b]) => code >= a && code <= b);
177
+ // Each letter once: a family of many files has many ranges to look in.
178
+ const known = new Map();
179
+ const lookUp = (c) => {
180
+ const code = c.codePointAt(0);
181
+ if (known.has(code)) return known.get(code);
182
+ let found = { advance: NaN, has: false };
183
+ for (const item of list) {
184
+ if (!inRange(item, code)) continue;
185
+ const font = fontOf(item);
186
+ const w = font ? font.advance(c) : NaN;
187
+ if (Number.isFinite(w)) {
188
+ found = { advance: w, has: true };
189
+ break;
190
+ }
191
+ if (!font && item.ranges) found.has = true;
192
+ }
193
+ known.set(code, found);
194
+ return found;
195
+ };
196
+ let first = null;
197
+ let family = "";
198
+ for (const item of list) {
199
+ const font = fontOf(item);
200
+ if (font && !first) first = font;
201
+ if (font && font.family) family = font.family;
202
+ if (first && family) break;
203
+ }
204
+ return { first, family, advance: (c) => lookUp(c).advance, has: (c) => lookUp(c).has };
205
+ }
206
+
207
+ // A name an override tag can carry: it ends at a backslash or a brace.
208
+ const tagName = (name) => styleName(name) && !/[\\{}]/.test(name);
209
+
210
+ // The marks that join or vary the letter before them and draw nothing of
211
+ // their own: the zero width joiners, the variation selectors and the tags
212
+ // of a flag.
213
+ const JOINER = /^[\u200c\u200d\ufe00-\ufe0f\u{e0020}-\u{e007f}\u{e0100}-\u{e01ef}]$/u;
214
+ const NONE = Object.freeze({ none: true });
215
+
216
+ // The fonts given for the letters a font lacks, each read once: a letter
217
+ // a font found already has is set in it, and fontconfig is asked about a
218
+ // letter once, and about 64 at most. An Arabic letter is set only in a
219
+ // font found for an Arabic letter, from the Arabic families Chrome draws
220
+ // them with, and not in one found for an arrow, say, that has them too. A
221
+ // letter of several, as an emoji with its skin tone is, goes in a font
222
+ // that has them all. libass draws no font of pictures alone, as a color
223
+ // emoji font is, and fontconfig gives a font that lacks a letter only
224
+ // when no font has it: `none(c)` says fontconfig gives no font libass can
225
+ // draw the letter with, so libass draws a box for it.
226
+ function fallbacksOf(give) {
227
+ const found = [];
228
+ const asked = new Map();
229
+ const fontFor = (first, arabic) => {
230
+ const code = first.codePointAt(0);
231
+ if (asked.has(code)) return asked.get(code);
232
+ if (asked.size >= 64) return null;
233
+ const given = give(code);
234
+ let got = null;
235
+ if (given && given.outline === false) got = NONE;
236
+ else if (given && tagName(given.family)) {
237
+ const font = readFont(given.file, given.index);
238
+ const scale = font ? assScale(font) : 0;
239
+ if (font && scale > 0.5 && scale < 3) got = Number.isFinite(font.advance(first)) ? { family: given.family, scale, advance: font.advance, arabic } : NONE;
240
+ }
241
+ if (got && got !== NONE) found.push(got);
242
+ asked.set(code, got);
243
+ return got;
244
+ };
245
+ // The font for a letter, NONE, or null where nothing can say.
246
+ const settle = (c) => {
247
+ const points = Array.from(c).filter((p) => !JOINER.test(p));
248
+ if (!points.length) return null;
249
+ const arabic = ARABIC.test(points[0]);
250
+ const all = (f) => points.every((p) => Number.isFinite(f.advance(p)));
251
+ for (const f of found) if (f.arabic === arabic && all(f)) return f;
252
+ const got = fontFor(points[0], arabic);
253
+ return got && got !== NONE && !all(got) ? NONE : got;
254
+ };
255
+ const fallback = (c) => {
256
+ const got = settle(c);
257
+ return got === NONE ? null : got;
258
+ };
259
+ fallback.none = (c) => settle(c) === NONE;
260
+ return fallback;
261
+ }
262
+
263
+ // The font a letter is set in: null for the font itself, which has it,
264
+ // else the one given for it. A space stays in the font of what is around
265
+ // it.
266
+ function setIn(c, font, now) {
267
+ if (/^\s+$/.test(c)) return now;
268
+ if (!font.fallback || font.has(Array.from(c)[0])) return null;
269
+ return font.fallback(c);
270
+ }
271
+
272
+ const distance = (face, weight) => (weight < face.weight[0] ? face.weight[0] - weight : weight > face.weight[1] ? weight - face.weight[1] : 0);
273
+
274
+ // A line's width in ems: each letter by its width in the font, a letter
275
+ // it lacks by its width in the font it is set in, else by its base letter,
276
+ // else as the lines are cut, 0.6 of an em a column, so a wide letter of
277
+ // Chinese or Japanese is an em and more.
278
+ function emsOf(line, font) {
279
+ let ems = 0;
280
+ for (const c of clusters(line)) {
281
+ const first = Array.from(c)[0];
282
+ let w = font.advance(first);
283
+ if (!Number.isFinite(w) && font.fallback) {
284
+ const other = setIn(c, font, null);
285
+ if (other) w = other.advance(first);
286
+ }
287
+ if (!Number.isFinite(w)) w = font.advance(Array.from(c.normalize("NFD"))[0]);
288
+ if (!Number.isFinite(w)) w = width(c) * 0.6;
289
+ ems += w;
290
+ }
291
+ return ems;
292
+ }
293
+
294
+ // How much smaller each caption is drawn so that its widest line, outline
295
+ // included, fits the safe space, in the shape Chrome measures them in: a
296
+ // scale for each cue, or for each word of a cue in the word style, 1 for
297
+ // one that fits. Without Chrome's measure it is an estimate from the
298
+ // widths of the letters in the font captions.ass names, leaving kerning
299
+ // out, so a line in capitals, which are wider than the lines are cut for,
300
+ // comes out smaller too.
301
+ function estimatedScales(cues, words, { style, place, font = ARIAL }) {
302
+ const fit = (lines) => {
303
+ const wide = lines.reduce((most, line) => Math.max(most, emsOf(line, font)), 0) * place.fontPx;
304
+ return wide > place.glyphWidth && wide > 0 ? Math.max(0.01, place.glyphWidth / wide) : 1;
305
+ };
306
+ return cues.map((cue) => (style === "word" ? words.slice(cue.first, cue.last + 1).map((w) => fit([w.text])) : fit(cue.lines)));
307
+ }
308
+
309
+ // How many captions (or words, in the word style) are drawn smaller, and
310
+ // the smallest scale.
311
+ function shrunkOf(scales) {
312
+ const all = scales.flat();
313
+ return { count: all.filter((x) => x < 1).length, smallest: all.reduce((a, b) => Math.min(a, b), 1) };
314
+ }
315
+
316
+ // One Dialogue line per state: a whole cue for the line style, the cue
317
+ // with its spoken word in the highlight color for karaoke, a word alone,
318
+ // lit the same way, for the word style. `level` dims every color, for
319
+ // the burn of an HDR video. `scales` makes a caption smaller, as the
320
+ // burned ones are; without it, the estimate above does. The font's size
321
+ // gives it an em of the layout's letter size, as Chrome draws it: libass
322
+ // makes a font as tall as its OS/2 heights, not its em, which for Arial
323
+ // is 1.12 of the em and for other fonts up to 1.4 and more. `matrix` is
324
+ // the YCbCr Matrix line, which players and ffmpeg 8's ass filter convert
325
+ // the colors with: the video's own matrix and range (colorOf), else what
326
+ // players take for the frame's size. `tint` gives the color written for
327
+ // each color, for an ass filter that converts every color as BT.601 does.
328
+ function ass(cues, words, { states, style, look, place, title = "", level = 1, scales = null, font = ARIAL, matrix = "", tint = null }) {
329
+ const sizes = scales || estimatedScales(cues, words, { style, place, font });
330
+ const { width: W, height: H } = place.frame;
331
+ const size = Math.round(place.fontPx * font.scale);
332
+ const outline = look.box ? 0 : Math.max(1, Math.round(place.fontPx * 0.09));
333
+ const border = look.box ? Math.round(place.fontPx * 0.18) : outline;
334
+ const shadow = look.box ? 0 : 2;
335
+ const paint = (hex) => (tint ? tint(hex) : hex);
336
+ const text = paint(dimmed(look.textColor, level));
337
+ const primary = assColor(text);
338
+ const outlineColor = look.box ? assColor("#000000", look.boxOpacity) : assColor("#000000");
339
+ const back = assColor("#000000", look.box ? look.boxOpacity : 0.45);
340
+ const borderStyle = look.box ? 3 : 1;
341
+ const side = Math.round(place.side);
342
+ // Where the block sits: its bottom edge, its middle or its top edge. The
343
+ // outline or the box, and the shadow under it, reach past the letters'
344
+ // lines, so they are kept inside the block too.
345
+ const alignment = place.position === "top" ? 8 : place.position === "middle" ? 5 : 2;
346
+ const marginV = place.position === "top" ? Math.round(place.blockTop) + border : Math.round(H - place.blockBottom) + border + shadow;
347
+ const lit = assTag(paint(dimmed(look.highlight, level)));
348
+ const plain = assTag(text);
349
+ // The spoken word: in its color, or, when the color is too dark for
350
+ // letters, as letters in the ink color with a thick border of it, which
351
+ // is as close to the burned patch as ASS comes.
352
+ const patch = look.highlightMode === "patch";
353
+ const thick = Math.max(4, Math.round(place.fontPx * 0.16));
354
+ const on = patch ? `{\\1c${assTag(paint(dimmed(look.highlightInk, level)))}\\3c${lit}\\bord${thick}}` : `{\\1c${lit}}`;
355
+ const off = patch ? `{\\1c${plain}\\3c${assTag("#000000")}\\bord${border}}` : `{\\1c${plain}}`;
356
+ // A Style line ends at a line break and its fields at commas.
357
+ const family = (font.family || look.family || "Arial").replace(/[\u0000-\u001f\u007f,]/g, " ");
358
+ // A run of letters the font lacks goes in the font given for them, at
359
+ // the size that gives it the same em, and back after; where the name of
360
+ // the font cannot go in an override tag, the letters stay as they are.
361
+ const switching = Boolean(font.fallback) && tagName(family);
362
+ const home = `{\\fn${family}\\fs${size}}`;
363
+ // libass lays every line out from left to right unless the style's
364
+ // Encoding is -1, its own value for taking each line's direction from
365
+ // its first letter, as Chrome's dir="auto" does. Laid out from the left,
366
+ // a line of Arabic or Hebrew has its full stop or comma at its right
367
+ // end, which is where it starts, and a Latin word in it puts the words
368
+ // around it in the wrong order. VSFilter reads the field as a Windows
369
+ // character set, so -1 goes only into captions that need it.
370
+ const encoding = cues.some((cue) => cue.lines.some((line) => RTL.test(line))) ? -1 : 1;
371
+ const head = [
372
+ "[Script Info]",
373
+ `; Written by ${look.tool}. The same captions as the burned ones, with the same look where ASS allows it.`,
374
+ ...(title ? [`Title: ${title.replace(/[\u0000-\u001f\u007f]+/g, " ")}`] : []),
375
+ "ScriptType: v4.00+",
376
+ `PlayResX: ${W}`,
377
+ `PlayResY: ${H}`,
378
+ "WrapStyle: 2",
379
+ "ScaledBorderAndShadow: yes",
380
+ `YCbCr Matrix: ${matrix || (H >= 720 ? "TV.709" : "TV.601")}`,
381
+ "",
382
+ "[V4+ Styles]",
383
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
384
+ `Style: Caption,${family},${size},${primary},${primary},${outlineColor},${back},${look.weight >= 600 ? -1 : 0},0,0,0,100,100,0,0,${borderStyle},${border},${shadow},${alignment},${side},${side},${marginV},${encoding}`,
385
+ "",
386
+ "[Events]",
387
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
388
+ ];
389
+ const position = alignment === 5 ? `{\\an5\\pos(${Math.round(W / 2)},${Math.round((place.blockTop + place.blockBottom) / 2)})}` : "";
390
+ // Each cue's lines as words, set once; a state only colors one of them.
391
+ const set = new Map();
392
+ const linesOf = (c) => {
393
+ if (!set.has(c)) set.set(c, cueLines(cues[c], words).map((line) => line.map((w) => ({ ...w, t: escapeAss(w.t) }))));
394
+ return set.get(c);
395
+ };
396
+ // The letters of a word, split once: karaoke gives each word of a caption
397
+ // again for every word lit in it.
398
+ const split = new Map();
399
+ const lettersOf = (text) => {
400
+ if (!split.has(text)) split.set(text, clusters(text));
401
+ return split.get(text);
402
+ };
403
+ // An event's text from its lines, each a list of pieces { text, lit }:
404
+ // the spoken word colored, and each run of letters the font lacks set
405
+ // in the font given for them.
406
+ const textOf = (lines) => {
407
+ let out = "";
408
+ let lighting = false;
409
+ let now = null;
410
+ lines.forEach((line, n) => {
411
+ if (n) out += "\\N";
412
+ for (const piece of line) {
413
+ if (piece.lit !== lighting) {
414
+ out += piece.lit ? on : off;
415
+ lighting = piece.lit;
416
+ }
417
+ if (!switching) {
418
+ out += piece.text;
419
+ continue;
420
+ }
421
+ for (const c of lettersOf(piece.text)) {
422
+ const want = setIn(c, font, now);
423
+ if (want !== now) {
424
+ out += want ? `{\\fn${want.family}\\fs${Math.round(place.fontPx * want.scale)}}` : home;
425
+ now = want;
426
+ }
427
+ out += c;
428
+ }
429
+ }
430
+ });
431
+ return lighting ? out + off : out;
432
+ };
433
+ const events = [];
434
+ for (const state of states) {
435
+ const cue = cues[state.cue];
436
+ const scale = style === "word" ? sizes[state.cue][state.word] : sizes[state.cue];
437
+ // In whole percent, rounded down, so it still fits.
438
+ const smaller = scale < 1 ? `{\\fscx${Math.max(1, Math.floor(scale * 100))}\\fscy${Math.max(1, Math.floor(scale * 100))}}` : "";
439
+ let lines;
440
+ if (style === "word") lines = [[{ text: escapeAss(words[cue.first + state.word].text), lit: true }]];
441
+ // Each line's words, the spoken one colored.
442
+ else if (style === "karaoke" && state.word >= 0) lines = linesOf(state.cue).map((line) => line.flatMap((w) => [...(w.s ? [{ text: " ", lit: false }] : []), { text: w.t, lit: w.i === state.word }]));
443
+ else lines = cue.lines.map((line) => [{ text: escapeAss(line), lit: false }]);
444
+ events.push(`Dialogue: 0,${assClock(state.from)},${assClock(state.to)},Caption,,0,0,0,,${position}${smaller}${textOf(lines)}`);
445
+ }
446
+ return `${head.join("\n")}\n${events.join("\n")}\n`;
447
+ }
448
+
449
+ // An emoji as an emoji, in color where the font has it: one shown so by
450
+ // default, one asked for so, or a flag. A © or a ™ is a letter.
451
+ const SHOWN_AS_EMOJI = /\p{Emoji_Presentation}|\ufe0f|\p{Regional_Indicator}/u;
452
+
453
+ // How captions.ass sets the letters of the captions, the spaces left out:
454
+ // { total, own, fallbacks, missing, unsure }. own is how many are in the
455
+ // font it names, or where nothing says otherwise; fallbacks the fonts it
456
+ // sets the others in, [{ family, letters }]; missing each letter for which
457
+ // libass has no font, [{ letter, cue }], with the number of its caption;
458
+ // unsure each emoji the font lacks that nothing says the font of, as on
459
+ // macOS and Windows, where there is no fontconfig to ask.
460
+ function assLetters(cues, font) {
461
+ const counts = new Map();
462
+ const missing = [];
463
+ const unsure = [];
464
+ let total = 0;
465
+ let own = 0;
466
+ for (const cue of cues) {
467
+ for (const line of cue.lines) {
468
+ for (const c of clusters(line)) {
469
+ if (/^\s+$/.test(c)) continue;
470
+ total++;
471
+ const lacks = !font.has(Array.from(c)[0]);
472
+ const other = setIn(c, font, null);
473
+ if (other) counts.set(other.family, (counts.get(other.family) || 0) + 1);
474
+ else if (font.fallback && lacks && font.fallback.none(c)) missing.push({ letter: c, cue: cue.n });
475
+ else {
476
+ own++;
477
+ if (lacks && SHOWN_AS_EMOJI.test(c)) unsure.push({ letter: c, cue: cue.n });
478
+ }
479
+ }
480
+ }
481
+ }
482
+ return { total, own, fallbacks: [...counts].map(([family, letters]) => ({ family, letters })), missing, unsure };
483
+ }
484
+
485
+ // The letters of the captions set in another font than the one captions.ass
486
+ // names, by that font: [{ family, letters }].
487
+ const fallbackLetters = (cues, font) => assLetters(cues, font).fallbacks;
488
+
489
+ module.exports = { srt, vtt, ass, assFont, estimatedScales, shrunkOf, assLetters, fallbackLetters, clock, assClock, assColor, assTag, dimmed, escapeSrt, SRT_SPECIAL, ARIAL };
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+
3
+ // Text the way a caption counts it: characters as people see them (a
4
+ // letter with its accents, an emoji with its modifiers), East Asian and
5
+ // emoji characters two columns wide, and words joined with or without a
6
+ // space depending on the script.
7
+
8
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
9
+
10
+ // A printable ASCII character is a character of its own, and the segmenter
11
+ // takes some 20 microseconds a word, which is seconds for the words of
12
+ // hours, so plain ASCII text is split by its characters.
13
+ const ASCII = /^[\x20-\x7e]*$/;
14
+
15
+ function clusters(text) {
16
+ const string = String(text);
17
+ if (ASCII.test(string)) return string.split("");
18
+ return Array.from(graphemes.segment(string), (part) => part.segment);
19
+ }
20
+
21
+ // Full width letters, CJK, Hangul, kana and pictographs take two columns.
22
+ const WIDE = /^(?:[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏ꥠ-꥿가-힣豈-﫿︰-﹏＀-⦆¢-₩]|[\u{1F000}-\u{1FAFF}\u{20000}-\u{3FFFD}]|\p{Extended_Pictographic})/u;
23
+
24
+ // Columns a line of text takes.
25
+ function width(text) {
26
+ let n = 0;
27
+ for (const c of clusters(text)) n += WIDE.test(c) ? 2 : 1;
28
+ return n;
29
+ }
30
+
31
+ // Characters as a reader counts them for reading speed: every visible
32
+ // character and the spaces between words, not line breaks.
33
+ function characters(text) {
34
+ return clusters(String(text).replace(/\s*\n\s*/g, " ").trim()).length;
35
+ }
36
+
37
+ // Scripts written without spaces between words.
38
+ const SPACELESS = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar} -〿!-・]/u;
39
+ const CLOSING = /^[,.!?;:%)\]}»”’›、。,!?;:…‐-‒–—'’]/u;
40
+ const OPENING = /[([{«“‘‹¿¡]$/u;
41
+
42
+ // Whether a space goes between two words. It depends only on the end of
43
+ // the text before and the start of the text after.
44
+ function spaced(before, after) {
45
+ if (!before || !after) return false;
46
+ const last = clusters(before.slice(-32)).pop();
47
+ const first = clusters(after.slice(0, 32))[0];
48
+ if (SPACELESS.test(last) || SPACELESS.test(first)) return false;
49
+ if (OPENING.test(before)) return false;
50
+ // "don" + "'t", or a word followed by its own punctuation.
51
+ if (CLOSING.test(after) && !/^['’]\p{L}{3,}/u.test(after)) return false;
52
+ return true;
53
+ }
54
+
55
+ function join(words) {
56
+ let out = "";
57
+ let before = "";
58
+ for (const word of words) {
59
+ out += out && spaced(before, word) ? ` ${word}` : word;
60
+ before = word;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ // A sentence ends with the word: . ! ? … or their CJK forms, before any
66
+ // closing quotes or brackets.
67
+ const SENTENCE = /[.!?…。!?‼⁇]["'”’»)\])」』]*$/u;
68
+ // A clause ends with it: a comma, a semicolon, a colon or a dash.
69
+ const CLAUSE = /[,;:,、;:—–]["'”’»)\])」』]*$/u;
70
+
71
+ function endsSentence(word) {
72
+ // "Mr." and "e.g." end no sentence; a number like "3.5" has no end mark.
73
+ return SENTENCE.test(word) && !/^(?:mr|mrs|ms|dr|st|vs|etc|e\.g|i\.e|no)\.$/i.test(word);
74
+ }
75
+
76
+ function endsClause(word) {
77
+ return CLAUSE.test(word);
78
+ }
79
+
80
+ // Characters only Traditional Chinese writes, and their Simplified forms.
81
+ const TRADITIONAL = /[這個們來時會為國說學對開關從東動發經過還進樣點實麼現間長書車門見話讓頭]/gu;
82
+ const SIMPLIFIED = /[这个们来时会为国说学对开关从东动发经过还进样点实么现间长书车门见话让头]/gu;
83
+
84
+ // The language of CJK words, from their letters: Japanese where there is
85
+ // kana, Korean where there is Hangul, else Chinese for Han, Traditional
86
+ // where its own characters outnumber the Simplified ones. "ja", "ko",
87
+ // "zh-Hans", "zh-Hant", or "" for words with none of them.
88
+ function cjkLanguage(texts) {
89
+ const text = texts.join(" ");
90
+ if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text)) return "ja";
91
+ if (/\p{Script=Hangul}/u.test(text)) return "ko";
92
+ if (!/\p{Script=Han}/u.test(text)) return "";
93
+ return (text.match(TRADITIONAL) || []).length > (text.match(SIMPLIFIED) || []).length ? "zh-Hant" : "zh-Hans";
94
+ }
95
+
96
+ // A letter of the Arabic script, which Arabic, Persian and Urdu are
97
+ // written in, with the comma, digits and marks it shares with others.
98
+ const ARABIC = /\p{Script_Extensions=Arabic}/u;
99
+
100
+ // Whether words hold Arabic letters.
101
+ const arabicIn = (texts) => texts.some((text) => ARABIC.test(text));
102
+
103
+ // The scripts of India and Southeast Asia whose families the look names,
104
+ // in the order it names them.
105
+ const SCRIPTS = ["Devanagari", "Bengali", "Gurmukhi", "Gujarati", "Oriya", "Tamil", "Telugu", "Kannada", "Malayalam", "Sinhala", "Thai", "Lao", "Khmer", "Myanmar"];
106
+ const SCRIPT_LETTER = Object.fromEntries(SCRIPTS.map((name) => [name, new RegExp(`\\p{Script=${name}}`, "u")]));
107
+
108
+ // Which of those scripts words hold letters of.
109
+ const scriptsIn = (texts) => SCRIPTS.filter((name) => texts.some((text) => SCRIPT_LETTER[name].test(text)));
110
+
111
+ // A letter of a script written from right to left: Arabic, Hebrew,
112
+ // Syriac, Thaana, NKo and the others Unicode gives that direction.
113
+ const RTL = /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Samaritan}\p{Script=Mandaic}\p{Script=Adlam}\p{Script=Hanifi_Rohingya}\p{Script=Yezidi}\p{Script=Mende_Kikakui}]/u;
114
+
115
+ module.exports = { clusters, width, characters, spaced, join, endsSentence, endsClause, cjkLanguage, arabicIn, scriptsIn, SPACELESS, ARABIC, RTL };
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ // Kept here, not read from package.json: a skill install copies this folder
4
+ // alone.
5
+ module.exports = "0.1.1";