@torrent-tv/proxy 2.73.0 → 2.74.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/CHANGELOG.md +1447 -1432
- package/CLAUDE.md +165 -160
- package/docs/container-architecture.md +192 -184
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +205 -205
- package/services/container/Container.js +354 -135
- package/services/container/MatroskaContainer.js +1155 -516
- package/services/container/Mp4Container.js +858 -392
- package/services/container/SubtitleFileContainer.js +323 -261
- package/services/controllers/SubtitleController.js +128 -127
- package/services/delivery-probe.js +64 -6
- package/services/hls-session-manager.js +32 -35
- package/services/language-detect.js +174 -228
- package/services/orchestrators/SubtitleOrchestrator.js +64 -5
- package/services/playback-planner.js +747 -747
- package/services/produced-index.js +300 -0
- package/services/torrent-worker/subtitle-cues.js +582 -618
- package/services/tracks/TextSubtitleTrack.js +287 -47
- package/services/tracks/index.js +14 -14
- package/test/delivery-probe.test.js +67 -0
- package/test/matroska-blocks.test.js +0 -0
- package/test/mp4-subtitles.test.js +173 -127
- package/test/produced-index.test.js +188 -0
- package/test/subtitle-cue-framing.test.js +200 -202
- package/test/subtitle-cue-source.test.js +104 -0
- package/test/subtitle-cue-walk.test.js +369 -0
- package/test/subtitle-defaults.test.js +97 -97
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +370 -370
- package/services/container-index/matroska-blocks.js +0 -202
- package/services/container-index/matroska-subtitles.js +0 -372
- package/services/container-index/mp4-subtitles.js +0 -404
- package/services/subtitle-convert.js +0 -144
- package/services/subtitle-defaults.js +0 -157
- package/services/tracks/subtitle-markup.js +0 -104
|
@@ -1,404 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The text subtitle tracks of an MP4, and where each cue's bytes are.
|
|
3
|
-
*
|
|
4
|
-
* The same rule as the Matroska side: nothing is extracted with ffmpeg and
|
|
5
|
-
* nothing is fetched for its own sake. Here it is cheaper still. Matroska hides
|
|
6
|
-
* its subtitle blocks inside clusters shared with the picture, so a cue costs
|
|
7
|
-
* whatever cluster holds it; an MP4 states every sample's offset and length in
|
|
8
|
-
* the sample table, so a cue costs its own bytes and nothing more — usually a
|
|
9
|
-
* few dozen of them.
|
|
10
|
-
*
|
|
11
|
-
* The tables, from ISO/IEC 14496-12:
|
|
12
|
-
*
|
|
13
|
-
* stsd — what the samples are (`tx3g` timed text, `wvtt` WebVTT, `stpp` TTML)
|
|
14
|
-
* stts — how long each sample lasts, run-length encoded (§8.6.1.2)
|
|
15
|
-
* stsz — how long each sample is, in bytes (§8.7.3)
|
|
16
|
-
* stsc — how samples are grouped into chunks (§8.7.4)
|
|
17
|
-
* stco / co64 — where each chunk begins in the file (§8.7.5)
|
|
18
|
-
*
|
|
19
|
-
* Together they give, for sample N: when it starts, how long it stays, and the
|
|
20
|
-
* exact byte range holding it. That is everything needed to show a cue without
|
|
21
|
-
* reading anything else.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
const HEADER_BYTES = 8;
|
|
25
|
-
const LARGE_SIZE_MARKER = 1;
|
|
26
|
-
const LARGE_HEADER_BYTES = 16;
|
|
27
|
-
const PROBE_BYTES = 64;
|
|
28
|
-
const MAX_MOOV_BYTES = 32 * 1024 * 1024;
|
|
29
|
-
|
|
30
|
-
/** Handlers that mean "this track is text on screen". */
|
|
31
|
-
const TEXT_HANDLERS = new Set(["text", "sbtl", "subt"]);
|
|
32
|
-
/**
|
|
33
|
-
* Every handler ffmpeg's mov demuxer turns into a SUBTITLE stream, whether or
|
|
34
|
-
* not this file can read it — `subp` is a DVD subpicture and `clcp` closed
|
|
35
|
-
* captions, both pictures or caption data rather than text. They are counted
|
|
36
|
-
* because `declaredIndex` has to equal ffmpeg's `0:s:N`, and a track left out
|
|
37
|
-
* of the count shifts every text track after it, which is the very defect
|
|
38
|
-
* `declaredIndex` exists to remove.
|
|
39
|
-
*/
|
|
40
|
-
const SUBTITLE_HANDLERS = new Set([...TEXT_HANDLERS, "subp", "clcp"]);
|
|
41
|
-
/** Sample formats this can turn into cues. `stpp` (TTML) is XML and is not one. */
|
|
42
|
-
const TEXT_FORMATS = new Set(["tx3g", "text", "wvtt"]);
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* One box header at `offset`, or null when the bytes do not hold one.
|
|
46
|
-
*
|
|
47
|
-
* @param {Buffer} buffer
|
|
48
|
-
* @param {number} offset
|
|
49
|
-
* @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
|
|
50
|
-
*/
|
|
51
|
-
function readBox(buffer, offset) {
|
|
52
|
-
if (offset + HEADER_BYTES > buffer.length) {
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
let size = buffer.readUInt32BE(offset);
|
|
56
|
-
const type = buffer.toString("latin1", offset + 4, offset + 8);
|
|
57
|
-
let headerBytes = HEADER_BYTES;
|
|
58
|
-
if (size === LARGE_SIZE_MARKER) {
|
|
59
|
-
if (offset + LARGE_HEADER_BYTES > buffer.length) {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
size = Number(buffer.readBigUInt64BE(offset + 8));
|
|
63
|
-
headerBytes = LARGE_HEADER_BYTES;
|
|
64
|
-
}
|
|
65
|
-
if (size < headerBytes) {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
return { type, size, dataOffset: offset + headerBytes, end: offset + size };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Every direct child of a range with the given type.
|
|
73
|
-
*
|
|
74
|
-
* @param {Buffer} buffer
|
|
75
|
-
* @param {number} start
|
|
76
|
-
* @param {number} end
|
|
77
|
-
* @param {string} type
|
|
78
|
-
* @returns {{ type: string, size: number, dataOffset: number, end: number }[]}
|
|
79
|
-
*/
|
|
80
|
-
function childrenOf(buffer, start, end, type) {
|
|
81
|
-
const found = [];
|
|
82
|
-
let at = start;
|
|
83
|
-
while (at < end) {
|
|
84
|
-
const box = readBox(buffer, at);
|
|
85
|
-
if (!box) {
|
|
86
|
-
break;
|
|
87
|
-
}
|
|
88
|
-
if (box.type === type) {
|
|
89
|
-
found.push(box);
|
|
90
|
-
}
|
|
91
|
-
at = box.end;
|
|
92
|
-
}
|
|
93
|
-
return found;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* The first child of a range with the given type, or null.
|
|
98
|
-
*
|
|
99
|
-
* @param {Buffer} buffer
|
|
100
|
-
* @param {number} start
|
|
101
|
-
* @param {number} end
|
|
102
|
-
* @param {string} type
|
|
103
|
-
* @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
|
|
104
|
-
*/
|
|
105
|
-
function childOf(buffer, start, end, type) {
|
|
106
|
-
return childrenOf(buffer, start, end, type)[0] ?? null;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Walk the top level of the file to find `moov`, reading only box headers.
|
|
111
|
-
*
|
|
112
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
113
|
-
* @param {number} fileSize
|
|
114
|
-
* @returns {Promise<{ offset: number, size: number } | null>}
|
|
115
|
-
*/
|
|
116
|
-
async function findMoov(readRange, fileSize) {
|
|
117
|
-
let at = 0;
|
|
118
|
-
while (at < fileSize) {
|
|
119
|
-
const probe = await readRange(at, Math.min(fileSize - 1, at + PROBE_BYTES - 1));
|
|
120
|
-
if (!probe || probe.length < HEADER_BYTES) {
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
const box = readBox(probe, 0);
|
|
124
|
-
if (!box) {
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
if (box.type === "moov") {
|
|
128
|
-
return { offset: at, size: box.size };
|
|
129
|
-
}
|
|
130
|
-
at += box.size;
|
|
131
|
-
}
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Sample durations, expanded from the run-length table.
|
|
137
|
-
*
|
|
138
|
-
* @param {Buffer} moov
|
|
139
|
-
* @param {{ dataOffset: number, end: number }} stts
|
|
140
|
-
* @param {number} total - How many samples the size table declares.
|
|
141
|
-
* @returns {number[]} Ticks each sample lasts.
|
|
142
|
-
*/
|
|
143
|
-
function sampleDurations(moov, stts, total) {
|
|
144
|
-
const durations = new Array(total).fill(0);
|
|
145
|
-
const entries = moov.readUInt32BE(stts.dataOffset + 4);
|
|
146
|
-
let at = stts.dataOffset + 8;
|
|
147
|
-
let sample = 0;
|
|
148
|
-
for (let entry = 0; entry < entries && at + 8 <= stts.end && sample < total; entry += 1, at += 8) {
|
|
149
|
-
const count = moov.readUInt32BE(at);
|
|
150
|
-
const delta = moov.readUInt32BE(at + 4);
|
|
151
|
-
for (let index = 0; index < count && sample < total; index += 1, sample += 1) {
|
|
152
|
-
durations[sample] = delta;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return durations;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Sample sizes, whether the table states one for all or one for each.
|
|
160
|
-
*
|
|
161
|
-
* @param {Buffer} moov
|
|
162
|
-
* @param {{ dataOffset: number, end: number }} stsz
|
|
163
|
-
* @returns {number[]}
|
|
164
|
-
*/
|
|
165
|
-
function sampleSizes(moov, stsz) {
|
|
166
|
-
const uniform = moov.readUInt32BE(stsz.dataOffset + 4);
|
|
167
|
-
const count = moov.readUInt32BE(stsz.dataOffset + 8);
|
|
168
|
-
if (uniform > 0) {
|
|
169
|
-
return new Array(count).fill(uniform);
|
|
170
|
-
}
|
|
171
|
-
const sizes = new Array(count).fill(0);
|
|
172
|
-
let at = stsz.dataOffset + 12;
|
|
173
|
-
for (let index = 0; index < count && at + 4 <= stsz.end; index += 1, at += 4) {
|
|
174
|
-
sizes[index] = moov.readUInt32BE(at);
|
|
175
|
-
}
|
|
176
|
-
return sizes;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Where every sample of a track begins in the file.
|
|
181
|
-
*
|
|
182
|
-
* The sample-to-chunk table says how many samples each run of chunks holds, and
|
|
183
|
-
* the chunk-offset table says where each chunk starts; a sample's own offset is
|
|
184
|
-
* its chunk's start plus the sizes of the samples before it in that chunk.
|
|
185
|
-
*
|
|
186
|
-
* @param {Buffer} moov
|
|
187
|
-
* @param {{ dataOffset: number, end: number }} stsc
|
|
188
|
-
* @param {number[]} chunkOffsets
|
|
189
|
-
* @param {number[]} sizes
|
|
190
|
-
* @returns {number[]}
|
|
191
|
-
*/
|
|
192
|
-
function sampleOffsets(moov, stsc, chunkOffsets, sizes) {
|
|
193
|
-
const offsets = new Array(sizes.length).fill(0);
|
|
194
|
-
const entries = moov.readUInt32BE(stsc.dataOffset + 4);
|
|
195
|
-
/** @type {{ firstChunk: number, perChunk: number }[]} */
|
|
196
|
-
const runs = [];
|
|
197
|
-
let at = stsc.dataOffset + 8;
|
|
198
|
-
for (let entry = 0; entry < entries && at + 12 <= stsc.end; entry += 1, at += 12) {
|
|
199
|
-
runs.push({ firstChunk: moov.readUInt32BE(at), perChunk: moov.readUInt32BE(at + 4) });
|
|
200
|
-
}
|
|
201
|
-
let sample = 0;
|
|
202
|
-
for (let run = 0; run < runs.length && sample < sizes.length; run += 1) {
|
|
203
|
-
const from = runs[run].firstChunk;
|
|
204
|
-
const to = run + 1 < runs.length ? runs[run + 1].firstChunk - 1 : chunkOffsets.length;
|
|
205
|
-
for (let chunk = from; chunk <= to && sample < sizes.length; chunk += 1) {
|
|
206
|
-
let inChunk = chunkOffsets[chunk - 1];
|
|
207
|
-
if (inChunk === undefined) {
|
|
208
|
-
break;
|
|
209
|
-
}
|
|
210
|
-
for (let index = 0; index < runs[run].perChunk && sample < sizes.length; index += 1, sample += 1) {
|
|
211
|
-
offsets[sample] = inChunk;
|
|
212
|
-
inChunk += sizes[sample];
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
return offsets;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* @typedef {object} Mp4SubtitleSample
|
|
221
|
-
* @property {number} startSeconds
|
|
222
|
-
* @property {number} endSeconds
|
|
223
|
-
* @property {number} offset - Where the sample's bytes are in the file.
|
|
224
|
-
* @property {number} size
|
|
225
|
-
*/
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* @typedef {object} Mp4SubtitleTrack
|
|
229
|
-
* @property {number} trackId
|
|
230
|
-
* @property {number} declaredIndex - Its position among ALL of the file's
|
|
231
|
-
* subtitle tracks, including the ones whose sample format this cannot turn
|
|
232
|
-
* into cues (`stpp` TTML). That is the number ffmpeg gives the same stream in
|
|
233
|
-
* `0:s:N`, which is the number the browser names; counting only the readable
|
|
234
|
-
* ones would shift every track after a TTML one.
|
|
235
|
-
* @property {string} format - `tx3g`, `text` or `wvtt`.
|
|
236
|
-
* @property {string} language - Three letters, as the file declares them.
|
|
237
|
-
* @property {Mp4SubtitleSample[]} samples - In time order.
|
|
238
|
-
*/
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* The text subtitle tracks of an MP4, with every cue's time and byte range.
|
|
242
|
-
*
|
|
243
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
244
|
-
* @param {number} fileSize
|
|
245
|
-
* @returns {Promise<{ tracks: Mp4SubtitleTrack[] } | null>}
|
|
246
|
-
*/
|
|
247
|
-
export async function readMp4SubtitlePlan(readRange, fileSize) {
|
|
248
|
-
const found = await findMoov(readRange, fileSize);
|
|
249
|
-
if (!found || found.size > MAX_MOOV_BYTES) {
|
|
250
|
-
return null;
|
|
251
|
-
}
|
|
252
|
-
const moov = await readRange(found.offset, Math.min(fileSize - 1, found.offset + found.size - 1));
|
|
253
|
-
if (!moov || moov.length < HEADER_BYTES) {
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
256
|
-
const moovBox = readBox(moov, 0);
|
|
257
|
-
if (!moovBox) {
|
|
258
|
-
return null;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** @type {Mp4SubtitleTrack[]} */
|
|
262
|
-
const tracks = [];
|
|
263
|
-
// Counts every subtitle track the file has, whether or not this can read it,
|
|
264
|
-
// so the number handed out matches ffmpeg's `0:s:N`. See `declaredIndex`.
|
|
265
|
-
let declaredIndex = -1;
|
|
266
|
-
for (const trak of childrenOf(moov, moovBox.dataOffset, moov.length, "trak")) {
|
|
267
|
-
const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
|
|
268
|
-
if (!mdia) {
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
const hdlr = childOf(moov, mdia.dataOffset, mdia.end, "hdlr");
|
|
272
|
-
const handler = hdlr ? moov.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) : "";
|
|
273
|
-
if (!SUBTITLE_HANDLERS.has(handler)) {
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
// Counted before the readability checks below, and before the handler is
|
|
277
|
-
// narrowed to the text ones: this number is the track's place in the file,
|
|
278
|
-
// not its place among the tracks this code can turn into cues.
|
|
279
|
-
declaredIndex += 1;
|
|
280
|
-
if (!TEXT_HANDLERS.has(handler)) {
|
|
281
|
-
continue;
|
|
282
|
-
}
|
|
283
|
-
const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
|
|
284
|
-
if (!mdhd) {
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
const version = moov[mdhd.dataOffset];
|
|
288
|
-
const timescale = moov.readUInt32BE(version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12);
|
|
289
|
-
if (!timescale) {
|
|
290
|
-
continue;
|
|
291
|
-
}
|
|
292
|
-
// The language is five bits per letter, offset from 0x60, packed into two
|
|
293
|
-
// bytes after the times (ISO/IEC 14496-12 §8.4.2.3).
|
|
294
|
-
const languageAt = version === 1 ? mdhd.dataOffset + 32 : mdhd.dataOffset + 20;
|
|
295
|
-
let language = "";
|
|
296
|
-
if (languageAt + 2 <= mdhd.end) {
|
|
297
|
-
const packed = moov.readUInt16BE(languageAt);
|
|
298
|
-
language = [10, 5, 0]
|
|
299
|
-
.map((shift) => String.fromCharCode(((packed >> shift) & 0x1f) + 0x60))
|
|
300
|
-
.join("")
|
|
301
|
-
.replace(/[^a-z]/g, "");
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
|
|
305
|
-
const trackId = tkhd
|
|
306
|
-
? moov.readUInt32BE(moov[tkhd.dataOffset] === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12)
|
|
307
|
-
: tracks.length + 1;
|
|
308
|
-
|
|
309
|
-
const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
|
|
310
|
-
const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
|
|
311
|
-
if (!stbl) {
|
|
312
|
-
continue;
|
|
313
|
-
}
|
|
314
|
-
const stsd = childOf(moov, stbl.dataOffset, stbl.end, "stsd");
|
|
315
|
-
const first = stsd && readBox(moov, stsd.dataOffset + 8);
|
|
316
|
-
const format = first ? first.type : "";
|
|
317
|
-
if (!TEXT_FORMATS.has(format)) {
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
const stts = childOf(moov, stbl.dataOffset, stbl.end, "stts");
|
|
321
|
-
const stsz = childOf(moov, stbl.dataOffset, stbl.end, "stsz");
|
|
322
|
-
const stsc = childOf(moov, stbl.dataOffset, stbl.end, "stsc");
|
|
323
|
-
const stco = childOf(moov, stbl.dataOffset, stbl.end, "stco");
|
|
324
|
-
const co64 = childOf(moov, stbl.dataOffset, stbl.end, "co64");
|
|
325
|
-
if (!stts || !stsz || !stsc || (!stco && !co64)) {
|
|
326
|
-
continue;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const sizes = sampleSizes(moov, stsz);
|
|
330
|
-
const durations = sampleDurations(moov, stts, sizes.length);
|
|
331
|
-
const chunkOffsets = [];
|
|
332
|
-
if (stco) {
|
|
333
|
-
const count = moov.readUInt32BE(stco.dataOffset + 4);
|
|
334
|
-
let at = stco.dataOffset + 8;
|
|
335
|
-
for (let index = 0; index < count && at + 4 <= stco.end; index += 1, at += 4) {
|
|
336
|
-
chunkOffsets.push(moov.readUInt32BE(at));
|
|
337
|
-
}
|
|
338
|
-
} else {
|
|
339
|
-
const count = moov.readUInt32BE(co64.dataOffset + 4);
|
|
340
|
-
let at = co64.dataOffset + 8;
|
|
341
|
-
for (let index = 0; index < count && at + 8 <= co64.end; index += 1, at += 8) {
|
|
342
|
-
chunkOffsets.push(Number(moov.readBigUInt64BE(at)));
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
const offsets = sampleOffsets(moov, stsc, chunkOffsets, sizes);
|
|
346
|
-
|
|
347
|
-
/** @type {Mp4SubtitleSample[]} */
|
|
348
|
-
const samples = [];
|
|
349
|
-
let ticks = 0;
|
|
350
|
-
for (let index = 0; index < sizes.length; index += 1) {
|
|
351
|
-
const start = ticks / timescale;
|
|
352
|
-
ticks += durations[index];
|
|
353
|
-
// An empty sample is a gap between cues, which the format uses to say
|
|
354
|
-
// "nothing on screen"; it is not a cue and would show as a blank line.
|
|
355
|
-
if (sizes[index] > 2) {
|
|
356
|
-
samples.push({
|
|
357
|
-
startSeconds: start,
|
|
358
|
-
endSeconds: ticks / timescale,
|
|
359
|
-
offset: offsets[index],
|
|
360
|
-
size: sizes[index]
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
tracks.push({ trackId, declaredIndex, format, language, samples });
|
|
365
|
-
}
|
|
366
|
-
return { tracks };
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* The text of one sample.
|
|
371
|
-
*
|
|
372
|
-
* `tx3g` is a two-byte length followed by UTF-8; anything after that is styling
|
|
373
|
-
* boxes, which this deliberately drops. `wvtt` is a sequence of boxes, and the
|
|
374
|
-
* text lives in the `payl` inside a `vttc`.
|
|
375
|
-
*
|
|
376
|
-
* @param {Buffer} bytes
|
|
377
|
-
* @param {string} format
|
|
378
|
-
* @returns {string}
|
|
379
|
-
*/
|
|
380
|
-
export function decodeSubtitleSample(bytes, format) {
|
|
381
|
-
if (format === "wvtt") {
|
|
382
|
-
let at = 0;
|
|
383
|
-
const parts = [];
|
|
384
|
-
while (at + HEADER_BYTES <= bytes.length) {
|
|
385
|
-
const box = readBox(bytes, at);
|
|
386
|
-
if (!box) {
|
|
387
|
-
break;
|
|
388
|
-
}
|
|
389
|
-
if (box.type === "vttc") {
|
|
390
|
-
const payl = childOf(bytes, box.dataOffset, Math.min(bytes.length, box.end), "payl");
|
|
391
|
-
if (payl) {
|
|
392
|
-
parts.push(bytes.toString("utf8", payl.dataOffset, Math.min(bytes.length, payl.end)));
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
at = box.end;
|
|
396
|
-
}
|
|
397
|
-
return parts.join("\n").trim();
|
|
398
|
-
}
|
|
399
|
-
if (bytes.length < 2) {
|
|
400
|
-
return "";
|
|
401
|
-
}
|
|
402
|
-
const length = bytes.readUInt16BE(0);
|
|
403
|
-
return bytes.toString("utf8", 2, Math.min(bytes.length, 2 + length)).trim();
|
|
404
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Subtitle conversion (proxy side).
|
|
3
|
-
*
|
|
4
|
-
* Decodes subtitle file bytes (encoding-aware) and converts SubRip (.srt) and
|
|
5
|
-
* ASS/SSA (.ass/.ssa) to WebVTT so the browser can attach them to a `<track>`
|
|
6
|
-
* without any client-side conversion. The proxy owns subtitle conversion so it
|
|
7
|
-
* can also run language detection where the full text is available.
|
|
8
|
-
*
|
|
9
|
-
* Reading a format's own framing is NOT here — it is `SubtitleFileContainer`
|
|
10
|
-
* for a file beside the film, and `MatroskaContainer` / `Mp4Container` for a
|
|
11
|
-
* track inside it. What is here is everything after that: a cue's missing end
|
|
12
|
-
* time, its codec's markup, and writing the WebVTT document. One writer, so a
|
|
13
|
-
* pushed cue and a pulled one cannot read differently.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { SubtitleFileContainer } from "./container/SubtitleFileContainer.js";
|
|
17
|
-
import { plainCueText } from "./tracks/subtitle-markup.js";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Decode subtitle bytes to text. Prefers UTF-8 (honouring a BOM); if the UTF-8
|
|
21
|
-
* decode yields many replacement characters the bytes are re-decoded as
|
|
22
|
-
* Windows-1251 (very common for Russian .srt files) — otherwise both display
|
|
23
|
-
* and language detection would see mojibake.
|
|
24
|
-
*
|
|
25
|
-
* @param {Buffer | Uint8Array} bytes
|
|
26
|
-
* @returns {string}
|
|
27
|
-
*/
|
|
28
|
-
export function decodeSubtitleBytes(bytes) {
|
|
29
|
-
const buf = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
30
|
-
// UTF-8 BOM → definitely UTF-8.
|
|
31
|
-
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
|
|
32
|
-
return new TextDecoder("utf-8").decode(buf);
|
|
33
|
-
}
|
|
34
|
-
const utf8 = new TextDecoder("utf-8").decode(buf);
|
|
35
|
-
const replacements = (utf8.match(/�/g) || []).length;
|
|
36
|
-
// >0.5% replacement chars ⇒ not valid UTF-8; try the common legacy Cyrillic
|
|
37
|
-
// codepage. TextDecoder supports windows-1251 with a full-ICU Node build.
|
|
38
|
-
if (replacements > Math.max(2, utf8.length * 0.005)) {
|
|
39
|
-
try {
|
|
40
|
-
return new TextDecoder("windows-1251").decode(buf);
|
|
41
|
-
} catch {
|
|
42
|
-
// Decoder unavailable — fall back to the UTF-8 attempt.
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return utf8;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Strip a leading UTF-8 BOM so it never leaks into the WEBVTT signature or first cue. */
|
|
49
|
-
function stripBom(text) {
|
|
50
|
-
return typeof text === "string" && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* One cue's start or end as WebVTT writes it: `hh:mm:ss.mmm`.
|
|
55
|
-
*
|
|
56
|
-
* @param {number} seconds
|
|
57
|
-
* @returns {string}
|
|
58
|
-
*/
|
|
59
|
-
export function vttTime(seconds) {
|
|
60
|
-
const safe = Math.max(0, Number(seconds) || 0);
|
|
61
|
-
const hours = Math.floor(safe / 3600);
|
|
62
|
-
const minutes = Math.floor((safe % 3600) / 60);
|
|
63
|
-
const rest = safe % 60;
|
|
64
|
-
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${rest.toFixed(3).padStart(6, "0")}`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Resolve what a cue is missing and take its codec's markup off, so what is
|
|
69
|
-
* left is what a player shows.
|
|
70
|
-
*
|
|
71
|
-
* The container's framing is NOT undone here — it is undone where the cue is
|
|
72
|
-
* read, by the container that framed it, which is the only place the framing is
|
|
73
|
-
* known. Until 2.72.1 this function tried to do both by counting commas, and
|
|
74
|
-
* on an embedded ASS track it showed every field of the dialogue row to the
|
|
75
|
-
* viewer.
|
|
76
|
-
*
|
|
77
|
-
* A cue with no duration — a Matroska SimpleBlock, which subtitles rarely use —
|
|
78
|
-
* is given the time until the next one IN THIS LIST, and the last such cue a
|
|
79
|
-
* few seconds. Not an invention about the film: it is what a player does with
|
|
80
|
-
* an open-ended cue, made explicit so every consumer agrees on it.
|
|
81
|
-
*
|
|
82
|
-
* @param {{ startSeconds: number, endSeconds?: number | null, text: string }[]} cues
|
|
83
|
-
* @param {string} codecId
|
|
84
|
-
* @returns {{ startSeconds: number, endSeconds: number, text: string }[]}
|
|
85
|
-
*/
|
|
86
|
-
export function finalizeCues(cues, codecId) {
|
|
87
|
-
const result = [];
|
|
88
|
-
(Array.isArray(cues) ? cues : []).forEach((cue, index) => {
|
|
89
|
-
const next = cues[index + 1];
|
|
90
|
-
const endSeconds = cue.endSeconds ?? (next ? next.startSeconds : cue.startSeconds + 4);
|
|
91
|
-
const text = plainCueText(cue.text, codecId);
|
|
92
|
-
if (!text) {
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
result.push({ startSeconds: cue.startSeconds, endSeconds, text });
|
|
96
|
-
});
|
|
97
|
-
return result;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* A WebVTT document from a list of cues — the one writer, used by every path
|
|
102
|
-
* that produces subtitles: a file beside the film, a track inside it, a pull
|
|
103
|
-
* and a push.
|
|
104
|
-
*
|
|
105
|
-
* @param {{ startSeconds: number, endSeconds?: number | null, text: string }[]} cues
|
|
106
|
-
* @param {string} codecId
|
|
107
|
-
* @returns {string}
|
|
108
|
-
*/
|
|
109
|
-
export function cuesToVtt(cues, codecId) {
|
|
110
|
-
const lines = ["WEBVTT", ""];
|
|
111
|
-
for (const cue of finalizeCues(cues, codecId)) {
|
|
112
|
-
lines.push(`${vttTime(cue.startSeconds)} --> ${vttTime(cue.endSeconds)}`);
|
|
113
|
-
lines.push(cue.text);
|
|
114
|
-
lines.push("");
|
|
115
|
-
}
|
|
116
|
-
return lines.join("\n");
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Convert subtitle text to WebVTT by file extension. Returns null for formats
|
|
121
|
-
* that cannot be converted in-place (image-based .sup, ambiguous .sub, .ttml).
|
|
122
|
-
*
|
|
123
|
-
* The reading is `SubtitleFileContainer`'s: the file states how its own cues
|
|
124
|
-
* are framed — SubRip by position, ASS by the `Format:` line of `[Events]` —
|
|
125
|
-
* and that is a fact about the file, not about this conversion.
|
|
126
|
-
*
|
|
127
|
-
* @param {string} text
|
|
128
|
-
* @param {string} ext - Lowercase extension including the dot, e.g. ".srt".
|
|
129
|
-
* @returns {string | null}
|
|
130
|
-
*/
|
|
131
|
-
export function convertSubtitleToVtt(text, ext) {
|
|
132
|
-
const clean = stripBom(text);
|
|
133
|
-
const extension = String(ext ?? "").toLowerCase();
|
|
134
|
-
if (extension === ".vtt" || extension === ".webvtt") {
|
|
135
|
-
// Already what a browser reads. Parsing it to write it back would drop its
|
|
136
|
-
// styles, its regions and its cue identifiers for nothing.
|
|
137
|
-
return clean.trimStart().startsWith("WEBVTT") ? clean : `WEBVTT\n\n${clean}`;
|
|
138
|
-
}
|
|
139
|
-
if (!SubtitleFileContainer.detect(extension)) {
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
const cues = new SubtitleFileContainer({ extension }).readCues(clean);
|
|
143
|
-
return cues === null ? null : cuesToVtt(cues, extension);
|
|
144
|
-
}
|