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,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 };
|