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,374 @@
1
+ "use strict";
2
+
3
+ // Chrome draws every caption state once, on a see-through page the size of
4
+ // the video, in the brand's fonts: a cue for the line style, a cue with one
5
+ // word lit for karaoke, a word alone for the word style. Each state is a
6
+ // PNG of the band the captions sit in. The PNGs then go into one animated
7
+ // PNG, each for as long as its state lasts, which ffmpeg reads as one
8
+ // stream and lays over the video once.
9
+
10
+ const fs = require("node:fs");
11
+ const path = require("node:path");
12
+ const { launch, openPage, evaluate } = require("./chrome");
13
+ const { cueLines } = require("./cues");
14
+ const { inkOf } = require("./places");
15
+ const { writeApng } = require("./apng");
16
+
17
+ // The cue's lines as words, each with whether a space goes before it.
18
+ const lineWords = cueLines;
19
+
20
+ // The page's style. The outline is drawn with SVG text stroked with round
21
+ // joins under the letters, since CSS text stroke joins its corners in
22
+ // points that stick out of a v or a W. Font names and ranges come from
23
+ // fonts.css and brand.json, which other tools read from websites: every
24
+ // "<" is escaped, so nothing in them can end the style element.
25
+ const css = (look, place, style) => {
26
+ const font = place.fontPx;
27
+ const { stroke, shadow } = place.ink || inkOf(font, { style, box: look.box });
28
+ const faces = look.faces
29
+ .map((f) => `@font-face { font-family: ${JSON.stringify(f.family)}; src: url(${JSON.stringify(f.url)}); font-weight: ${f.weight[0]} ${f.weight[1]}; font-style: ${f.italic ? "italic" : "normal"};${f.unicodeRange ? ` unicode-range: ${f.unicodeRange};` : ""} font-display: block; }`)
30
+ .join("\n");
31
+ const anchor =
32
+ place.position === "top"
33
+ ? `top: ${place.blockTop}px;`
34
+ : place.position === "middle"
35
+ ? `top: ${(place.blockTop + place.blockBottom) / 2}px; transform: translateY(-50%);`
36
+ : `bottom: ${place.frame.height - place.blockBottom}px;`;
37
+ const patch = look.highlightMode === "patch";
38
+ return `${faces}
39
+ html, body { margin: 0; padding: 0; background: transparent; }
40
+ body { width: ${place.frame.width}px; height: ${place.frame.height}px; overflow: hidden; position: relative; }
41
+ #cap { position: absolute; left: ${place.side}px; right: ${place.side}px; ${anchor} display: grid; justify-items: center;
42
+ font-family: ${look.stack}; font-weight: ${look.weight}; font-size: ${font}px; line-height: ${place.lineHeight};
43
+ font-synthesis: none; text-align: center; font-kerning: normal; text-rendering: geometricPrecision; }
44
+ #cap .layer { grid-area: 1 / 1; display: flex; flex-direction: column; align-items: center; position: relative; }
45
+ #cap .back { z-index: 0; color: transparent; }
46
+ #cap .front { z-index: 2; color: ${look.textColor}; }
47
+ #cap .line { white-space: pre; unicode-bidi: plaintext; }
48
+ #cap .base { display: inline-block; width: 0; height: 0; vertical-align: baseline; }
49
+ #cap .w { padding: 0.02em 0.12em 0.06em; margin: 0 -0.12em; border-radius: 0.2em; box-decoration-break: clone; -webkit-box-decoration-break: clone; }
50
+ #cap svg.ink { position: absolute; left: 0; top: 0; overflow: visible; z-index: 1; filter: drop-shadow(0 ${shadow.y}px ${shadow.blur}px rgba(0, 0, 0, 0.55)); }
51
+ #cap svg.ink text { fill: #000; stroke: #000; stroke-width: ${stroke * 2}px; stroke-linejoin: round; stroke-linecap: round; paint-order: stroke fill; white-space: pre; unicode-bidi: plaintext; }
52
+ ${look.box ? `#cap .back .line { background: rgba(0, 0, 0, ${look.boxOpacity}); border-radius: 0.16em; padding: 0 0.3em; margin: 0 -0.3em; }` : ""}
53
+ ${patch ? `#cap .back .lit { background: ${look.highlight}; }
54
+ #cap svg.ink .lit { fill: none; stroke: none; }
55
+ #cap .front .lit { color: ${look.highlightInk}; }` : `#cap .front .lit { color: ${look.highlight}; }`}
56
+ #probe { position: absolute; left: 0; top: ${place.frame.height + 50}px; width: ${place.frame.width}px; white-space: pre-line;
57
+ font-family: ${look.stack}; font-weight: ${look.weight}; font-size: 40px; font-synthesis: none; }`.replace(/</g, "\\3c ");
58
+ };
59
+
60
+ // The page script: builds a state, fits each cue to the width, and finds
61
+ // the band all states fit in.
62
+ const SCRIPT = String.raw`
63
+ const D = window.__data;
64
+ const cap = document.getElementById("cap");
65
+ const NS = "http://www.w3.org/2000/svg";
66
+ function words(line, lit) {
67
+ const frag = document.createDocumentFragment();
68
+ const base = document.createElement("span");
69
+ base.className = "base";
70
+ frag.appendChild(base);
71
+ for (const w of line) {
72
+ if (w.s) frag.appendChild(document.createTextNode(" "));
73
+ const span = document.createElement("span");
74
+ span.className = w.i === lit ? "w lit" : "w";
75
+ span.textContent = w.t;
76
+ frag.appendChild(span);
77
+ }
78
+ return frag;
79
+ }
80
+ // The outline: each line again as SVG text, centered on the line and on
81
+ // its baseline, stroked with round joins under the letters.
82
+ function ink() {
83
+ if (D.box) return;
84
+ const svg = document.createElementNS(NS, "svg");
85
+ svg.setAttribute("class", "ink");
86
+ const box = cap.getBoundingClientRect();
87
+ svg.setAttribute("width", String(box.width));
88
+ svg.setAttribute("height", String(box.height));
89
+ for (const line of cap.querySelectorAll(".front .line")) {
90
+ const r = line.getBoundingClientRect();
91
+ const text = document.createElementNS(NS, "text");
92
+ text.setAttribute("x", String(r.left + r.width / 2 - box.left));
93
+ text.setAttribute("y", String(line.querySelector(".base").getBoundingClientRect().bottom - box.top));
94
+ text.setAttribute("text-anchor", "middle");
95
+ for (const node of line.childNodes) {
96
+ if (node.nodeType === 3) text.appendChild(document.createTextNode(node.data));
97
+ else if (node.classList.contains("w")) {
98
+ const span = document.createElementNS(NS, "tspan");
99
+ if (node.classList.contains("lit")) span.setAttribute("class", "lit");
100
+ span.textContent = node.textContent;
101
+ text.appendChild(span);
102
+ }
103
+ }
104
+ svg.appendChild(text);
105
+ }
106
+ cap.appendChild(svg);
107
+ }
108
+ // unit: the cue, or the cue's word k shown alone (word style).
109
+ function build(c, lit, solo, scale) {
110
+ cap.textContent = "";
111
+ if (c < 0) return;
112
+ const lines = solo >= 0 ? [[{ t: D.cues[c].words[solo], s: false, i: solo }]] : D.cues[c].lines;
113
+ for (const kind of ["back", "front"]) {
114
+ const layer = document.createElement("div");
115
+ layer.className = "layer " + kind;
116
+ if (kind === "back") layer.setAttribute("aria-hidden", "true");
117
+ for (const line of lines) {
118
+ const div = document.createElement("div");
119
+ div.className = "line";
120
+ div.dir = "auto";
121
+ div.appendChild(words(line, lit));
122
+ layer.appendChild(div);
123
+ }
124
+ cap.appendChild(layer);
125
+ }
126
+ cap.style.fontSize = (D.font * scale) + "px";
127
+ ink();
128
+ }
129
+ function widest() {
130
+ let w = 0;
131
+ for (const line of cap.querySelectorAll(".front .line")) w = Math.max(w, line.getBoundingClientRect().width);
132
+ return w;
133
+ }
134
+ // How far up and down what is built reaches: the letters as the font
135
+ // draws them (accents above the line, descenders below it), with the
136
+ // outline and its shadow around them, or the box.
137
+ const pen = document.createElement("canvas").getContext("2d");
138
+ function reach() {
139
+ pen.font = D.weight + " " + cap.style.fontSize + " " + D.stack;
140
+ let top = Infinity, bottom = -Infinity;
141
+ const take = (a, b) => { top = Math.min(top, a); bottom = Math.max(bottom, b); };
142
+ for (const line of cap.querySelectorAll(".front .line")) {
143
+ const base = line.querySelector(".base").getBoundingClientRect().bottom;
144
+ const m = pen.measureText(line.textContent);
145
+ take(base - m.actualBoundingBoxAscent - D.above, base + m.actualBoundingBoxDescent + D.below);
146
+ if (D.box) { const r = line.getBoundingClientRect(); take(r.top, r.bottom); }
147
+ }
148
+ if (D.patch) for (const w of cap.querySelectorAll(".back .w")) { const r = w.getBoundingClientRect(); take(r.top, r.bottom); }
149
+ return { top, bottom };
150
+ }
151
+ // A caption whose widest line, with the outline or box around it, is
152
+ // wider than the safe space is drawn smaller, as much as it takes: the
153
+ // box grows and shrinks with the letters, the outline and its shadow keep
154
+ // their width in pixels. Then all of them move up or down together, as
155
+ // far as it takes for every state to stay inside the block the layout
156
+ // gives them.
157
+ window.__fit = () => {
158
+ const scales = [];
159
+ let top = Infinity, bottom = -Infinity;
160
+ const measure = (c, solo) => {
161
+ build(c, -1, solo, 1);
162
+ const letters = widest();
163
+ const w = letters + 2 * D.reach;
164
+ const scale = w <= D.width ? 1 : Math.max(0.01, D.box ? D.width / w : (D.width - 2 * D.reach) / letters);
165
+ if (scale < 1) build(c, -1, solo, scale);
166
+ const r = cap.getBoundingClientRect();
167
+ const k = reach();
168
+ top = Math.min(top, r.top, k.top);
169
+ bottom = Math.max(bottom, r.bottom, k.bottom);
170
+ return scale;
171
+ };
172
+ D.cues.forEach((cue, c) => {
173
+ if (D.style === "word") scales.push(cue.words.map((_, k) => measure(c, k)));
174
+ else scales.push(measure(c, -1));
175
+ });
176
+ cap.textContent = "";
177
+ const below = Math.max(0, bottom - D.blockBottom);
178
+ const above = Math.max(0, D.blockTop - top);
179
+ const shift = Number.isFinite(top) ? (D.position === "top" ? above || -below : -below || above) : 0;
180
+ cap.style.translate = "0 " + shift + "px";
181
+ return { scales, top: top + shift, bottom: bottom + shift, shift };
182
+ };
183
+ window.__show = (c, lit, solo) => {
184
+ const scale = c < 0 ? 1 : D.style === "word" ? D.scales[c][solo] : D.scales[c];
185
+ build(c, lit, solo, scale);
186
+ return true;
187
+ };
188
+ `;
189
+
190
+ function pageHtml(look, place, style, data) {
191
+ return `<!doctype html>
192
+ <html${look.lang ? ` lang="${look.lang}"` : ""}><head><meta charset="utf-8"><title>captions</title>
193
+ <style>${css(look, place, style)}</style></head>
194
+ <body><div id="cap"></div><div id="probe"></div>
195
+ <script>window.__data = ${JSON.stringify(data).replace(/</g, "\\u003c")};</script>
196
+ <script>${SCRIPT}</script></body></html>`;
197
+ }
198
+
199
+ // Draws every state of the timeline to PNGs in `dir` and puts them in one
200
+ // animated PNG. Returns { file, y, height, reach, states, fonts, first,
201
+ // failedFonts, shrunk, scales }: the band the PNGs cover, the rows the
202
+ // captions reach in it, how many states there are, which fonts drew the
203
+ // letters and which of them is the first of the list this machine has,
204
+ // and how many captions had to be drawn smaller to fit.
205
+ async function renderStates({ cues, words, states, style, look, place, duration, dir, log = () => {} }) {
206
+ const W = place.frame.width;
207
+ const H = place.frame.height;
208
+ const ink = place.ink || inkOf(place.fontPx, { style, box: look.box });
209
+ const data = {
210
+ style,
211
+ font: place.fontPx,
212
+ weight: look.weight,
213
+ stack: look.stack,
214
+ width: place.textWidth,
215
+ reach: ink.reach,
216
+ box: Boolean(look.box),
217
+ patch: look.highlightMode === "patch" && style !== "line",
218
+ // The outline reaches past the letters by its stroke, and its shadow
219
+ // goes down by its offset and blurs out by its blur.
220
+ above: look.box ? 0 : ink.stroke + Math.max(0, ink.shadow.blur - ink.shadow.y),
221
+ below: look.box ? 0 : ink.stroke + ink.shadow.y + ink.shadow.blur,
222
+ position: place.position,
223
+ blockTop: place.blockTop,
224
+ blockBottom: place.blockBottom,
225
+ cues: cues.map((cue) => ({ lines: lineWords(cue, words), words: words.slice(cue.first, cue.last + 1).map((w) => w.text) })),
226
+ };
227
+ const file = path.join(dir, "captions-page.html");
228
+ fs.writeFileSync(file, pageHtml(look, place, style, data));
229
+ // The page is the size of the video; Chrome's window is never smaller
230
+ // than 320 px a side, since below that it gives no picture of the page.
231
+ const browser = await launch({ width: Math.max(W, 320), height: Math.max(H, 320) });
232
+ try {
233
+ const page = await openPage(browser, file, { width: W, height: H, transparent: true });
234
+ // The letters the captions show, each word on a line of its own:
235
+ // Chrome counts a space as a letter of the font that drew it, the
236
+ // first of the list, and a script written without spaces shows none.
237
+ // The fonts load for every letter of them before the first shot, and
238
+ // Chrome says which font drew which letters.
239
+ const probeText = cues.flatMap((cue) => cue.lines.flatMap((line) => line.split(/\s+/))).filter(Boolean).join("\n");
240
+ const letters = [...new Set(probeText.replace(/\n/g, ""))].join("");
241
+ const loaded = await evaluate(
242
+ page,
243
+ `(async () => {
244
+ const spec = ${JSON.stringify(`${look.weight} ${place.fontPx}px ${look.stack}`)};
245
+ try { await document.fonts.load(spec, ${JSON.stringify(letters)}); } catch (e) {}
246
+ await document.fonts.ready;
247
+ return [...document.fonts].filter((f) => f.status === "error").map((f) => f.family);
248
+ })()`,
249
+ 60_000,
250
+ );
251
+ const fonts = await fontsOf(page, probeText);
252
+ // The first font of the list this machine has: the one that draws
253
+ // plain Latin letters. The others draw only what it lacks.
254
+ const latin = await fontsOf(page, "Hamburgefonstiv");
255
+ await evaluate(page, `document.getElementById("probe").textContent = ""; true`);
256
+ const fit = await evaluate(page, "__fit()", 120_000);
257
+ await evaluate(page, `window.__data.scales = ${JSON.stringify(fit.scales)}; true`);
258
+ // The band: every state fits in it, with room for the outline and
259
+ // shadow; even, so it lands on whole chroma samples.
260
+ const spill = Math.ceil(ink.reach + ink.shadow.y + place.fontPx * 0.05);
261
+ let y = Math.max(0, Math.floor(fit.top - spill));
262
+ let bottom = Math.min(H, Math.ceil(fit.bottom + spill));
263
+ if (!Number.isFinite(fit.top)) {
264
+ y = Math.round(place.blockTop);
265
+ bottom = Math.round(place.blockBottom);
266
+ }
267
+ y -= y % 2;
268
+ let height = bottom - y;
269
+ height += height % 2;
270
+ if (y + height > H) height = H - y - ((H - y) % 2);
271
+ const shot = async (name) => {
272
+ const { data: png } = await page.call("Page.captureScreenshot", { format: "png", clip: { x: 0, y, width: W, height, scale: 1 }, fromSurface: true, captureBeyondViewport: false });
273
+ const out = path.join(dir, name);
274
+ fs.writeFileSync(out, Buffer.from(png, "base64"));
275
+ return out;
276
+ };
277
+ await evaluate(page, "__show(-1, -1, -1)");
278
+ const blank = await shot("state-blank.png");
279
+ // One PNG per distinct state, in time order.
280
+ const made = new Map();
281
+ const timeline = [];
282
+ let count = 0;
283
+ const total = states.length;
284
+ let shown = -1;
285
+ for (const state of states) {
286
+ // The spoken word is lit in both styles that follow the words.
287
+ const lit = style === "line" ? -1 : state.word;
288
+ const solo = style === "word" ? state.word : -1;
289
+ const key = `${state.cue}:${lit}:${solo}`;
290
+ if (!made.has(key)) {
291
+ await evaluate(page, `__show(${state.cue}, ${lit}, ${solo})`);
292
+ made.set(key, await shot(`state-${String(made.size + 1).padStart(5, "0")}.png`));
293
+ }
294
+ timeline.push({ from: state.from, to: state.to, file: made.get(key) });
295
+ count++;
296
+ const step = Math.floor((count / total) * 4);
297
+ if (step > shown && step > 0 && total > 200) {
298
+ shown = step;
299
+ log(`Drew ${count} of ${total} caption states`);
300
+ }
301
+ }
302
+ const animation = path.join(dir, "states.apng");
303
+ writeApng(animation, statesFrames(timeline, blank, duration));
304
+ const all = style === "word" ? fit.scales.flat() : fit.scales;
305
+ const shrunk = all.filter((s) => s < 1).length;
306
+ // The rows every state stays in, its outline and shadow included.
307
+ const reach = Number.isFinite(fit.top) ? { top: Math.max(0, Math.floor(fit.top)), bottom: Math.min(H, Math.ceil(fit.bottom)) } : null;
308
+ return { file: animation, y, height, reach, states: made.size + 1, fonts, first: latin.length ? latin[0].family : "", failedFonts: loaded || [], shrunk, smallest: all.reduce((a, b) => Math.min(a, b), 1), scales: fit.scales };
309
+ } finally {
310
+ await browser.close();
311
+ }
312
+ }
313
+
314
+ // Which fonts Chrome draws a text with, and how many letters each. Chrome
315
+ // says so only of text it has laid out, and a text just set is laid out at
316
+ // the next frame: the probe is laid out in the same step its text is set
317
+ // in, and asked again after a frame when Chrome still says nothing.
318
+ async function fontsOf(page, text) {
319
+ for (let tries = 0; tries < 2; tries++) {
320
+ await evaluate(page, `(() => { const probe = document.getElementById("probe"); if (probe.textContent !== ${JSON.stringify(text)}) probe.textContent = ${JSON.stringify(text)}; probe.getBoundingClientRect(); return true; })()`);
321
+ const fonts = await fontsUsed(page);
322
+ if (fonts.length) return fonts;
323
+ await evaluate(page, "new Promise((resolve) => requestAnimationFrame(() => resolve(true)))");
324
+ }
325
+ return [];
326
+ }
327
+
328
+ // Which fonts Chrome drew the probe text with, and how many letters each.
329
+ async function fontsUsed(page) {
330
+ try {
331
+ await page.call("DOM.enable");
332
+ await page.call("CSS.enable");
333
+ const { root } = await page.call("DOM.getDocument", { depth: 1 });
334
+ const { nodeId } = await page.call("DOM.querySelector", { nodeId: root.nodeId, selector: "#probe" });
335
+ const { fonts } = await page.call("CSS.getPlatformFontsForNode", { nodeId });
336
+ return (fonts || []).map((f) => ({ family: f.familyName, postScriptName: f.postScriptName || "", custom: Boolean(f.isCustomFont), glyphs: f.glyphCount })).sort((a, b) => b.glyphs - a.glyphs);
337
+ } catch {
338
+ return [];
339
+ }
340
+ }
341
+
342
+ // The frames of the animated PNG: each state's PNG for as long as it
343
+ // shows, and the blank PNG in the gaps, from 0 to the end of the last
344
+ // state. Each frame starts and ends on a whole millisecond of the video's
345
+ // time, so no change drifts from its time however many come before it.
346
+ // The stream ends with the blank PNG a second after the last state: once
347
+ // the stream ends, the overlay passes the video on as it is, and a caption
348
+ // still showing then would go with it. The video's length bounds the
349
+ // states and nothing else, so a header that claims more than the file
350
+ // holds makes no longer a stream.
351
+ function statesFrames(timeline, blank, duration) {
352
+ const at = (t) => Math.round(Math.min(Math.max(t, 0), duration) * 1000);
353
+ const frames = [];
354
+ let now = 0;
355
+ const add = (png, ms) => {
356
+ const last = frames[frames.length - 1];
357
+ if (last && last.png === png) last.ms += ms;
358
+ else frames.push({ png, ms });
359
+ };
360
+ const until = (png, t) => {
361
+ const end = at(t);
362
+ if (end <= now) return;
363
+ add(png, end - now);
364
+ now = end;
365
+ };
366
+ for (const item of timeline) {
367
+ if (at(item.from) > now) until(blank, item.from);
368
+ until(item.file, item.to);
369
+ }
370
+ add(blank, 1000);
371
+ return frames;
372
+ }
373
+
374
+ module.exports = { renderStates, statesFrames, lineWords, pageHtml, fontsOf };