easycaptionkit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,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 };
|