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,317 @@
1
+ "use strict";
2
+
3
+ // Words into captions. A caption (a cue) is a run of words shown together,
4
+ // at most `lines` lines of at most `maxChars` columns each. The cuts are
5
+ // chosen over the whole text at once, by dynamic programming: a cue ends
6
+ // best where a sentence ends, next best at a comma or a pause, and worst in
7
+ // the middle of a phrase; a pause inside a cue costs, and so do cues that
8
+ // are too short to read or too long to hold.
9
+
10
+ const { width, characters, join, spaced, endsSentence, endsClause } = require("./text");
11
+
12
+ const MAX_WORDS = 40;
13
+ const PARTICLE = /^(?:は|が|を|に|で|と|も|へ|や|の|から|まで|より|けど|ので)$/u;
14
+ const NO_LINE_START = /^(?:[ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮーゝゞヽヾ]|(?:は|が|を|に|で|と|も|へ|や|の|ね|よ)$)/u;
15
+ // Reading speed people can follow, in characters a second, and the limit
16
+ // past which a cue counts as fast (Netflix's for adult English programmes).
17
+ const COMFORT_CPS = 17;
18
+ const FAST_CPS = 20;
19
+
20
+ // Japanese: a line or a caption does not start with a particle or a small
21
+ // kana, and does not split a word the segmenter cut in two: kanji from the
22
+ // kana that ends it (高|く) or the polite お and ご from what follows them
23
+ // (お|兄さん).
24
+ function badBreak(before, after) {
25
+ if (NO_LINE_START.test(after)) return true;
26
+ if (/\p{Script=Han}$/u.test(before) && /^\p{Script=Hiragana}/u.test(after)) return true;
27
+ return /^[おご]$/u.test(before) && /^\p{Script=Han}/u.test(after);
28
+ }
29
+
30
+ // Each word measured once: its columns, whether a space comes before it,
31
+ // and what kind of break comes after it. A run of words then measures as
32
+ // a difference of sums, without joining any text.
33
+ function measure(texts) {
34
+ const n = texts.length;
35
+ const at = new Float64Array(n + 1);
36
+ const gap = new Uint8Array(n);
37
+ for (let k = 0; k < n; k++) {
38
+ gap[k] = k > 0 && spaced(texts[k - 1], texts[k]) ? 1 : 0;
39
+ at[k + 1] = at[k] + gap[k] + width(texts[k]);
40
+ }
41
+ const sentence = texts.map(endsSentence);
42
+ const clause = texts.map(endsClause);
43
+ const particle = texts.map((t) => PARTICLE.test(t));
44
+ const bad = texts.map((t, k) => k > 0 && badBreak(texts[k - 1], t));
45
+ const bads = new Uint32Array(n + 1);
46
+ for (let k = 0; k < n; k++) bads[k + 1] = bads[k] + (bad[k] ? 1 : 0);
47
+ return {
48
+ texts,
49
+ sentence,
50
+ clause,
51
+ particle,
52
+ bad,
53
+ // Columns of words i..j-1 on one line.
54
+ span: (i, j) => at[j] - at[i] - gap[i],
55
+ // Bad places to break between words i..j-1.
56
+ badInside: (i, j) => (j - i > 1 ? bads[j] - bads[i + 1] : 0),
57
+ };
58
+ }
59
+
60
+ // Whether words i..j-1 fit on `lines` lines of `maxChars` columns: filling
61
+ // each line as far as it goes uses the fewest lines.
62
+ function fits(m, i, j, lines, maxChars) {
63
+ let used = 1;
64
+ let start = i;
65
+ for (let k = i + 1; k <= j; k++) {
66
+ if (m.span(start, k) <= maxChars) continue;
67
+ if (k - 1 === start) return false;
68
+ used++;
69
+ start = k - 1;
70
+ if (used > lines || m.span(start, k) > maxChars) return false;
71
+ }
72
+ return true;
73
+ }
74
+
75
+ // The best cuts for words i..j-1 on at most `lines` lines: [] when they fit
76
+ // on one, null when they do not fit at all; otherwise as few lines as fit,
77
+ // balanced, and broken after punctuation when that costs little.
78
+ function bestCuts(m, i, j, lines, maxChars) {
79
+ if (m.span(i, j) <= maxChars) return [];
80
+ if (lines < 2 || j - i < 2) return null;
81
+ let best = null;
82
+ const breakCost = (cut) => {
83
+ let cost = 0;
84
+ if (m.sentence[cut - 1]) cost -= 12;
85
+ else if (m.clause[cut - 1]) cost -= 8;
86
+ // A particle ends a phrase, which is a good place to break.
87
+ if (m.bad[cut]) cost += 30;
88
+ else if (m.particle[cut - 1]) cost -= 6;
89
+ return cost;
90
+ };
91
+ const score = (widths, cuts) => {
92
+ let cost = Math.max(...widths) - Math.min(...widths);
93
+ // A top line longer than the one under it reads worse than the other way.
94
+ for (let k = 0; k + 1 < widths.length; k++) if (widths[k] > widths[k + 1]) cost += 0.3 * (widths[k] - widths[k + 1]);
95
+ for (const cut of cuts) cost += breakCost(cut);
96
+ cost += widths.length * 50;
97
+ if (!best || cost < best.cost) best = { cost, cuts };
98
+ };
99
+ for (let a = i + 1; a < j; a++) {
100
+ const first = m.span(i, a);
101
+ if (first > maxChars) break;
102
+ const rest = m.span(a, j);
103
+ if (rest <= maxChars) score([first, rest], [a]);
104
+ if (lines < 3) continue;
105
+ for (let b = a + 1; b < j; b++) {
106
+ const second = m.span(a, b);
107
+ if (second > maxChars) break;
108
+ const third = m.span(b, j);
109
+ if (third <= maxChars) score([first, second, third], [a, b]);
110
+ }
111
+ }
112
+ return best ? best.cuts : null;
113
+ }
114
+
115
+ function linesAt(m, i, j, cuts) {
116
+ const bounds = [i, ...cuts, j];
117
+ return bounds.slice(1).map((end, k) => join(m.texts.slice(bounds[k], end)));
118
+ }
119
+
120
+ // The best way to set words on at most `lines` lines of `maxChars`
121
+ // columns, or null when they do not fit. One line when they fit on one;
122
+ // otherwise as few lines as fit, balanced, and broken after punctuation
123
+ // when that costs little.
124
+ function layout(texts, lines, maxChars) {
125
+ const m = measure(texts);
126
+ const cuts = bestCuts(m, 0, texts.length, lines, maxChars);
127
+ return cuts ? linesAt(m, 0, texts.length, cuts) : null;
128
+ }
129
+
130
+ // What a cue made of words[i..j) costs, the cut after it included.
131
+ function cueCost(words, m, i, j, { maxChars, lines, blockEnd }) {
132
+ const last = words[j - 1];
133
+ const next = j < words.length ? words[j] : null;
134
+ let cost = 1;
135
+ if (next && !blockEnd) {
136
+ const pause = next.start - last.end;
137
+ if (m.sentence[j - 1]) cost += 0;
138
+ else if (m.clause[j - 1]) cost += pause > 0.25 ? 0.5 : 1.5;
139
+ else if (m.particle[j - 1]) cost += pause > 0.25 ? 1 : 2.5;
140
+ else if (pause >= 0.5) cost += 1.5;
141
+ else if (pause >= 0.25) cost += 3;
142
+ else cost += 6;
143
+ if (m.bad[j]) cost += 8;
144
+ if (next.seg !== last.seg && next.seg >= 0) cost -= 0.8;
145
+ }
146
+ for (let k = i; k < j - 1; k++) {
147
+ if (m.sentence[k]) cost += 2.5;
148
+ const pause = words[k + 1].start - words[k].end;
149
+ if (pause >= 0.7) cost += 4;
150
+ else if (pause >= 0.4) cost += 1.5;
151
+ }
152
+ const fill = m.span(i, j) / (lines * maxChars);
153
+ cost += 2 * (1 - Math.min(1, fill)) ** 2;
154
+ const duration = last.end - words[i].start;
155
+ if (duration < 0.7 && next && !blockEnd && !m.sentence[j - 1]) cost += 2;
156
+ return cost;
157
+ }
158
+
159
+ // Cuts one block of words (never joined with the next) into cues.
160
+ function cutBlock(words, m, from, to, options) {
161
+ const { maxChars, lines, maxDuration } = options;
162
+ const n = to - from;
163
+ const best = new Array(n + 1).fill(Infinity);
164
+ const back = new Array(n + 1).fill(-1);
165
+ best[0] = 0;
166
+ for (let j = 1; j <= n; j++) {
167
+ for (let i = j - 1; i >= Math.max(0, j - MAX_WORDS); i--) {
168
+ const single = j - i === 1;
169
+ if (!single && !fits(m, from + i, from + j, lines, maxChars)) break;
170
+ const duration = words[from + j - 1].end - words[from + i].start;
171
+ if (duration > maxDuration && !single) break;
172
+ let cost = best[i] + cueCost(words, m, from + i, from + j, { maxChars, lines, blockEnd: from + j === to });
173
+ // A cue whose next one comes on as it does would never show.
174
+ if (from + j < to && words[from + j].start <= words[from + i].start) cost += 100;
175
+ // A caption whose lines can only break in a bad place costs as much
176
+ // as a bad cut between captions.
177
+ if (!single && m.badInside(from + i, from + j)) {
178
+ const cuts = bestCuts(m, from + i, from + j, lines, maxChars);
179
+ if (cuts && cuts.some((cut) => m.bad[cut])) cost += 8;
180
+ }
181
+ if (cost < best[j]) {
182
+ best[j] = cost;
183
+ back[j] = i;
184
+ }
185
+ }
186
+ }
187
+ const cuts = [];
188
+ for (let j = n; j > 0; j = back[j]) cuts.unshift([from + back[j], from + j]);
189
+ return cuts;
190
+ }
191
+
192
+ // Cues from words. options: { maxChars, lines, maxDuration, hard, duration }
193
+ // where hard says segments are never joined, and duration (the video's
194
+ // length) is where the last cue has to end.
195
+ function buildCues(words, options) {
196
+ const opts = { maxDuration: 7, ...options };
197
+ const blocks = [];
198
+ let start = 0;
199
+ for (let k = 1; k <= words.length; k++) {
200
+ const gap = k < words.length ? words[k].start - words[k - 1].end : 0;
201
+ // Segments that come on at the same moment, like two steps of a demo
202
+ // at one time, are shown together: apart, the first would never show.
203
+ const segment = opts.hard && words[k] && words[k].seg !== words[k - 1].seg && words[k].start > words[start].start;
204
+ const boundary = k === words.length || segment || gap > 2.5;
205
+ if (boundary) {
206
+ blocks.push([start, k]);
207
+ start = k;
208
+ }
209
+ }
210
+ const m = measure(words.map((w) => w.text));
211
+ const cues = [];
212
+ for (const [from, to] of blocks) {
213
+ for (const [i, j] of cutBlock(words, m, from, to, opts)) {
214
+ const cuts = bestCuts(m, i, j, opts.lines, opts.maxChars) || [];
215
+ const lines = linesAt(m, i, j, cuts);
216
+ // cuts: where each line after the first starts, so the lines can be
217
+ // set again word by word without joining any text.
218
+ cues.push({ first: i, last: j - 1, start: words[i].start, end: words[j - 1].end, spoken: words[j - 1].end, lines, cuts, text: lines.join(" ") });
219
+ }
220
+ }
221
+ time(cues, opts);
222
+ cues.forEach((cue, i) => {
223
+ cue.n = i + 1;
224
+ cue.chars = characters(cue.lines.join("\n"));
225
+ cue.cps = cue.end > cue.start ? cue.chars / (cue.end - cue.start) : Infinity;
226
+ cue.wide = cue.lines.some((line) => width(line) > opts.maxChars);
227
+ });
228
+ return cues;
229
+ }
230
+
231
+ // When each cue shows. It comes on with its first word. A short gap to the
232
+ // next cue is closed so the captions do not blink; a cue too short to read
233
+ // is held longer where there is room; after the last word of a cue that
234
+ // nothing follows soon, it stays a little. A cue the next one would
235
+ // replace as it comes on still shows for a moment, and the next one comes
236
+ // on after it: no cue ends where it starts.
237
+ const BRIEF = 0.2;
238
+ function time(cues, { duration = Infinity }) {
239
+ for (let i = 0; i < cues.length; i++) {
240
+ const cue = cues[i];
241
+ const next = cues[i + 1];
242
+ const until = Math.min(next ? next.start : Infinity, duration);
243
+ const want = Math.max(0.9, characters(cue.text) / COMFORT_CPS);
244
+ let end = Math.min(Math.max(cue.spoken + 0.4, cue.start + want), cue.spoken + 1.2);
245
+ if (next && next.start - end < 0.3) end = next.start;
246
+ cue.end = Math.max(cue.start, Math.min(end, until));
247
+ if (cue.end <= cue.start) {
248
+ cue.end = Math.min(cue.start + BRIEF, Math.max(duration, cue.start + 0.001));
249
+ if (next && next.start < cue.end) next.start = cue.end;
250
+ }
251
+ }
252
+ }
253
+
254
+ // A cue's lines as words: { t: the text, s: whether a space comes before
255
+ // it, i: its place in the cue }. A cue made without its cuts has them
256
+ // found again from its lines.
257
+ function cueLines(cue, words) {
258
+ let cuts = cue.cuts;
259
+ if (!cuts) {
260
+ cuts = [];
261
+ let k = cue.first;
262
+ for (const line of cue.lines.slice(0, -1)) {
263
+ let built = "";
264
+ while (k <= cue.last && built.length < line.length) built = join([built, words[k++].text].filter(Boolean));
265
+ cuts.push(k);
266
+ }
267
+ }
268
+ const bounds = [cue.first, ...cuts, cue.last + 1];
269
+ return bounds.slice(1).map((end, n) => {
270
+ const line = [];
271
+ for (let k = bounds[n]; k < end; k++) line.push({ t: words[k].text, s: k > bounds[n] && spaced(words[k - 1].text, words[k].text), i: k - cue.first });
272
+ return line;
273
+ });
274
+ }
275
+
276
+ // The states a karaoke or word style shows, in time order: { from, to,
277
+ // cue, word } where word is the index in the cue of the word that is lit,
278
+ // or shown alone; -1 for the whole cue unlit. Between cues nothing shows.
279
+ function states(cues, words, style) {
280
+ const out = [];
281
+ for (const [c, cue] of cues.entries()) {
282
+ if (style === "line") {
283
+ out.push({ from: cue.start, to: cue.end, cue: c, word: -1 });
284
+ continue;
285
+ }
286
+ const count = cue.last - cue.first + 1;
287
+ for (let k = 0; k < count; k++) {
288
+ const w = words[cue.first + k];
289
+ const from = k === 0 ? cue.start : Math.max(cue.start, w.start);
290
+ let to = k + 1 < count ? Math.max(from, words[cue.first + k + 1].start) : cue.end;
291
+ // A word shown alone does not stay up through a long pause.
292
+ if (style === "word" && to - w.end > 1.0) to = Math.max(from + 0.1, w.end + 1.0);
293
+ to = Math.min(to, cue.end);
294
+ if (to - from > 0.0005) out.push({ from, to, cue: c, word: k });
295
+ }
296
+ }
297
+ return out;
298
+ }
299
+
300
+ // Reading speed over all cues: the fastest, the median and the cues past
301
+ // FAST_CPS, slowest last.
302
+ function readingSpeed(cues) {
303
+ const speeds = cues.map((c) => c.cps).filter(Number.isFinite).sort((a, b) => a - b);
304
+ const median = speeds.length ? speeds[Math.floor(speeds.length / 2)] : 0;
305
+ const fast = cues.filter((c) => c.cps > FAST_CPS).sort((a, b) => b.cps - a.cps);
306
+ return {
307
+ limit: FAST_CPS,
308
+ comfort: COMFORT_CPS,
309
+ fastest: speeds.length ? round(speeds[speeds.length - 1], 1) : 0,
310
+ median: round(median, 1),
311
+ fast: fast.map((c) => ({ n: c.n, start: round(c.start, 3), end: round(c.end, 3), cps: round(c.cps, 1), text: c.text })),
312
+ };
313
+ }
314
+
315
+ const round = (value, places) => Math.round(value * 10 ** places) / 10 ** places;
316
+
317
+ module.exports = { buildCues, layout, states, cueLines, readingSpeed, badBreak, FAST_CPS, COMFORT_CPS };
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+
3
+ // The pictures to look at: sheet.png, a frame of the captioned video for
4
+ // each caption, and safe-zones.png, a frame under each place's buttons and
5
+ // text, drawn where each app puts them, with the margins the captions keep
6
+ // out of. Chrome lays them out; without Chrome, ffmpeg draws plainer ones.
7
+
8
+ const fs = require("node:fs");
9
+ const path = require("node:path");
10
+ const { pathToFileURL } = require("node:url");
11
+ const { launch, openPage, evaluate } = require("./chrome");
12
+ const { frameAt, run, whyFailed } = require("./ffmpeg");
13
+ const { writeApng } = require("./apng");
14
+ const { clock } = require("./report");
15
+ const esc = (text) => String(text).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
16
+
17
+ // The cues the sheet shows: all of them up to `most`, else spread evenly.
18
+ function sheetCues(cues, most = 24) {
19
+ if (cues.length <= most) return cues;
20
+ const picked = [];
21
+ for (let i = 0; i < most; i++) picked.push(cues[Math.round((i * (cues.length - 1)) / (most - 1))]);
22
+ return [...new Set(picked)];
23
+ }
24
+
25
+ // When a cue is best seen: the middle of its spoken words.
26
+ const momentOf = (cue) => Math.max(cue.start, Math.min(cue.end - 0.02, (cue.start + Math.max(cue.start + 0.05, cue.spoken)) / 2));
27
+
28
+ // The frame of captioned.mp4 to show a cue with, from the times of its
29
+ // frames: { seek, time }, the time of the frame that shows the cue nearest
30
+ // its moment, and a time a moment before it to seek to, which lands on
31
+ // it. Where no frame shows the cue, the one on screen at its moment.
32
+ // Without the times, the moment itself, and the first frame after it.
33
+ function shotOf(cue, times = []) {
34
+ const moment = momentOf(cue);
35
+ if (!times.length) return { seek: moment, time: moment };
36
+ // The first frame at or after t, as its index.
37
+ const at = (t) => {
38
+ let lo = 0;
39
+ let hi = times.length;
40
+ while (lo < hi) {
41
+ const mid = (lo + hi) >> 1;
42
+ if (times[mid] >= t - 0.0005) hi = mid;
43
+ else lo = mid + 1;
44
+ }
45
+ return lo;
46
+ };
47
+ const from = at(cue.start);
48
+ const to = at(cue.end);
49
+ let pick;
50
+ if (to > from) {
51
+ const i = Math.min(Math.max(at(moment), from), to - 1);
52
+ pick = i > from && moment - times[i - 1] < times[i] - moment ? i - 1 : i;
53
+ } else pick = Math.max(0, at(moment + 0.001) - 1);
54
+ return { seek: Math.max(0, times[pick] - 0.0005), time: times[pick] };
55
+ }
56
+
57
+ // Up to four ffmpeg runs at a time. Once one fails, no other starts.
58
+ async function inPool(items, worker, size = 4) {
59
+ const out = new Array(items.length);
60
+ let next = 0;
61
+ let failed = false;
62
+ await Promise.all(
63
+ Array.from({ length: Math.min(size, items.length) }, async () => {
64
+ while (next < items.length && !failed) {
65
+ const i = next++;
66
+ try {
67
+ out[i] = await worker(items[i], i);
68
+ } catch (err) {
69
+ failed = true;
70
+ throw err;
71
+ }
72
+ }
73
+ }),
74
+ );
75
+ return out;
76
+ }
77
+
78
+ // ─── shapes of each app's buttons, in shares of its screen ────────────────
79
+
80
+ // dot: a round button; bar: a line of text; band: a strip of controls.
81
+ const UI = {
82
+ tiktok: [
83
+ ["bar", 0.3, 0.05, 0.16, 0.014], ["bar", 0.54, 0.05, 0.16, 0.014], ["dot", 0.93, 0.057, 0.03],
84
+ ["dot", 0.915, 0.49, 0.05], ["dot", 0.915, 0.575, 0.04], ["dot", 0.915, 0.645, 0.04], ["dot", 0.915, 0.715, 0.04], ["dot", 0.915, 0.785, 0.04], ["dot", 0.915, 0.87, 0.045],
85
+ ["bar", 0.04, 0.8, 0.3, 0.018], ["bar", 0.04, 0.83, 0.64, 0.013], ["bar", 0.04, 0.852, 0.5, 0.013], ["bar", 0.04, 0.88, 0.42, 0.013],
86
+ ["band", 0, 0.935, 1, 0.065],
87
+ ],
88
+ reels: [
89
+ ["bar", 0.05, 0.05, 0.18, 0.018], ["dot", 0.93, 0.058, 0.03],
90
+ ["dot", 0.92, 0.62, 0.04], ["dot", 0.92, 0.69, 0.04], ["dot", 0.92, 0.76, 0.04], ["dot", 0.92, 0.83, 0.035], ["dot", 0.92, 0.895, 0.04],
91
+ ["dot", 0.07, 0.8, 0.04], ["bar", 0.13, 0.79, 0.25, 0.018], ["bar", 0.04, 0.835, 0.66, 0.013], ["bar", 0.04, 0.858, 0.48, 0.013], ["bar", 0.04, 0.89, 0.4, 0.013],
92
+ ["band", 0, 0.935, 1, 0.065],
93
+ ],
94
+ shorts: [
95
+ ["dot", 0.82, 0.055, 0.03], ["dot", 0.93, 0.055, 0.03],
96
+ ["dot", 0.915, 0.55, 0.045], ["dot", 0.915, 0.625, 0.045], ["dot", 0.915, 0.7, 0.045], ["dot", 0.915, 0.775, 0.045], ["dot", 0.915, 0.85, 0.04],
97
+ ["dot", 0.07, 0.8, 0.04], ["bar", 0.13, 0.79, 0.22, 0.018], ["bar", 0.38, 0.787, 0.18, 0.026], ["bar", 0.04, 0.835, 0.66, 0.013], ["bar", 0.04, 0.858, 0.45, 0.013],
98
+ ["band", 0, 0.935, 1, 0.065],
99
+ ],
100
+ youtube: [["bar", 0.03, 0.04, 0.4, 0.04], ["band", 0, 0.885, 1, 0.115], ["bar", 0.02, 0.895, 0.96, 0.008]],
101
+ x: [["bar", 0.03, 0.9, 0.09, 0.055], ["dot", 0.95, 0.93, 0.05]],
102
+ "x-square": [["bar", 0.03, 0.935, 0.09, 0.04], ["dot", 0.95, 0.955, 0.04]],
103
+ "x-vertical": [["bar", 0.04, 0.8, 0.5, 0.014], ["bar", 0.04, 0.83, 0.7, 0.012], ["band", 0, 0.9, 1, 0.1]],
104
+ linkedin: [["band", 0, 0.88, 1, 0.12], ["bar", 0.15, 0.93, 0.6, 0.012]],
105
+ "linkedin-square": [["band", 0, 0.926, 1, 0.074], ["bar", 0.15, 0.955, 0.6, 0.012]],
106
+ "linkedin-vertical": [["dot", 0.9, 0.62, 0.045], ["dot", 0.9, 0.7, 0.045], ["dot", 0.9, 0.78, 0.045], ["bar", 0.04, 0.8, 0.55, 0.014], ["bar", 0.04, 0.83, 0.62, 0.012], ["band", 0, 0.92, 1, 0.08]],
107
+ square: [["dot", 0.06, 0.955, 0.04], ["dot", 0.94, 0.955, 0.04]],
108
+ };
109
+
110
+ // Each shape of a kind as a rectangle in a box of w by h: { x, y, w, h }.
111
+ function uiRects(kind, w, h) {
112
+ return (UI[kind] || []).map(([type, x, y, a, b]) => (type === "dot" ? { x: x * w - (a * w) / 2, y: y * h - (a * w) / 2, w: a * w, h: a * w } : { x: x * w, y: y * h, w: a * w, h: b * h }));
113
+ }
114
+
115
+ function uiShapes(kind, box) {
116
+ return (UI[kind] || [])
117
+ .map(([type, x, y, a, b]) => {
118
+ if (type === "dot") {
119
+ const d = a * box.w;
120
+ return `<div class="ui dot" style="left:${box.x + x * box.w - d / 2}px;top:${box.y + y * box.h - d / 2}px;width:${d}px;height:${d}px"></div>`;
121
+ }
122
+ return `<div class="ui ${type}" style="left:${box.x + x * box.w}px;top:${box.y + y * box.h}px;width:${a * box.w}px;height:${b * box.h}px"></div>`;
123
+ })
124
+ .join("");
125
+ }
126
+
127
+ // One panel: the frame where the app shows it, the app's buttons over it,
128
+ // the margins tinted and the space the captions may use outlined.
129
+ function panel(zone, frame, video, height) {
130
+ if (zone.screen) {
131
+ const s = height / zone.screen.height;
132
+ const w = zone.screen.width * s;
133
+ const v = zone.screen.video;
134
+ const m = zone.reference.margins;
135
+ const box = { x: 0, y: 0, w, h: height };
136
+ const col = zone.reference.column;
137
+ const safe = { x: m.left * s, y: m.top * s, w: (zone.screen.width - m.left - m.right) * s, h: (zone.screen.height - m.top - m.bottom) * s };
138
+ return {
139
+ width: w,
140
+ html: `<div class="phone" style="width:${w}px;height:${height}px">
141
+ <img src="${frame}" style="left:${v.x * s}px;top:${v.y * s}px;width:${v.width * s}px;height:${v.height * s}px">
142
+ ${uiShapes(zone.ui, box)}
143
+ <div class="margin" style="left:0;top:0;width:${w}px;height:${m.top * s}px"></div>
144
+ <div class="margin" style="left:0;top:${height - m.bottom * s}px;width:${w}px;height:${m.bottom * s}px"></div>
145
+ <div class="margin" style="left:0;top:${m.top * s}px;width:${m.left * s}px;height:${safe.h}px"></div>
146
+ <div class="margin" style="left:${w - m.right * s}px;top:${m.top * s}px;width:${m.right * s}px;height:${safe.h}px"></div>
147
+ ${col ? `<div class="margin" style="left:${w - col.width * s}px;top:${col.from * s}px;width:${(col.width - m.right) * s}px;height:${Math.max(0, height - m.bottom * s - col.from * s)}px"></div>` : ""}
148
+ <div class="safe" style="left:${safe.x}px;top:${safe.y}px;width:${safe.w}px;height:${safe.h}px"></div>
149
+ </div>`,
150
+ };
151
+ }
152
+ const scale = Math.min(height / video.height, (height * 1.25) / video.width);
153
+ const w = video.width * scale;
154
+ const h = video.height * scale;
155
+ const m = zone.margins;
156
+ const box = { x: 0, y: 0, w, h };
157
+ return {
158
+ width: w,
159
+ html: `<div class="frame" style="width:${w}px;height:${h}px;margin-top:${(height - h) / 2}px">
160
+ <img src="${frame}" style="left:0;top:0;width:${w}px;height:${h}px">
161
+ ${uiShapes(zone.ui, box)}
162
+ <div class="margin" style="left:0;top:0;width:${w}px;height:${m.top * scale}px"></div>
163
+ <div class="margin" style="left:0;top:${h - m.bottom * scale}px;width:${w}px;height:${m.bottom * scale}px"></div>
164
+ <div class="margin" style="left:0;top:${m.top * scale}px;width:${m.left * scale}px;height:${h - (m.top + m.bottom) * scale}px"></div>
165
+ <div class="margin" style="left:${w - m.right * scale}px;top:${m.top * scale}px;width:${m.right * scale}px;height:${h - (m.top + m.bottom) * scale}px"></div>
166
+ <div class="safe" style="left:${m.left * scale}px;top:${m.top * scale}px;width:${w - (m.left + m.right) * scale}px;height:${h - (m.top + m.bottom) * scale}px"></div>
167
+ </div>`,
168
+ };
169
+ }
170
+
171
+ function marginsText(zone) {
172
+ const r = zone.reference;
173
+ const m = r.margins;
174
+ const column = r.column ? `, buttons ${r.column.width} px wide from ${r.column.from} px down` : "";
175
+ return `Top ${m.top}, bottom ${m.bottom}, left ${m.left}, right ${m.right} px of a ${r.width}x${r.height} ${zone.screen ? "screen" : "frame"}${column}.`;
176
+ }
177
+
178
+ const PAGE_CSS = `
179
+ html, body { margin: 0; background: #15161a; color: #e9e7e1; font: 15px/1.4 "Helvetica Neue", Arial, "Liberation Sans", sans-serif; }
180
+ .wrap { display: inline-flex; gap: 36px; padding: 32px; align-items: flex-start; }
181
+ .cell { display: flex; flex-direction: column; gap: 12px; }
182
+ .phone, .frame { position: relative; overflow: hidden; background: #000; }
183
+ .phone { border-radius: 26px; box-shadow: 0 0 0 6px #2b2c31; }
184
+ .frame { box-shadow: 0 0 0 2px #2b2c31; }
185
+ img { position: absolute; display: block; }
186
+ .ui { position: absolute; background: rgba(255, 255, 255, 0.78); box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.55); }
187
+ .ui.dot { border-radius: 50%; }
188
+ .ui.bar { border-radius: 99px; }
189
+ .ui.band { background: rgba(20, 20, 22, 0.72); box-shadow: none; }
190
+ .margin { position: absolute; background: repeating-linear-gradient(135deg, rgba(255, 45, 45, 0.38) 0 6px, rgba(255, 45, 45, 0.12) 6px 12px); }
191
+ .safe { position: absolute; outline: 2px dashed #7cff9b; outline-offset: -1px; }
192
+ h2 { margin: 0; font-size: 18px; }
193
+ p { margin: 0; color: #b9b6ad; font-size: 13px; }
194
+ .note { padding: 0 32px 28px; color: #b9b6ad; font-size: 13px; max-width: 1400px; }
195
+ .sheet { display: grid; gap: 14px; padding: 24px; }
196
+ .shot { position: relative; }
197
+ .shot img { position: static; display: block; }
198
+ .shot b { position: absolute; left: 6px; top: 6px; background: rgba(0, 0, 0, 0.72); color: #fff; padding: 2px 7px; border-radius: 6px; font-size: 13px; }`;
199
+
200
+ async function screenshotPage(html, file, dir, { width = 1600, height = 900 } = {}) {
201
+ const page = path.join(dir, `${path.basename(file, ".png")}-page.html`);
202
+ fs.writeFileSync(page, html);
203
+ const browser = await launch({ width, height });
204
+ try {
205
+ const tab = await openPage(browser, page, { width, height });
206
+ await evaluate(tab, "Promise.all([...document.images].map((i) => i.decode().catch(() => {}))).then(() => true)");
207
+ const size = await evaluate(tab, "({ w: Math.ceil(document.querySelector('.root').getBoundingClientRect().width), h: Math.ceil(document.querySelector('.root').getBoundingClientRect().height) })");
208
+ await tab.call("Emulation.setDeviceMetricsOverride", { width: Math.max(320, size.w), height: Math.max(200, size.h), deviceScaleFactor: 1, mobile: false });
209
+ const { data } = await tab.call("Page.captureScreenshot", { format: "png", captureBeyondViewport: true, clip: { x: 0, y: 0, width: size.w, height: size.h, scale: 1 } });
210
+ fs.writeFileSync(file, Buffer.from(data, "base64"));
211
+ } finally {
212
+ await browser.close();
213
+ }
214
+ }
215
+
216
+ // safe-zones.png: one frame of the captioned video under each place's
217
+ // buttons, one that shows the caption that reaches widest. `times` are the
218
+ // frames' times. The frames go to Chrome as PNG, converted to RGB with
219
+ // captioned.mp4's own matrix: a JPEG is read as BT.601, and ffmpeg writes
220
+ // the BT.709 picture of an HD video into one as it is, so its colors and
221
+ // the lit word's came out shifted. `frameTags` are burn's, for frameAt:
222
+ // a PNG of an SDR video names no primaries or transfer, which Chrome
223
+ // would convert its colors by.
224
+ async function safeZonesPng({ bins, video, output, zones, cue, times = [], file, dir, frameTags = {} }) {
225
+ const frame = await frameAt(bins, output, shotOf(cue, times).seek, path.join(dir, "zone-frame.png"), { width: Math.min(1080, video.width), ...frameTags });
226
+ const url = pathToFileURL(frame).href;
227
+ const cells = zones.map((zone) => {
228
+ const p = panel(zone, url, video, 640);
229
+ return `<div class="cell" style="width:${Math.max(p.width, 300)}px">${p.html}
230
+ <h2>${esc(zone.name)}</h2><p>${esc(marginsText(zone))}</p><p>From ${esc(zone.source)}, read ${esc(zone.read)}.</p>${zone.note ? `<p>${esc(zone.note)}</p>` : ""}</div>`;
231
+ });
232
+ const html = `<!doctype html><html><head><meta charset="utf-8"><style>${PAGE_CSS}</style></head><body><div class="root" style="display:inline-block">
233
+ <div class="wrap">${cells.join("")}</div>
234
+ <div class="note">Red: the margins each app covers with its buttons and text. Dashed green: where text is safe. The buttons are drawn roughly where each app puts them. Apps change their screens: check one post on a phone.</div>
235
+ </div></body></html>`;
236
+ await screenshotPage(html, file, dir);
237
+ }
238
+
239
+ // sheet.png: a frame of the captioned video for each caption, one that
240
+ // shows it, with its number and the frame's time.
241
+ async function sheetPng({ bins, video, output, cues, times = [], file, dir, frameTags = {} }) {
242
+ const picked = sheetCues(cues);
243
+ const shots = picked.map((cue) => shotOf(cue, times));
244
+ const thumb = video.width >= video.height ? 400 : 240;
245
+ const frames = await inPool(picked, (cue, i) => frameAt(bins, output, shots[i].seek, path.join(dir, `sheet-${String(i + 1).padStart(3, "0")}.png`), { width: thumb, ...frameTags }));
246
+ const columns = video.width >= video.height ? 4 : 6;
247
+ const cells = picked.map((cue, i) => `<div class="shot"><img src="${pathToFileURL(frames[i]).href}" width="${thumb}"><b>${cue.n} · ${clock(shots[i].time)}</b></div>`);
248
+ const html = `<!doctype html><html><head><meta charset="utf-8"><style>${PAGE_CSS}</style></head><body>
249
+ <div class="root sheet" style="display:inline-grid;grid-template-columns:repeat(${Math.min(columns, picked.length)}, ${thumb}px)">${cells.join("")}</div></body></html>`;
250
+ await screenshotPage(html, file, dir);
251
+ return picked.length;
252
+ }
253
+
254
+ // Without Chrome: the sheet as ffmpeg's tile of frames, and each place's
255
+ // margins tinted over one frame with drawbox, side by side.
256
+ async function plainPictures({ bins, video, output, cues, times = [], zones, cue, sheetFile, zonesFile, dir, frameTags = {} }) {
257
+ const picked = sheetCues(cues);
258
+ const thumb = video.width >= video.height ? 400 : 240;
259
+ const frames = await inPool(picked, (c, i) => frameAt(bins, output, shotOf(c, times).seek, path.join(dir, `sheet-${String(i + 1).padStart(3, "0")}.png`), { width: thumb, ...frameTags }));
260
+ const columns = Math.min(video.width >= video.height ? 4 : 6, frames.length);
261
+ const rows = Math.ceil(frames.length / columns);
262
+ // The frames as one animated PNG, read by its name from its folder, as
263
+ // the captions' states are.
264
+ const strip = path.join(dir, "sheet.apng");
265
+ writeApng(strip, frames.map((png) => ({ png, ms: 1000 })));
266
+ let r = await run(bins.ffmpeg, ["-hide_banner", "-nostdin", "-y", "-loglevel", "error", "-f", "apng", "-i", path.basename(strip), "-vf", `tile=${columns}x${rows}:padding=12:margin=12:color=0x15161a`, "-frames:v", "1", "-update", "1", path.resolve(sheetFile)], { cwd: dir });
267
+ if (r.code !== 0) throw new Error(`ffmpeg could not make sheet.png: ${whyFailed(r)}`);
268
+ const frame = await frameAt(bins, output, shotOf(cue, times).seek, path.join(dir, "zone-frame.png"), { width: 0, ...frameTags });
269
+ const inputs = [];
270
+ const parts = [];
271
+ zones.forEach((zone, i) => {
272
+ const m = zone.margins;
273
+ const box = (x, y, w, h) => `drawbox=x=${Math.round(x)}:y=${Math.round(y)}:w=${Math.max(1, Math.round(w))}:h=${Math.max(1, Math.round(h))}:color=red@0.25:t=fill`;
274
+ const W = video.out.width;
275
+ const H = video.out.height;
276
+ const boxes = [box(0, 0, W, m.top), box(0, H - m.bottom, W, m.bottom), box(0, m.top, m.left, H - m.top - m.bottom), box(W - m.right, m.top, m.right, H - m.top - m.bottom)];
277
+ if (zone.column) boxes.push(box(W - zone.column.width, zone.column.from, zone.column.width, Math.max(1, H - m.bottom - zone.column.from)));
278
+ boxes.push(`drawbox=x=${Math.round(m.left)}:y=${Math.round(m.top)}:w=${Math.round(W - m.left - m.right)}:h=${Math.round(H - m.top - m.bottom)}:color=0x7cff9b:t=4`);
279
+ inputs.push(...(frame.includes("%") ? ["-pattern_type", "none"] : []), "-i", frame);
280
+ parts.push(`[${i}:v]${boxes.join(",")},scale=-2:640,pad=iw+24:ih+24:12:12:color=0x15161a[p${i}]`);
281
+ });
282
+ const graph = zones.length > 1 ? `${parts.join(";")};${zones.map((_, i) => `[p${i}]`).join("")}hstack=inputs=${zones.length}[out]` : `${parts[0]};[p0]null[out]`;
283
+ r = await run(bins.ffmpeg, ["-hide_banner", "-nostdin", "-y", "-loglevel", "error", ...inputs, "-filter_complex", graph, "-map", "[out]", "-frames:v", "1", "-update", "1", zonesFile]);
284
+ if (r.code !== 0) throw new Error(`ffmpeg could not make safe-zones.png: ${whyFailed(r)}`);
285
+ return picked.length;
286
+ }
287
+
288
+ module.exports = { safeZonesPng, sheetPng, plainPictures, sheetCues, momentOf, shotOf, marginsText, uiRects, UI };