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.
- package/LICENSE +21 -0
- package/README.md +175 -2
- package/package.json +42 -4
- package/skills/easycaptionkit/SKILL.md +58 -0
- package/skills/easycaptionkit/scripts/easycaptionkit.js +386 -0
- package/skills/easycaptionkit/scripts/lib/apng.js +136 -0
- package/skills/easycaptionkit/scripts/lib/caption.js +1395 -0
- package/skills/easycaptionkit/scripts/lib/chrome.js +424 -0
- package/skills/easycaptionkit/scripts/lib/cues.js +317 -0
- package/skills/easycaptionkit/scripts/lib/draw.js +288 -0
- package/skills/easycaptionkit/scripts/lib/ffmpeg.js +1032 -0
- package/skills/easycaptionkit/scripts/lib/files.js +249 -0
- package/skills/easycaptionkit/scripts/lib/fontfile.js +355 -0
- package/skills/easycaptionkit/scripts/lib/look.js +571 -0
- package/skills/easycaptionkit/scripts/lib/places.js +337 -0
- package/skills/easycaptionkit/scripts/lib/render.js +374 -0
- package/skills/easycaptionkit/scripts/lib/report.js +604 -0
- package/skills/easycaptionkit/scripts/lib/running.js +62 -0
- package/skills/easycaptionkit/scripts/lib/subtitles.js +489 -0
- package/skills/easycaptionkit/scripts/lib/text.js +115 -0
- package/skills/easycaptionkit/scripts/lib/version.js +5 -0
- package/skills/easycaptionkit/scripts/lib/words.js +1090 -0
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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 };
|