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.
@@ -0,0 +1,1090 @@
1
+ "use strict";
2
+
3
+ // Reads the words to caption and their times from whatever made them: a
4
+ // speech-to-text JSON with word times (OpenAI verbose_json, whisper.cpp,
5
+ // WhisperX, ElevenLabs, Deepgram, AssemblyAI, NeMo, Parakeet, Hugging Face
6
+ // transformers, AWS Transcribe, Google Speech-to-Text), an SRT or WebVTT
7
+ // file, easyscreenshotkit's steps.json, or a words.json this kit wrote.
8
+ //
9
+ // Every reader gives the same list: { text, start, end, seg, timed }, in
10
+ // seconds. seg numbers the segment a word came in (a cue of an SRT file, a
11
+ // step, a speech-to-text segment); hard segments are never joined in one
12
+ // caption. timed is false for a word whose time was spread over its
13
+ // segment by length, since the file gave times for whole lines only.
14
+
15
+ const fs = require("node:fs");
16
+ const path = require("node:path");
17
+ const crypto = require("node:crypto");
18
+ const { width, SPACELESS } = require("./text");
19
+ const { readAtMost, readAtMostLater, jsonShape, jsonProblem, sizeText } = require("./files");
20
+
21
+ // The SHA-256 of the words of a words.json, as JSON: a words.json says
22
+ // what its run wrote, so a later run can tell the words were fixed by
23
+ // hand since.
24
+ const wordsSha256 = (words) => crypto.createHash("sha256").update(JSON.stringify(words)).digest("hex");
25
+
26
+ // Whether the words of a words.json this kit wrote were fixed by hand:
27
+ // they are not the words its run wrote, or that run read them from a
28
+ // words.json fixed by hand. One that does not say what its run wrote
29
+ // cannot be told apart from one fixed by hand.
30
+ function handFixed(data) {
31
+ if (!data || data.fixed === true) return true;
32
+ return !(Array.isArray(data.words) && typeof data.wordsSha256 === "string" && wordsSha256(data.words) === data.wordsSha256);
33
+ }
34
+
35
+ const FORMATS = {
36
+ easycaptionkit: "a words.json from easycaptionkit",
37
+ steps: "easyscreenshotkit's steps.json",
38
+ srt: "an SRT file",
39
+ vtt: "a WebVTT file",
40
+ openai: "OpenAI verbose_json",
41
+ whispercpp: "whisper.cpp JSON",
42
+ whisperx: "WhisperX or faster-whisper JSON",
43
+ elevenlabs: "ElevenLabs speech-to-text JSON",
44
+ deepgram: "Deepgram JSON",
45
+ assemblyai: "AssemblyAI JSON",
46
+ nemo: "NeMo JSON",
47
+ parakeet: "Parakeet JSON",
48
+ transformers: "Hugging Face transformers JSON (chunks)",
49
+ aws: "AWS Transcribe JSON",
50
+ google: "Google Speech-to-Text JSON",
51
+ words: "a JSON list of words with times",
52
+ segments: "a JSON list of lines with times",
53
+ };
54
+
55
+ // The most a run takes: 100,000 words, some 10 hours of speech, of at most
56
+ // 500 characters each and a million in all. Past that a file is no words
57
+ // to caption, as one word of megabytes is not, and a run of it would take
58
+ // gigabytes and minutes, and write a words.json and a captions.json too
59
+ // big for the next run into the folder to read back.
60
+ const MOST_WORDS = 100_000;
61
+ const LONGEST_WORD = 500;
62
+ const MOST_TEXT = 1_000_000;
63
+ const big = (n) => n.toLocaleString("en-US");
64
+ const tooMuch = (message) => Object.assign(new Error(message), { code: "EWORDS" });
65
+ const tooLong = (length) => tooMuch(`it holds a word ${big(length)} characters long, and a word to caption is at most ${LONGEST_WORD}. Check that it holds the words of the video, each with its time`);
66
+ // The count of words, or of characters, or none where the reading stopped
67
+ // at the most.
68
+ const tooMany = (count = 0) => tooMuch(`it holds ${count ? big(count) : `more than ${big(MOST_WORDS)}`} words, and a run takes at most ${big(MOST_WORDS)}, some 10 hours of speech. Caption a longer video in parts, each with its own words`);
69
+ const tooMuchText = (count = 0) => tooMuch(`its words hold ${count ? big(count) : `more than ${big(MOST_TEXT)}`} characters, and a run takes at most ${big(MOST_TEXT)}, some 10 hours of speech. Caption a longer video in parts, each with its own words`);
70
+
71
+ // Words within what a run takes, or the error that says how they are not.
72
+ function withinLimits(words) {
73
+ if (words.length > MOST_WORDS) throw tooMany(words.length);
74
+ let text = 0;
75
+ for (const w of words) {
76
+ const length = String(w.text).length;
77
+ if (length > LONGEST_WORD) throw tooLong(length);
78
+ text += length;
79
+ }
80
+ if (text > MOST_TEXT) throw tooMuchText(text);
81
+ return words;
82
+ }
83
+
84
+ // What a reader has read so far, for the limits to stop the reading as
85
+ // soon as it passes one, across a whole file: words and their characters,
86
+ // the lines of a subtitle file's cues and the entries of a JSON file's
87
+ // lists.
88
+ const tally = () => ({ words: 0, text: 0, lines: 0, items: 0 });
89
+
90
+ // A cue of a subtitle file loses at most its first three lines, as the
91
+ // repeats of rolling captions, and each line it keeps holds a word: one
92
+ // of more lines than that is more words than a run takes, which is said
93
+ // as it is read.
94
+ const MOST_LINES = MOST_WORDS + 3;
95
+
96
+ // The lines of text a subtitle file holds in all, across its cues: five
97
+ // for each word a run takes, as rolling captions say each line up to three
98
+ // times, and a roll-up of four lines up to five. Past that the file is
99
+ // refused as it is read, before its lines take gigabytes.
100
+ const MOST_ALL_LINES = 5 * MOST_WORDS;
101
+ const tooManyLines = () => tooMuch(`it holds more than ${big(MOST_ALL_LINES)} lines of captions, and a run takes at most ${big(MOST_WORDS)} words, some 10 hours of speech. Caption a longer video in parts, each with its own words`);
102
+
103
+ // The entries a JSON file's lists hold in all, five for each word a run
104
+ // takes: a speech-to-text's list of words can hold a spacing or a mark of
105
+ // punctuation between them, and its tokens are pieces of words. Each list
106
+ // is counted before any of it is read, so a list of millions of entries is
107
+ // refused before it is made into words.
108
+ const MOST_ITEMS = 5 * MOST_WORDS;
109
+ const tooManyItems = () => tooMuch(`it holds more than ${big(MOST_ITEMS)} entries, and a run takes at most ${big(MOST_WORDS)} words, some 10 hours of speech. Caption a longer video in parts, each with its own words`);
110
+ function listed(list, read) {
111
+ read.items += list.length;
112
+ if (read.items > MOST_ITEMS) throw tooManyItems();
113
+ return list;
114
+ }
115
+
116
+ // The lists and objects a JSON text may open in all, five for each entry
117
+ // of its lists a run takes, as a word of a speech-to-text's list is an
118
+ // object that holds a few more, and how deep they may nest, where a
119
+ // speech-to-text's JSON nests them a few deep. Both are looked at before
120
+ // the text is parsed: a text of 64 MB, which a words file can be, of
121
+ // millions of empty objects or of lists opened one inside another took
122
+ // JSON.parse and badAt gigabytes and half a minute before anything was
123
+ // counted. A words.json and a captions.json a run writes, of the most
124
+ // words, hold under half a million.
125
+ const MOST_OPENED = 5 * MOST_ITEMS;
126
+ const DEEPEST = 64;
127
+ const tooDeep = () => tooMuch(`it nests lists and objects more than ${DEEPEST} deep, where a words file nests them a few deep. Check that it holds the words of the video, each with its time`);
128
+
129
+ // The fewest words a line of a subtitle file gives once it is cleaned:
130
+ // runs of what is not a space, as its tags, its entities and its control
131
+ // characters give none, found a character at a time without making
132
+ // anything of the line. A line of more of them than a run takes is
133
+ // refused before it is cleaned, which for millions of words takes
134
+ // seconds and gigabytes; a shorter line cannot hold that many.
135
+ function fewestWords(line) {
136
+ if (line.length <= 2 * MOST_WORDS) return;
137
+ let words = 0;
138
+ let inWord = false;
139
+ for (let i = 0; i < line.length; i++) {
140
+ const c = line.charCodeAt(i);
141
+ // A tag or an override block, to its end or the end of the line.
142
+ if (c === 0x3c || (c === 0x7b && line.charCodeAt(i + 1) === 0x5c)) {
143
+ const close = line.indexOf(c === 0x3c ? ">" : "}", i + 1);
144
+ i = close < 0 ? line.length : close;
145
+ continue;
146
+ }
147
+ if (c === 0x26) {
148
+ const entity = /^&(?:#x[0-9a-f]+|#\d+|[a-z]+);/i.exec(line.slice(i, i + 40));
149
+ if (entity) {
150
+ i += entity[0].length - 1;
151
+ continue;
152
+ }
153
+ }
154
+ if (c <= 0x08 || (c >= 0x0b && c <= 0x1f) || c === 0x7f) continue;
155
+ if (c === 0x20 || c === 0x09 || c === 0x0a || (c > 0x7f && /\s/.test(line[i]))) {
156
+ inWord = false;
157
+ continue;
158
+ }
159
+ if (inWord) continue;
160
+ inWord = true;
161
+ if (++words > MOST_WORDS) throw tooMany();
162
+ }
163
+ }
164
+
165
+ // ─── times ────────────────────────────────────────────────────────────────
166
+
167
+ // Seconds from 12.5, "12.5", "12.5s" (Google), "00:00:12,500" or
168
+ // "00:12.500" (SRT, VTT, whisper.cpp) and "1:02:03.25" (ASS).
169
+ function seconds(value) {
170
+ if (typeof value === "number") return Number.isFinite(value) ? value : NaN;
171
+ if (typeof value !== "string") return NaN;
172
+ const text = value.trim();
173
+ let m;
174
+ if ((m = /^(-?\d+(?:\.\d+)?)s?$/.exec(text))) return Number(m[1]);
175
+ if ((m = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?$/.exec(text))) {
176
+ const fraction = m[4] ? Number(m[4]) / 10 ** m[4].length : 0;
177
+ return (m[1] ? Number(m[1]) * 3600 : 0) + Number(m[2]) * 60 + Number(m[3]) + fraction;
178
+ }
179
+ return NaN;
180
+ }
181
+
182
+ const first = (object, keys) => {
183
+ for (const key of keys) if (object && object[key] !== undefined && object[key] !== null) return object[key];
184
+ return undefined;
185
+ };
186
+
187
+ // An entry of a speech-to-text list that can be read: an object, where a
188
+ // null or a number in its place is passed over.
189
+ const entry = (value) => Boolean(value) && typeof value === "object";
190
+
191
+ // A value of a JSON file as text: a string as it is, a finite number as
192
+ // its digits, and anything else as none. An object or a list where a
193
+ // word's text goes gives no word, rather than "[object Object]" or "a,b"
194
+ // burned into the video, and one with a toString or a valueOf of its own,
195
+ // which JavaScript cannot make text of, stops no run with an error that
196
+ // names no file.
197
+ const textOf = (value) => (typeof value === "string" ? value : typeof value === "number" && Number.isFinite(value) ? String(value) : "");
198
+
199
+ // A value of a JSON file as a number, as Number takes it, but an object or
200
+ // a list, which is none, for the same reason.
201
+ const numberOf = (value) => (value !== null && typeof value === "object" ? NaN : Number(value));
202
+
203
+ // ─── the list every reader returns ────────────────────────────────────────
204
+
205
+ // Text on one line, its control characters out and each run of spaces and
206
+ // line breaks one space. A space that is one already stays as it is, so a
207
+ // line of millions of words is not made again a word at a time.
208
+ function cleanText(text) {
209
+ return textOf(text)
210
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "")
211
+ .replace(/\s{2,}|[^\S ]/g, " ")
212
+ .trim();
213
+ }
214
+
215
+ // The latest time a subtitle file can say to the millisecond: past it, as
216
+ // 1e308 s is, a time in milliseconds is no whole number, or no number.
217
+ const LAST_TIME = Number.MAX_SAFE_INTEGER / 1000;
218
+
219
+ // Words in order, with times made sound: a word with no time, or one past
220
+ // what a subtitle file can say, placed between its neighbors, and marked
221
+ // placed, an end before its start set to the start, and nothing before
222
+ // zero. With `bySegment` (an SRT's cues, a demo's steps) the words of one
223
+ // segment stay together, in the segments' order.
224
+ function settle(words, { bySegment = false } = {}) {
225
+ const out = words.filter((w) => w.text);
226
+ out.forEach((w, i) => (w.order = i));
227
+ for (const w of out) {
228
+ if (!(Math.abs(w.start) <= LAST_TIME)) w.start = NaN;
229
+ if (!(Math.abs(w.end) <= LAST_TIME)) w.end = NaN;
230
+ }
231
+ const known = (w) => Number.isFinite(w.start) && Number.isFinite(w.end);
232
+ // Fill a run of untimed words from the neighbors, by length.
233
+ let untimed = 0;
234
+ for (let i = 0; i < out.length; i++) {
235
+ if (known(out[i])) continue;
236
+ let j = i;
237
+ while (j < out.length && !known(out[j])) j++;
238
+ const from = i > 0 ? out[i - 1].end : j < out.length ? Math.max(0, out[j].start - 0.3 * (j - i)) : 0;
239
+ const to = j < out.length ? Math.max(from, out[j].start) : from + 0.3 * (j - i);
240
+ spread(out.slice(i, j), from, to);
241
+ for (let k = i; k < j; k++) Object.assign(out[k], { timed: false, placed: true });
242
+ untimed += j - i;
243
+ i = j - 1;
244
+ }
245
+ for (const w of out) {
246
+ w.start = Math.max(0, w.start);
247
+ w.end = Math.max(w.start, w.end);
248
+ }
249
+ // Stable: words that start together keep the order they came in.
250
+ out.sort((a, b) => (bySegment ? a.seg - b.seg : 0) || a.start - b.start || a.order - b.order);
251
+ for (const w of out) delete w.order;
252
+ return { words: out, untimed };
253
+ }
254
+
255
+ // Times for words that share one segment, by their length: a long word
256
+ // takes longer to say. Each word gets its letters plus one for the space.
257
+ function spread(words, start, end) {
258
+ const weights = words.map((w) => width(w.text) + 1);
259
+ const total = weights.reduce((a, b) => a + b, 0) || 1;
260
+ let at = start;
261
+ words.forEach((w, i) => {
262
+ const span = ((end - start) * weights[i]) / total;
263
+ w.start = at;
264
+ w.end = at + span;
265
+ at += span;
266
+ });
267
+ return words;
268
+ }
269
+
270
+ // A line's text as words, with its time spread over them. A line of one
271
+ // word gives that word its own time. `read` counts the words of the file
272
+ // so far.
273
+ function lineWords(text, start, end, seg, read = tally()) {
274
+ const texts = splitWords(text, read);
275
+ const words = texts.map((t) => ({ text: t, seg, timed: texts.length === 1 }));
276
+ return spread(words, start, end);
277
+ }
278
+
279
+ // The cues of a subtitle file, each with its lines as text, as words. A
280
+ // cue still up when the next one comes on ends there, so the words of two
281
+ // cues never mix: YouTube's downloaded captions keep each line up while
282
+ // the next rolls in. Cues that come on together are shown together, as
283
+ // one. Returns { words, left }: left is how many repeated lines of the
284
+ // file were left out.
285
+ //
286
+ // Rolling captions say each line once: YouTube's automatic captions, as
287
+ // yt-dlp and ffmpeg write them as SRT, repeat the line before at the top
288
+ // of every cue, with a cue of a few milliseconds between that repeats it
289
+ // alone, each cue coming on as the one before it goes. Only a file that
290
+ // shows that pattern is read as one: a cue that short repeating the line
291
+ // before, or most of the cues of two lines or more that come on as the
292
+ // one before goes starting with its last lines, three at least. There a
293
+ // cue's first lines that are the last lines of the cue before are left
294
+ // out when it comes on within 50 ms of that cue's end, or before it, and
295
+ // has a line of its own after them, and so is a cue that short which only
296
+ // repeats. In any other file every line stays, as a speaker in a film who
297
+ // says again the line the cue before ended with, cues back to back or
298
+ // not, or a chorus in cues of its own. `read` counts the words so far.
299
+ function cueWords(cues, read = tally()) {
300
+ const sorted = cues
301
+ .map((cue, i) => ({ ...cue, lines: cue.lines.filter(Boolean), i }))
302
+ .filter((cue) => cue.lines.length && cue.end >= cue.start)
303
+ .sort((a, b) => a.start - b.start || a.i - b.i);
304
+ // Each cue against the one before: whether it comes on as that one
305
+ // goes, how many of its first lines are that one's last lines, with a
306
+ // line of its own after them, and whether it is a cue of a moment that
307
+ // only repeats.
308
+ const seen = sorted.map((cue, k) => {
309
+ const before = k > 0 ? sorted[k - 1] : null;
310
+ if (!before) return { rolling: false, repeated: 0, echo: false };
311
+ const { lines } = cue;
312
+ const ends = (n) => lines.slice(0, n).every((line, j) => line === before.lines[before.lines.length - n + j]);
313
+ const rolling = cue.start - before.end <= 0.05;
314
+ // Rolling captions repeat a line or two, three in a roll-up of four:
315
+ // no more is looked for, so a cue of many lines is not compared again
316
+ // for each count of them.
317
+ let repeated = 0;
318
+ for (let n = rolling ? Math.min(3, before.lines.length, lines.length - 1) : 0; n > 0; n--) {
319
+ if (ends(n)) {
320
+ repeated = n;
321
+ break;
322
+ }
323
+ }
324
+ const echo = rolling && cue.end - cue.start < 0.05 && lines.length <= before.lines.length && ends(lines.length);
325
+ return { rolling, repeated, echo };
326
+ });
327
+ let echoes = 0;
328
+ let stacked = 0;
329
+ let repeating = 0;
330
+ sorted.forEach((cue, k) => {
331
+ if (seen[k].echo) echoes++;
332
+ if (!seen[k].rolling || cue.lines.length < 2) return;
333
+ stacked++;
334
+ if (seen[k].repeated > 0) repeating++;
335
+ });
336
+ const pattern = echoes > 0 || (repeating >= 3 && repeating >= 0.8 * stacked);
337
+ const fresh = [];
338
+ let left = 0;
339
+ sorted.forEach((cue, k) => {
340
+ const { repeated, echo } = pattern ? seen[k] : { repeated: 0, echo: false };
341
+ if (echo) left += cue.lines.length;
342
+ else {
343
+ left += repeated;
344
+ fresh.push({ start: cue.start, end: cue.end, text: cue.lines.slice(repeated).join(" ") });
345
+ }
346
+ });
347
+ const merged = [];
348
+ for (const cue of fresh) {
349
+ const last = merged[merged.length - 1];
350
+ if (last && cue.start - last.start < 0.05) {
351
+ last.text += ` ${cue.text}`;
352
+ last.end = Math.max(last.end, cue.end);
353
+ } else merged.push({ ...cue });
354
+ }
355
+ const words = merged.flatMap((cue, seg) => {
356
+ const next = merged[seg + 1];
357
+ return lineWords(cue.text, cue.start, next ? Math.min(cue.end, next.start) : cue.end, seg, read);
358
+ });
359
+ return { words, left };
360
+ }
361
+
362
+ // A note for the repeated lines a file of rolling captions had.
363
+ const rollingNote = (left) => (left ? [`The file repeats the line before at the top of its cues, as YouTube's automatic captions do, so ${left === 1 ? "1 repeated line was" : `${left} repeated lines were`} left out.`] : []);
364
+
365
+ // Splits text into words at spaces; text in a script without spaces is
366
+ // split by the language's own word segmenter.
367
+ const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
368
+ // What cleanText leaves between its spaces: a run of anything but the
369
+ // spaces and line breaks it turns into one, as it takes out the control
370
+ // characters, the carriage return among them, rather than space them.
371
+ const PIECES = /[\S\v\f\r]+/g;
372
+ const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g;
373
+ // A word longer than a run takes, or more words or characters than it
374
+ // takes, in this text and the ones `read` counted before it, stops the
375
+ // reading here, before a word is measured. The text is walked a piece at
376
+ // a time, each cleaned as it comes, so a line of millions of words stops
377
+ // at the word past the most, not once all of them are split.
378
+ function splitWords(text, read = tally()) {
379
+ const out = [];
380
+ const raw = textOf(text);
381
+ for (const found of raw.matchAll(PIECES)) {
382
+ const piece = found[0].replace(CONTROL, "");
383
+ if (!piece) continue;
384
+ if (!/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u.test(piece)) {
385
+ if (piece.length > LONGEST_WORD) throw tooLong(piece.length);
386
+ out.push(piece);
387
+ read.words++;
388
+ read.text += piece.length;
389
+ if (read.text > MOST_TEXT) throw tooMuchText();
390
+ if (read.words > MOST_WORDS) throw tooMany();
391
+ continue;
392
+ }
393
+ // Words of a spaceless script: punctuation goes with the word before
394
+ // it, an opening quote or bracket with the word after it. Every
395
+ // character of the piece goes into one of its words.
396
+ const words = [];
397
+ let opening = "";
398
+ let seen = 0;
399
+ for (const part of wordSegmenter.segment(piece)) {
400
+ if (part.isWordLike) {
401
+ words.push(opening + part.segment);
402
+ opening = "";
403
+ } else if (/^[\p{Ps}\p{Pi}]+$/u.test(part.segment) || !words.length) opening += part.segment;
404
+ else words[words.length - 1] += part.segment;
405
+ seen += part.segment.length;
406
+ if (words.length && words[words.length - 1].length > LONGEST_WORD) throw tooLong(words[words.length - 1].length);
407
+ if (opening.length > LONGEST_WORD) throw tooLong(opening.length);
408
+ if (read.text + seen > MOST_TEXT) throw tooMuchText();
409
+ if (read.words + words.length > MOST_WORDS) throw tooMany();
410
+ }
411
+ if (opening) {
412
+ if (words.length) words[words.length - 1] += opening;
413
+ else words.push(opening);
414
+ }
415
+ out.push(...words);
416
+ read.words += words.length;
417
+ read.text += seen;
418
+ }
419
+ return out;
420
+ }
421
+
422
+ // ─── SRT and WebVTT ───────────────────────────────────────────────────────
423
+
424
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", lrm: "‎", rlm: "‏" };
425
+ function decodeEntities(text) {
426
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, name) => {
427
+ if (name[0] === "#") {
428
+ const code = name[1].toLowerCase() === "x" ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
429
+ return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : all;
430
+ }
431
+ return Object.hasOwn(ENTITIES, name.toLowerCase()) ? ENTITIES[name.toLowerCase()] : all;
432
+ });
433
+ }
434
+
435
+ // SRT and VTT style tags and ASS override blocks some SRT files carry. A
436
+ // tag holds no other < and a block no other {, so a line of many of them
437
+ // is looked at once, not again from each.
438
+ function stripTags(text) {
439
+ return text.replace(/\{\\[^{}]*\}/g, "").replace(/<[^<>]*>/g, "");
440
+ }
441
+
442
+ const ARROW = /^\s*((?:\d+:)?\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)\s*-->\s*((?:\d+:)?\d{1,2}:\d{1,2}(?:[.,]\d{1,3})?)/;
443
+
444
+ // SRT. Every timing line starts a cue, so a file that leaves out the empty
445
+ // line between two cues still reads right: the bare number just above a
446
+ // timing line is that cue's number, not text of the cue before. The lines
447
+ // are read one at a time and the cues with text counted as they come: each
448
+ // holds a word at least, so a file of more of them than a run takes words
449
+ // is refused there, before the rest of it is read, and so is a cue of more
450
+ // lines, a line of more words, or a file of more lines in all than the
451
+ // rolling captions of that many words hold. Each line of a cue but an
452
+ // empty one counts there, as a WebVTT cue's lines do, whether or not it
453
+ // gives words, and one that gives none is not kept.
454
+ function readSrt(content) {
455
+ const text = content.replace(/\r\n?/g, "\n");
456
+ const cues = [];
457
+ const read = tally();
458
+ let cue = null;
459
+ // Whether the line before is a bare number the cue took as its text.
460
+ let number = false;
461
+ for (let at = 0; at <= text.length; ) {
462
+ const end = text.indexOf("\n", at);
463
+ const stop = end < 0 ? text.length : end;
464
+ const line = text.slice(at, stop);
465
+ at = stop + 1;
466
+ const m = ARROW.exec(line);
467
+ if (m) {
468
+ // A cue whose only text was that number has none.
469
+ if (cue && number && cue.lines.pop() && --cue.said === 0 && cues[cues.length - 1] === cue) cues.pop();
470
+ if (cues.length > MOST_WORDS) throw tooMany();
471
+ cue = { start: seconds(m[1]), end: seconds(m[2]), lines: [], said: 0 };
472
+ number = false;
473
+ } else if (cue && line.trim()) {
474
+ fewestWords(line);
475
+ if (++read.lines > MOST_ALL_LINES) throw tooManyLines();
476
+ const said = lineText(line);
477
+ // A line of tags, entities or control characters alone, as a line of
478
+ // spaces, gives no words.
479
+ if (!said) {
480
+ number = false;
481
+ continue;
482
+ }
483
+ cue.lines.push(said);
484
+ if (++cue.said === 1) cues.push(cue);
485
+ if (cue.said > MOST_LINES) throw tooMany();
486
+ number = /^\s*\d+\s*$/.test(line);
487
+ } else number = false;
488
+ }
489
+ if (cues.length > MOST_WORDS) throw tooMany();
490
+ const { words, left } = cueWords(cues, read);
491
+ return { words, hard: true, notes: rollingNote(left) };
492
+ }
493
+
494
+ // A line of a cue as plain text.
495
+ const lineText = (line) => cleanText(decodeEntities(stripTags(line)));
496
+
497
+ // WebVTT. Inline times (<00:00:01.500>) give each word its own start, as
498
+ // in YouTube's automatic captions; those files repeat the previous line in
499
+ // every cue, so when a file has inline times only the timed lines count.
500
+ // The cues are read one at a time, and a file of more cues with text than
501
+ // a run takes words is refused there, as an SRT is, and so is one of more
502
+ // lines of text.
503
+ function readVtt(content) {
504
+ const text = content.replace(/^\ufeff/, "").replace(/\r\n?/g, "\n");
505
+ if (!/^WEBVTT/.test(text)) throw new Error("it starts without WEBVTT, so it is not a WebVTT file");
506
+ const cues = [];
507
+ const read = tally();
508
+ // Only an empty line ends a cue: YouTube's files have lines of one space
509
+ // inside their cues, which give no words and are not kept. A cue's lines
510
+ // are taken one at a time, and its lines with text counted as they come.
511
+ const blank = /\n\n+/g;
512
+ for (let at = 0; at <= text.length; ) {
513
+ blank.lastIndex = at;
514
+ const gap = blank.exec(text);
515
+ const stop = gap ? gap.index : text.length;
516
+ let from = at;
517
+ at = gap ? gap.index + gap[0].length : text.length + 1;
518
+ let m = null;
519
+ const said = [];
520
+ let lines = 0;
521
+ for (let first = true; from <= stop; first = false) {
522
+ const end = text.indexOf("\n", from);
523
+ const line = text.slice(from, end < 0 || end > stop ? stop : end);
524
+ from = end < 0 || end > stop ? stop + 1 : end + 1;
525
+ if (first && /^(NOTE|STYLE|REGION)\b/.test(line)) break;
526
+ if (!m) m = ARROW.exec(line);
527
+ else if (line.trim()) {
528
+ said.push(line);
529
+ if (++lines > MOST_LINES) throw tooMany();
530
+ if (++read.lines > MOST_ALL_LINES) throw tooManyLines();
531
+ }
532
+ }
533
+ if (!m || !lines) continue;
534
+ cues.push({ start: seconds(m[1]), end: seconds(m[2]), lines: said });
535
+ if (cues.length > MOST_WORDS) throw tooMany();
536
+ }
537
+ const INLINE = /<((?:\d+:)?\d{1,2}:\d{1,2}[.,]\d{1,3})>/;
538
+ const karaoke = cues.some((cue) => cue.lines.some((line) => INLINE.test(line)));
539
+ if (!karaoke) {
540
+ const { words, left } = cueWords(cues.map((cue) => ({ start: cue.start, end: cue.end, lines: cue.lines.map((line) => (fewestWords(line), lineText(line))) })), read);
541
+ return { words, hard: true, notes: rollingNote(left) };
542
+ }
543
+ const words = [];
544
+ let seg = 0;
545
+ for (const cue of cues) {
546
+ if (!(cue.end >= cue.start)) continue;
547
+ for (const line of cue.lines) {
548
+ if (!INLINE.test(line)) continue;
549
+ // Pieces between the times, each taken as it comes, so a line of
550
+ // millions of them stops where the words pass the most: the first
551
+ // starts with the cue.
552
+ const times = /<((?:\d+:)?\d{1,2}:\d{1,2}[.,]\d{1,3})>/g;
553
+ let at = cue.start;
554
+ let from = 0;
555
+ const timed = [];
556
+ const take = (part) => {
557
+ fewestWords(part);
558
+ for (const word of splitWords(decodeEntities(stripTags(part)), read)) timed.push({ text: word, start: at, seg, timed: true });
559
+ };
560
+ for (let m; (m = times.exec(line)); from = m.index + m[0].length) {
561
+ take(line.slice(from, m.index));
562
+ at = seconds(m[1]);
563
+ }
564
+ take(line.slice(from));
565
+ timed.forEach((w, i) => (w.end = i + 1 < timed.length ? Math.max(w.start, timed[i + 1].start) : Math.max(w.start, cue.end)));
566
+ words.push(...timed);
567
+ seg++;
568
+ }
569
+ }
570
+ return { words, hard: false };
571
+ }
572
+
573
+ // ─── JSON shapes ──────────────────────────────────────────────────────────
574
+
575
+ // Times in a list of items: seconds, or milliseconds when `ms`.
576
+ function timedItems(items, { text = ["word", "text", "punctuated_word", "content", "token"], start = ["start", "start_time", "startTime", "from"], end = ["end", "end_time", "endTime", "to"], ms = false, seg = -1 } = {}) {
577
+ return items
578
+ .filter(entry)
579
+ .map((item) => {
580
+ const t = ms ? 0.001 : 1;
581
+ const s = seconds(first(item, start));
582
+ const e = seconds(first(item, end));
583
+ // A segment the item gives is kept as a words.json's are: a whole
584
+ // number, none below zero.
585
+ return { text: cleanText(first(item, text)), start: s * t, end: e * t, seg: Number.isInteger(item.seg) && item.seg >= 0 ? item.seg : seg, timed: Number.isFinite(s) };
586
+ });
587
+ }
588
+
589
+ // Words from segments that hold their own words (WhisperX, OpenAI with
590
+ // both, Parakeet's sentences): each word keeps its segment. The words of
591
+ // the segments that hold none are counted across all of them, as a
592
+ // subtitle file's are.
593
+ function wordsInSegments(segments, key, options = {}, read = tally()) {
594
+ const words = [];
595
+ listed(segments, read);
596
+ segments.forEach((segment, i) => {
597
+ if (!entry(segment)) return;
598
+ const inside = Array.isArray(segment[key]) ? segment[key] : [];
599
+ if (inside.length) words.push(...timedItems(listed(inside, read), { ...options, seg: i }));
600
+ else {
601
+ const s = seconds(first(segment, ["start", "start_time"]));
602
+ const e = seconds(first(segment, ["end", "end_time"]));
603
+ if (Number.isFinite(s) && Number.isFinite(e)) words.push(...lineWords(first(segment, ["text", "sentence"]), s, e, i, read));
604
+ }
605
+ });
606
+ return words;
607
+ }
608
+
609
+ // Tokens that make up words (whisper.cpp's full JSON, Parakeet): a token
610
+ // that starts with a space or "▁" starts a word, any other is joined to
611
+ // the word before it.
612
+ function wordsFromTokens(tokens, seg) {
613
+ const words = [];
614
+ for (const token of tokens) {
615
+ const raw = textOf(token.text);
616
+ if (!raw || /^\[_|^<\|/.test(raw)) continue;
617
+ const startsWord = /^[\s▁]/.test(raw) || !words.length || words[words.length - 1].seg !== seg;
618
+ const text = raw.replace(/^[\s▁]+/, "");
619
+ if (!text) continue;
620
+ if (startsWord) words.push({ text, start: token.start, end: token.end, seg, timed: Number.isFinite(token.start) });
621
+ else {
622
+ const last = words[words.length - 1];
623
+ last.text += text;
624
+ if (Number.isFinite(token.end)) last.end = token.end;
625
+ }
626
+ }
627
+ return words.map((w) => ({ ...w, text: cleanText(w.text) }));
628
+ }
629
+
630
+ // The shape of a JSON file, its words, and `entries`: how many entries the
631
+ // list of that shape held, which a file of a known shape whose list is
632
+ // empty, as a speech-to-text's answer for a clip with no speech is, has
633
+ // none of. Each list is counted before it is read (listed).
634
+ function detectJson(data) {
635
+ const shape = (found, list) => ({ ...found, entries: Array.isArray(list) ? list.length : 0 });
636
+ const read = tally();
637
+ if (data && typeof data === "object" && !Array.isArray(data)) {
638
+ if (typeof data.tool === "string" && /^easycaptionkit\b/.test(data.tool) && Array.isArray(data.words)) {
639
+ // A word the first run spread over its line keeps saying so, and so
640
+ // does one it placed between its neighbors, as the file gave it none.
641
+ const items = listed(data.words, read).filter((w) => w && typeof w === "object");
642
+ const words = timedItems(items).map((w, i) => ({ ...w, seg: Number.isInteger(items[i].seg) && items[i].seg >= 0 ? items[i].seg : -1, timed: w.timed && !items[i].spread && !items[i].placed, ...(items[i].placed ? { placed: true } : {}) }));
643
+ if (data.hard) placeAdded(words);
644
+ // The format the words first came in, which words.json names as its
645
+ // `from`: a demo's steps stay steps when their words.json is read
646
+ // again, in its own folder or in another.
647
+ const first = Object.keys(FORMATS).find((key) => key !== "easycaptionkit" && FORMATS[key] === data.from);
648
+ return shape({ format: "easycaptionkit", words, hard: Boolean(data.hard), fixed: handFixed(data), ...(first ? { first } : {}) }, data.words);
649
+ }
650
+ if (data.kind === "demo" && Array.isArray(data.steps)) return shape({ format: "steps", ...readSteps(data, listed(data.steps, read)) }, data.steps);
651
+ if (Array.isArray(data.transcription)) return shape({ format: "whispercpp", words: readWhisperCpp(listed(data.transcription, read), read) }, data.transcription);
652
+ const alternative = data.results && Array.isArray(data.results.channels) && data.results.channels[0] && (data.results.channels[0].alternatives || [])[0];
653
+ if (alternative && Array.isArray(alternative.words)) return shape({ format: "deepgram", words: timedItems(listed(alternative.words, read), { text: ["punctuated_word", "word"] }) }, alternative.words);
654
+ if (data.results && Array.isArray(data.results.items)) return shape({ format: "aws", words: readAws(listed(data.results.items, read)) }, data.results.items);
655
+ if (Array.isArray(data.results) && data.results.some((r) => r && Array.isArray(r.alternatives))) return shape({ format: "google", words: readGoogle(listed(data.results, read), read) }, data.results);
656
+ if (data.timestamp && Array.isArray(data.timestamp.word)) return shape({ format: "nemo", words: timedItems(listed(data.timestamp.word, read), { text: ["word", "text"] }) }, data.timestamp.word);
657
+ if (Array.isArray(data.sentences) && data.sentences.some((s) => s && Array.isArray(s.tokens))) {
658
+ listed(data.sentences, read);
659
+ return shape({ format: "parakeet", words: data.sentences.flatMap((s, i) => (entry(s) && Array.isArray(s.tokens) ? wordsFromTokens(listed(s.tokens, read).filter(entry).map((t) => ({ text: t.text, start: seconds(t.start), end: seconds(t.end) })), i) : [])) }, data.sentences);
660
+ }
661
+ if (Array.isArray(data.chunks) && data.chunks.some((c) => c && Array.isArray(c.timestamp))) return shape({ format: "transformers", ...readChunks(listed(data.chunks, read)) }, data.chunks);
662
+ if (Array.isArray(data.words) && data.words.some((w) => w && w.type !== undefined && w.text !== undefined)) {
663
+ // ElevenLabs: spacing entries between words, and sound tags.
664
+ const spoken = listed(data.words, read).filter((w) => w && w.type !== "spacing");
665
+ const tags = spoken.filter((w) => w.type === "audio_event").length;
666
+ return shape({ format: "elevenlabs", words: timedItems(spoken.filter((w) => w.type !== "audio_event"), { text: ["text"] }), notes: tags ? [`${tags} sound ${tags === 1 ? "tag" : "tags"} like (laughs) left out.`] : [] }, data.words);
667
+ }
668
+ if (Array.isArray(data.words) && (data.audio_duration !== undefined || data.audio_url !== undefined || data.acoustic_model !== undefined)) {
669
+ // AssemblyAI: milliseconds.
670
+ return shape({ format: "assemblyai", words: timedItems(listed(data.words, read), { text: ["text"], ms: true }) }, data.words);
671
+ }
672
+ if (Array.isArray(data.segments) && data.segments.some((s) => s && Array.isArray(s.words))) {
673
+ return shape({ format: "whisperx", words: wordsInSegments(data.segments, "words", {}, read) }, data.segments);
674
+ }
675
+ if (Array.isArray(data.words) && data.words.length) {
676
+ // OpenAI verbose_json with word times, and lists like it, within
677
+ // what a run takes before their text is matched.
678
+ const words = withinLimits(timedItems(listed(data.words, read)));
679
+ if (Array.isArray(data.segments)) markSegments(words, listed(data.segments, read));
680
+ const text = typeof data.text === "string" ? data.text : Array.isArray(data.segments) ? data.segments.map((s) => (s && typeof s.text === "string" ? s.text : "")).join(" ") : "";
681
+ if (text) punctuate(words, text);
682
+ return shape({ format: data.task === "transcribe" || data.segments ? "openai" : "words", words }, data.words);
683
+ }
684
+ if (Array.isArray(data.word_segments)) return shape({ format: "whisperx", words: timedItems(listed(data.word_segments, read)) }, data.word_segments);
685
+ if (Array.isArray(data.word)) return shape({ format: "nemo", words: timedItems(listed(data.word, read), { text: ["word", "text"] }) }, data.word);
686
+ if (Array.isArray(data.segments)) return shape({ format: data.task === "transcribe" ? "openai" : "segments", words: wordsInSegments(data.segments, "words", {}, read) }, data.segments);
687
+ // An empty list where a speech-to-text keeps its words, as ElevenLabs'
688
+ // answer for silence is: a list of words with none in it.
689
+ if (["words", "results", "chunks", "sentences"].some((key) => Array.isArray(data[key]) && !data[key].length)) return { format: "words", words: [], entries: 0 };
690
+ }
691
+ if (Array.isArray(data)) {
692
+ const items = listed(data, read).filter((d) => d && typeof d === "object");
693
+ // An empty list, which nothing says a speech-to-text wrote.
694
+ if (!data.length) return { format: "words", words: [], entries: 0, bare: true };
695
+ if (items.length && items.every((d) => first(d, ["text", "word", "content"]) !== undefined)) {
696
+ const timed = timedItems(items);
697
+ // A list of lines rather than words: spread each over its words,
698
+ // counted across all the lines.
699
+ const lines = timed.filter((w) => /\s/.test(w.text)).length > timed.length / 2;
700
+ if (lines) return shape({ format: "segments", words: timed.flatMap((w, i) => lineWords(w.text, w.start, w.end, i, read)) }, data);
701
+ return shape({ format: "words", words: timed }, data);
702
+ }
703
+ }
704
+ return null;
705
+ }
706
+
707
+ // A word added to a words.json by hand, without the segment the others
708
+ // carry, goes in the segment its time falls in, else in the one of the
709
+ // word before it in time, so it shows where it is said. A word with no
710
+ // time goes with the word before it in the list.
711
+ function placeAdded(words) {
712
+ const spans = new Map();
713
+ for (const w of words) {
714
+ if (w.seg < 0 || !Number.isFinite(w.start)) continue;
715
+ const s = spans.get(w.seg) || { start: Infinity, end: -Infinity };
716
+ spans.set(w.seg, { start: Math.min(s.start, w.start), end: Math.max(s.end, Number.isFinite(w.end) ? w.end : w.start) });
717
+ }
718
+ const placed = words.filter((w) => w.seg >= 0 && Number.isFinite(w.start)).sort((a, b) => a.start - b.start);
719
+ if (!placed.length) return;
720
+ const segments = [...spans].sort((a, b) => a[1].start - b[1].start);
721
+ // The last of `list` (sorted by start) that starts at or before t.
722
+ const lastBefore = (list, startOf, t) => {
723
+ let lo = 0;
724
+ let hi = list.length - 1;
725
+ let found = -1;
726
+ while (lo <= hi) {
727
+ const mid = (lo + hi) >> 1;
728
+ if (startOf(list[mid]) <= t) {
729
+ found = mid;
730
+ lo = mid + 1;
731
+ } else hi = mid - 1;
732
+ }
733
+ return found;
734
+ };
735
+ const added = [];
736
+ words.forEach((w, i) => {
737
+ if (w.seg >= 0) return;
738
+ if (!Number.isFinite(w.start)) return added.push(i);
739
+ const k = lastBefore(segments, (s) => s[1].start, w.start);
740
+ if (k >= 0 && w.start < segments[k][1].end) w.seg = segments[k][0];
741
+ else {
742
+ const p = lastBefore(placed, (x) => x.start, w.start);
743
+ w.seg = (p >= 0 ? placed[p] : placed[0]).seg;
744
+ }
745
+ });
746
+ for (const i of added) {
747
+ let j = i - 1;
748
+ while (j >= 0 && words[j].seg < 0) j--;
749
+ let after = i + 1;
750
+ while (after < words.length && words[after].seg < 0) after++;
751
+ words[i].seg = j >= 0 ? words[j].seg : after < words.length ? words[after].seg : 0;
752
+ }
753
+ }
754
+
755
+ // Letters, digits and the marks on them: what a word is spelled with.
756
+ const SPELLED = /[\p{L}\p{N}\p{M}]/u;
757
+ const OPENS = /[\p{Ps}\p{Pi}]/u;
758
+
759
+ // OpenAI sends its word times as bare words ("Hello"), and the punctuation
760
+ // and the letter case only in its text ("Hello."). Each word takes its
761
+ // spelling from the text, with the punctuation after it and any opening
762
+ // quote, bracket or sign right before it, so captions can end where the
763
+ // sentences do. A word the text does not have keeps its own spelling.
764
+ //
765
+ // The text is walked with positions in it, and its letters are found only
766
+ // as far as the words reach: a word is looked for no further than 80
767
+ // letters past the one before it, so a text far longer than its words
768
+ // costs no more than the part they use.
769
+ function punctuate(words, raw) {
770
+ const text = raw.normalize("NFC");
771
+ // The character that starts at a position of the text, and the one that
772
+ // ends there.
773
+ const at = (i) => String.fromCodePoint(text.codePointAt(i));
774
+ const before = (i) => {
775
+ const low = text.charCodeAt(i - 1);
776
+ if (low >= 0xdc00 && low <= 0xdfff && i >= 2) {
777
+ const high = text.charCodeAt(i - 2);
778
+ if (high >= 0xd800 && high <= 0xdbff) return text.slice(i - 2, i);
779
+ }
780
+ return text[i - 1];
781
+ };
782
+ const space = (c) => /\s/.test(c);
783
+ // Where each letter of the text starts, found as they are needed.
784
+ let starts = new Uint32Array(256);
785
+ let found = 0;
786
+ let scanned = 0;
787
+ const letters = (n) => {
788
+ while (found < n && scanned < text.length) {
789
+ const c = at(scanned);
790
+ if (SPELLED.test(c)) {
791
+ if (found === starts.length) {
792
+ const more = new Uint32Array(starts.length * 2);
793
+ more.set(starts);
794
+ starts = more;
795
+ }
796
+ starts[found++] = scanned;
797
+ }
798
+ scanned += c.length;
799
+ }
800
+ return found >= n;
801
+ };
802
+ const letter = (k) => at(starts[k]).toLowerCase();
803
+ const endOf = (k) => starts[k] + at(starts[k]).length;
804
+ let next = 0;
805
+ let after = 0;
806
+ for (const w of words) {
807
+ const want = Array.from(String(w.text).normalize("NFC")).filter((c) => SPELLED.test(c)).map((c) => c.toLowerCase());
808
+ if (!want.length) continue;
809
+ const spacedWord = !SPACELESS.test(want[0]);
810
+ let match = -1;
811
+ for (let s = next; s < next + 80 && letters(s + want.length); s++) {
812
+ if (!want.every((c, k) => letter(s + k) === c)) continue;
813
+ const from = starts[s];
814
+ const to = endOf(s + want.length - 1);
815
+ if (/\s/.test(text.slice(from, to))) continue;
816
+ // A word of a script written with spaces is a whole word of the
817
+ // text, not a piece of a longer one.
818
+ if (spacedWord && ((from > 0 && SPELLED.test(before(from))) || (to < text.length && SPELLED.test(at(to))))) continue;
819
+ match = s;
820
+ break;
821
+ }
822
+ if (match < 0) continue;
823
+ let from = starts[match];
824
+ let to = endOf(match + want.length - 1);
825
+ // What comes before it at the start of a word of the text, like the $
826
+ // of $20 or a bracket, else an opening quote or bracket alone, as in
827
+ // Japanese.
828
+ let lead = from;
829
+ while (lead > after && !space(before(lead)) && !SPELLED.test(before(lead))) lead -= before(lead).length;
830
+ if (lead === 0 || space(before(lead))) from = lead;
831
+ else while (from > after && OPENS.test(before(from))) from -= before(from).length;
832
+ let end = to;
833
+ while (end < text.length && !space(at(end)) && !SPELLED.test(at(end)) && !OPENS.test(at(end))) end += at(end).length;
834
+ // Not a hyphen or an apostrophe that joins it to the next word.
835
+ if (end === text.length || space(at(end)) || OPENS.test(at(end)) || !spacedWord || SPACELESS.test(at(end))) to = end;
836
+ w.text = cleanText(text.slice(from, to));
837
+ next = match + want.length;
838
+ after = to;
839
+ }
840
+ }
841
+
842
+ // Which segment of OpenAI's each word falls in, so captions prefer to
843
+ // break where Whisper did.
844
+ function markSegments(words, segments) {
845
+ const ends = segments.filter(entry).map((s) => seconds(s.end)).filter(Number.isFinite);
846
+ let k = 0;
847
+ for (const w of words) {
848
+ while (k < ends.length - 1 && w.start >= ends[k] - 0.01) k++;
849
+ w.seg = k;
850
+ }
851
+ }
852
+
853
+ // whisper.cpp's segments. With -ml 1 and no -sow, it cuts a segment at
854
+ // every token, so a word can come in pieces (" Makers", "MC", "P") and
855
+ // punctuation alone ("."): a segment that starts without a space goes on
856
+ // the word before it, as a token does, and so does closing punctuation.
857
+ // In a script written without spaces each piece stays a word. The words
858
+ // of segments read from their text are counted across all of them.
859
+ function readWhisperCpp(transcription, read = tally()) {
860
+ const words = [];
861
+ transcription.forEach((segment, i) => {
862
+ if (!segment || typeof segment !== "object") return;
863
+ const offsets = segment.offsets || {};
864
+ const stamps = segment.timestamps || {};
865
+ const start = Number.isFinite(offsets.from) ? offsets.from / 1000 : seconds(stamps.from);
866
+ const end = Number.isFinite(offsets.to) ? offsets.to / 1000 : seconds(stamps.to);
867
+ let pieces = [];
868
+ let raw = "";
869
+ if (Array.isArray(segment.tokens) && segment.tokens.some((t) => t && t.offsets)) {
870
+ const at = (offsets, key) => (entry(offsets) ? numberOf(offsets[key]) / 1000 : NaN);
871
+ const tokens = listed(segment.tokens, read).filter((t) => t && typeof t === "object").map((t) => ({ text: textOf(t.text), start: at(t.offsets, "from"), end: at(t.offsets, "to") }));
872
+ const first = tokens.find((t) => t.text && !/^\[_|^<\|/.test(t.text));
873
+ raw = first ? first.text : "";
874
+ pieces = wordsFromTokens(tokens, i);
875
+ } else if (Number.isFinite(start) && Number.isFinite(end)) {
876
+ raw = textOf(segment.text);
877
+ pieces = lineWords(raw, start, end, i, read);
878
+ }
879
+ const last = words[words.length - 1];
880
+ if (last && pieces.length && continues(last.text, raw, pieces[0].text)) {
881
+ last.text += pieces[0].text;
882
+ if (Number.isFinite(pieces[0].end)) last.end = Math.max(last.end, pieces[0].end);
883
+ pieces.shift();
884
+ }
885
+ words.push(...pieces);
886
+ });
887
+ return words;
888
+ }
889
+
890
+ // Whether a piece goes on the word before it: closing punctuation does,
891
+ // and so does a piece that starts without a space, unless one of the two
892
+ // is in a script written without spaces.
893
+ const CLOSES = /^[.,!?;:…。、,!?;:)\]}»”’›」』)]+$/u;
894
+ function continues(before, raw, piece) {
895
+ if (CLOSES.test(piece)) return true;
896
+ if (/^[\s▁]/.test(raw)) return false;
897
+ return !SPACELESS.test(Array.from(before).pop() || "") && !SPACELESS.test(Array.from(piece)[0] || "");
898
+ }
899
+
900
+ function readAws(items) {
901
+ const words = [];
902
+ for (const item of items.filter(entry)) {
903
+ const best = Array.isArray(item.alternatives) && entry(item.alternatives[0]) ? item.alternatives[0] : {};
904
+ const content = cleanText(best.content);
905
+ if (!content) continue;
906
+ if (item.type === "punctuation" && words.length) words[words.length - 1].text += content;
907
+ else words.push({ text: content, start: seconds(item.start_time), end: seconds(item.end_time), seg: -1, timed: true });
908
+ }
909
+ return words;
910
+ }
911
+
912
+ function readGoogle(results, read = tally()) {
913
+ const words = [];
914
+ results.forEach((result, i) => {
915
+ const best = entry(result) && Array.isArray(result.alternatives) ? result.alternatives[0] : null;
916
+ if (!entry(best) || !Array.isArray(best.words)) return;
917
+ words.push(...timedItems(listed(best.words, read), { text: ["word"], start: ["startTime", "startOffset", "start_time"], end: ["endTime", "endOffset", "end_time"], seg: i }));
918
+ });
919
+ return words;
920
+ }
921
+
922
+ function readChunks(chunks) {
923
+ const items = chunks.filter(entry).map((c) => ({ text: c.text, start: Array.isArray(c.timestamp) ? c.timestamp[0] : NaN, end: Array.isArray(c.timestamp) ? c.timestamp[1] : NaN }));
924
+ // The last chunk of a pipeline that ran out of audio can end with null.
925
+ items.forEach((item, i) => {
926
+ if (item.end === null || item.end === undefined) item.end = i + 1 < items.length ? items[i + 1].start : item.start;
927
+ });
928
+ const timed = timedItems(items);
929
+ const lines = timed.filter((w) => /\s/.test(w.text)).length > timed.length / 2;
930
+ const read = tally();
931
+ return lines ? { words: timed.flatMap((w, i) => lineWords(w.text, w.start, w.end, i, read)), hard: false } : { words: timed };
932
+ }
933
+
934
+ // easyscreenshotkit's steps: each step's note, or its label when no step
935
+ // has a note, shown while the step plays. A wait step without a note says
936
+ // nothing worth reading, so it shows nothing.
937
+ function readSteps(data, list = data.steps) {
938
+ const steps = list.filter((s) => s && Number.isFinite(numberOf(s.start))).sort((a, b) => numberOf(a.start) - numberOf(b.start));
939
+ const noted = steps.some((s) => cleanText(s.note));
940
+ const notes = [];
941
+ const shown = steps.filter((s) => (noted ? cleanText(s.note) : cleanText(s.label) && s.action !== "wait"));
942
+ if (!noted && shown.length) notes.push("steps.json has no step notes, so the step labels are shown. A note on each step (what to say about it) makes better captions.");
943
+ const video = entry(data.video) && numberOf(data.video.duration) > 0 ? { duration: numberOf(data.video.duration), file: textOf(data.video.file) } : null;
944
+ const words = [];
945
+ const read = tally();
946
+ shown.forEach((step, i) => {
947
+ const start = numberOf(step.start);
948
+ // What a step's note says holds while the steps after it play, until
949
+ // the next step with words, for at most ten seconds; a page opening
950
+ // takes no time of its own.
951
+ const next = shown[i + 1] ? numberOf(shown[i + 1].start) : video ? video.duration : Math.max(numberOf(step.end) || start, start + 3);
952
+ const end = Math.min(next, start + 10);
953
+ words.push(...lineWords(noted ? step.note : step.label, start, Math.max(start + 0.1, end), i, read));
954
+ });
955
+ return { words, hard: true, notes, video };
956
+ }
957
+
958
+ // ─── the entry point ──────────────────────────────────────────────────────
959
+
960
+ // The words of an hour of speech take about a megabyte; a file far bigger
961
+ // is something else, often the video given in its place.
962
+ const MAX_SIZE = 64 * 1024 * 1024;
963
+
964
+ // The words of a file's content, in whatever shape it is: { format,
965
+ // words, hard, notes, json }.
966
+ function readContent(content, ext, file, unknown) {
967
+ // The shape of an SRT is looked for within lines: a pattern that runs on
968
+ // across line breaks would scan a file of blank lines again from each.
969
+ if (ext === ".srt" || (ext !== ".json" && ext !== ".vtt" && /^\s*\d+[ \t]*\r?\n[ \t]*\d{1,2}:\d{2}:\d{2}[,.]\d{1,3}[ \t]*-->/.test(content))) return { format: "srt", ...readSrt(content) };
970
+ if (ext === ".vtt" || /^WEBVTT/.test(content)) {
971
+ try {
972
+ return { format: "vtt", ...readVtt(content) };
973
+ } catch (err) {
974
+ if (err.code === "EWORDS") throw err;
975
+ throw new Error(`Cannot read ${file}: ${err.message}`);
976
+ }
977
+ }
978
+ // A JSON text's lists and objects are counted before it is parsed, and a
979
+ // text of far more of them than words take, or nested far deeper, is
980
+ // refused. Only a text that starts as a list or an object has any: JSON
981
+ // reads anything else as one value, or stops at its first character.
982
+ if (/^\s*[{[]/.test(content)) {
983
+ const shape = jsonShape(content, { most: MOST_OPENED, deepest: DEEPEST });
984
+ if (shape.deepest > DEEPEST) throw tooDeep();
985
+ if (shape.over) throw tooManyItems();
986
+ }
987
+ let data;
988
+ try {
989
+ data = JSON.parse(content);
990
+ } catch (err) {
991
+ const json = /^\s*[{[]/.test(content);
992
+ if (!json && /^[ \t]*\d{1,2}:\d{2}:\d{2}[,.]\d{1,3}[ \t]*-->/m.test(content)) return { format: "srt", ...readSrt(content) };
993
+ // A JSON file says where it breaks, as a quote typed by hand can make it.
994
+ if (json || ext === ".json") throw new Error(`Cannot read ${file}: it is not valid JSON${jsonProblem(err, content)}`);
995
+ throw new Error(`Cannot read ${file}: it is not JSON, SRT or WebVTT. Give the words with their times: a speech-to-text JSON, an SRT or VTT file, or easyscreenshotkit's steps.json`);
996
+ }
997
+ const found = detectJson(data);
998
+ if (!found) throw unknown();
999
+ return { ...found, json: true };
1000
+ }
1001
+
1002
+ // Why the words file could not be read, as the error to give.
1003
+ function unread(err, file) {
1004
+ if (err.code === "EFBIG") return new Error(`${file} is ${sizeText(err, MAX_SIZE)}, too big to be a words file (the words of an hour of speech take about 1 MB). Check that it is the words and not the video`);
1005
+ if (err.code === "ENOENT") return new Error(`Cannot read ${file}: no such file`);
1006
+ if (err.code === "EISDIR") return new Error(`${file} is a folder; give the words file in it`);
1007
+ return new Error(`Cannot read ${file}: ${err.message}`);
1008
+ }
1009
+
1010
+ // The words of a file, as readWords reads them. A pipe or a device, as a
1011
+ // shell's <(...) or /dev/stdin is, is read without holding up the
1012
+ // process, so Ctrl-C or a kill still stops a run whose words have not
1013
+ // come yet: reading one at once would wait in the system, where no signal
1014
+ // gets through.
1015
+ async function loadWords(file) {
1016
+ let stat = null;
1017
+ try {
1018
+ stat = fs.statSync(file);
1019
+ } catch {
1020
+ // Not there, which readWords says.
1021
+ }
1022
+ if (!stat || stat.isFile() || stat.isDirectory()) return readWords(file);
1023
+ let content;
1024
+ try {
1025
+ content = await readAtMostLater(file, MAX_SIZE);
1026
+ } catch (err) {
1027
+ throw unread(err, file);
1028
+ }
1029
+ return readWords(file, content);
1030
+ }
1031
+
1032
+ // The words of a file, from the text `given` when it was read already.
1033
+ function readWords(file, given = null) {
1034
+ let content = given;
1035
+ if (content === null) {
1036
+ try {
1037
+ content = readAtMost(file, MAX_SIZE);
1038
+ } catch (err) {
1039
+ throw unread(err, file);
1040
+ }
1041
+ }
1042
+ content = content.replace(/^\ufeff/, "");
1043
+ const ext = path.extname(file).toLowerCase();
1044
+ // A JSON file in no shape easycaptionkit knows, or in a shape it took
1045
+ // for one it knows and found no words in the entries of, as a
1046
+ // Speechmatics file looks like Google's.
1047
+ const unknown = () => new Error(`Cannot read ${file}: no words with times in it. easycaptionkit reads ${Object.values(FORMATS).slice(1, -2).join(", ")}, and plain lists of { text, start, end }`);
1048
+ // An ASS file, as Aegisub writes it, yt-dlp's --convert-subs ass, or this
1049
+ // kit's own captions.ass, starts with its [Script Info].
1050
+ if (ext === ".ass" || ext === ".ssa" || /^\s*\[Script Info\]/i.test(content)) throw new Error(`Cannot read ${file}: easycaptionkit does not read ASS files as words. Give the SRT or VTT file, or the speech-to-text JSON, the ASS was made from`);
1051
+ let found;
1052
+ try {
1053
+ found = readContent(content, ext, file, unknown);
1054
+ withinLimits(found.words || []);
1055
+ } catch (err) {
1056
+ if (err.code === "EWORDS") throw new Error(`Cannot read ${file}: ${err.message}`);
1057
+ throw err;
1058
+ }
1059
+ const { words, untimed } = settle(found.words || [], { bySegment: Boolean(found.hard) });
1060
+ if (!words.length) {
1061
+ const spoken = found.json && !["easycaptionkit", "steps"].includes(found.format);
1062
+ if (spoken && found.entries > 0) throw unknown();
1063
+ // A list of a speech-to-text's shape with nothing in it, as one writes
1064
+ // for a clip with no speech.
1065
+ throw new Error(`${file} holds no words to caption${spoken && !found.bare ? ", as a speech-to-text model gives for a clip with no speech" : ""}`);
1066
+ }
1067
+ const notes = [...(found.notes || [])];
1068
+ if (untimed && ["openai", "whisperx", "deepgram", "elevenlabs", "assemblyai", "nemo", "parakeet", "aws", "google", "words", "transformers", "easycaptionkit"].includes(found.format)) {
1069
+ notes.push(`${untimed} ${untimed === 1 ? "word had" : "words had"} no time of ${untimed === 1 ? "its" : "their"} own and ${untimed === 1 ? "was" : "were"} placed between the words around ${untimed === 1 ? "it" : "them"}.`);
1070
+ }
1071
+ const timedOwn = words.filter((w) => w.timed).length;
1072
+ return {
1073
+ format: found.format,
1074
+ about: FORMATS[found.format] || found.format,
1075
+ // For a words.json of this kit, the format its words first came in,
1076
+ // where it names one; else "".
1077
+ firstFormat: found.first || "",
1078
+ words,
1079
+ // Segments never joined: an SRT's cues, a demo's steps.
1080
+ hard: Boolean(found.hard),
1081
+ byWord: timedOwn >= words.length * 0.9,
1082
+ // Words a person fixed in a words.json of this kit, which the
1083
+ // words.json a run writes from them keeps saying.
1084
+ fixed: Boolean(found.fixed),
1085
+ notes,
1086
+ video: found.video || null,
1087
+ };
1088
+ }
1089
+
1090
+ module.exports = { readWords, loadWords, seconds, spread, splitWords, settle, wordsSha256, handFixed, FORMATS, MOST_WORDS, LONGEST_WORD, MOST_TEXT, MOST_OPENED, DEEPEST };