@torrent-tv/proxy 2.42.0 → 2.43.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
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
## 2.
|
|
1
|
+
## 2.43.0
|
|
2
|
+
|
|
3
|
+
- **New**: An MP4's text subtitles are read the same way, and more cheaply than Matroska's. Where a Matroska cue costs whatever cluster holds it — the picture around it included — an MP4 states every sample's own byte range in its sample table (ISO/IEC 14496-12 §8.6.1.2, §8.7.3-8.7.5), so a cue costs its own few dozen bytes and nothing else. The tables are read out of the `moov` the keyframe reader already fetches: `stts` for when each cue starts and how long it lasts, `stsz` for its length, `stsc` with `stco`/`co64` for where its bytes are. `tx3g` (3GPP timed text) and `wvtt` (WebVTT in MP4) are decoded; `stpp` (TTML) is XML and is deliberately left out rather than half-shown. An empty sample is the format's way of saying nothing is on screen and is not turned into a blank cue. Same rule as before: only samples whose bytes are already downloaded are read, so a cue never costs a request.
|
|
4
|
+
- **Chore**: The MP4 reader has its own tests over a file built byte by byte — the sample table walked into times and offsets, the gap sample dropped, a `wvtt` payload decoded, and a file with no text track answering with nothing.
|
|
5
|
+
|
|
6
|
+
## 2.42.0
|
|
2
7
|
|
|
3
8
|
- **New**: Embedded text subtitles are read out of the clusters the film is already downloading, and no longer extracted with ffmpeg. Measured 2026-08-19 on `Minions.and.Monsters.1080p.mkv`: the browser asked for a track, gave up at its own 60 s limit, and the proxy answered **752 seconds later** with 3040 bytes — because a subtitle stream is sparse and ffmpeg walks the whole container whatever range is asked of it. Measured twice more to be sure: `-ss 1200 -t 4` read to the end of the file and pulled the download from 2.7 % to 81 % of 6.5 GB, and `-copyts -ss 600 -to 604` took 154 s on a copy already 81 % local and still emitted the whole track. A subtitle block sits in the same cluster as the picture around it, so those clusters are in hand anyway: the cue points of the subtitle track name them, the blocks are read where every piece covering them is already downloaded, and nothing is requested from the swarm. **Cost: zero extra bytes**, and the cues for the part being watched are ready before the viewer reaches it — which is the rule this was held to, subtitles arriving like the picture or not at all. On the field file the plan reads in 3.8 s over the swarm and names all four tracks with their languages, and the cues come out with their real times (`118.41s → 125.71s «МАГИЯ ГОЛЛИВУДА»`). A file this cannot be read from falls back to the old extraction, unchanged. `S_TEXT/UTF8` needs no conversion; `S_TEXT/ASS` and `S_TEXT/SSA` have their dialogue fields stripped; image subtitles (PGS, VobSub) are deliberately not offered, since this path cannot show them.
|
|
4
9
|
- **Chore**: The Matroska block reader is its own module with its own tests (`services/container-index/matroska-blocks.js`): cluster time plus the block's own offset, the duration out of the block group, other tracks skipped, negative offsets placed correctly, and lacing stepped over rather than read as text.
|
package/package.json
CHANGED
|
@@ -0,0 +1,380 @@
|
|
|
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
|
+
/** Sample formats this can turn into cues. `stpp` (TTML) is XML and is not one. */
|
|
33
|
+
const TEXT_FORMATS = new Set(["tx3g", "text", "wvtt"]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One box header at `offset`, or null when the bytes do not hold one.
|
|
37
|
+
*
|
|
38
|
+
* @param {Buffer} buffer
|
|
39
|
+
* @param {number} offset
|
|
40
|
+
* @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
|
|
41
|
+
*/
|
|
42
|
+
function readBox(buffer, offset) {
|
|
43
|
+
if (offset + HEADER_BYTES > buffer.length) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
let size = buffer.readUInt32BE(offset);
|
|
47
|
+
const type = buffer.toString("latin1", offset + 4, offset + 8);
|
|
48
|
+
let headerBytes = HEADER_BYTES;
|
|
49
|
+
if (size === LARGE_SIZE_MARKER) {
|
|
50
|
+
if (offset + LARGE_HEADER_BYTES > buffer.length) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
size = Number(buffer.readBigUInt64BE(offset + 8));
|
|
54
|
+
headerBytes = LARGE_HEADER_BYTES;
|
|
55
|
+
}
|
|
56
|
+
if (size < headerBytes) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return { type, size, dataOffset: offset + headerBytes, end: offset + size };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every direct child of a range with the given type.
|
|
64
|
+
*
|
|
65
|
+
* @param {Buffer} buffer
|
|
66
|
+
* @param {number} start
|
|
67
|
+
* @param {number} end
|
|
68
|
+
* @param {string} type
|
|
69
|
+
* @returns {{ type: string, size: number, dataOffset: number, end: number }[]}
|
|
70
|
+
*/
|
|
71
|
+
function childrenOf(buffer, start, end, type) {
|
|
72
|
+
const found = [];
|
|
73
|
+
let at = start;
|
|
74
|
+
while (at < end) {
|
|
75
|
+
const box = readBox(buffer, at);
|
|
76
|
+
if (!box) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
if (box.type === type) {
|
|
80
|
+
found.push(box);
|
|
81
|
+
}
|
|
82
|
+
at = box.end;
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The first child of a range with the given type, or null.
|
|
89
|
+
*
|
|
90
|
+
* @param {Buffer} buffer
|
|
91
|
+
* @param {number} start
|
|
92
|
+
* @param {number} end
|
|
93
|
+
* @param {string} type
|
|
94
|
+
* @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
|
|
95
|
+
*/
|
|
96
|
+
function childOf(buffer, start, end, type) {
|
|
97
|
+
return childrenOf(buffer, start, end, type)[0] ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Walk the top level of the file to find `moov`, reading only box headers.
|
|
102
|
+
*
|
|
103
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
104
|
+
* @param {number} fileSize
|
|
105
|
+
* @returns {Promise<{ offset: number, size: number } | null>}
|
|
106
|
+
*/
|
|
107
|
+
async function findMoov(readRange, fileSize) {
|
|
108
|
+
let at = 0;
|
|
109
|
+
while (at < fileSize) {
|
|
110
|
+
const probe = await readRange(at, Math.min(fileSize - 1, at + PROBE_BYTES - 1));
|
|
111
|
+
if (!probe || probe.length < HEADER_BYTES) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const box = readBox(probe, 0);
|
|
115
|
+
if (!box) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (box.type === "moov") {
|
|
119
|
+
return { offset: at, size: box.size };
|
|
120
|
+
}
|
|
121
|
+
at += box.size;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Sample durations, expanded from the run-length table.
|
|
128
|
+
*
|
|
129
|
+
* @param {Buffer} moov
|
|
130
|
+
* @param {{ dataOffset: number, end: number }} stts
|
|
131
|
+
* @param {number} total - How many samples the size table declares.
|
|
132
|
+
* @returns {number[]} Ticks each sample lasts.
|
|
133
|
+
*/
|
|
134
|
+
function sampleDurations(moov, stts, total) {
|
|
135
|
+
const durations = new Array(total).fill(0);
|
|
136
|
+
const entries = moov.readUInt32BE(stts.dataOffset + 4);
|
|
137
|
+
let at = stts.dataOffset + 8;
|
|
138
|
+
let sample = 0;
|
|
139
|
+
for (let entry = 0; entry < entries && at + 8 <= stts.end && sample < total; entry += 1, at += 8) {
|
|
140
|
+
const count = moov.readUInt32BE(at);
|
|
141
|
+
const delta = moov.readUInt32BE(at + 4);
|
|
142
|
+
for (let index = 0; index < count && sample < total; index += 1, sample += 1) {
|
|
143
|
+
durations[sample] = delta;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return durations;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Sample sizes, whether the table states one for all or one for each.
|
|
151
|
+
*
|
|
152
|
+
* @param {Buffer} moov
|
|
153
|
+
* @param {{ dataOffset: number, end: number }} stsz
|
|
154
|
+
* @returns {number[]}
|
|
155
|
+
*/
|
|
156
|
+
function sampleSizes(moov, stsz) {
|
|
157
|
+
const uniform = moov.readUInt32BE(stsz.dataOffset + 4);
|
|
158
|
+
const count = moov.readUInt32BE(stsz.dataOffset + 8);
|
|
159
|
+
if (uniform > 0) {
|
|
160
|
+
return new Array(count).fill(uniform);
|
|
161
|
+
}
|
|
162
|
+
const sizes = new Array(count).fill(0);
|
|
163
|
+
let at = stsz.dataOffset + 12;
|
|
164
|
+
for (let index = 0; index < count && at + 4 <= stsz.end; index += 1, at += 4) {
|
|
165
|
+
sizes[index] = moov.readUInt32BE(at);
|
|
166
|
+
}
|
|
167
|
+
return sizes;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Where every sample of a track begins in the file.
|
|
172
|
+
*
|
|
173
|
+
* The sample-to-chunk table says how many samples each run of chunks holds, and
|
|
174
|
+
* the chunk-offset table says where each chunk starts; a sample's own offset is
|
|
175
|
+
* its chunk's start plus the sizes of the samples before it in that chunk.
|
|
176
|
+
*
|
|
177
|
+
* @param {Buffer} moov
|
|
178
|
+
* @param {{ dataOffset: number, end: number }} stsc
|
|
179
|
+
* @param {number[]} chunkOffsets
|
|
180
|
+
* @param {number[]} sizes
|
|
181
|
+
* @returns {number[]}
|
|
182
|
+
*/
|
|
183
|
+
function sampleOffsets(moov, stsc, chunkOffsets, sizes) {
|
|
184
|
+
const offsets = new Array(sizes.length).fill(0);
|
|
185
|
+
const entries = moov.readUInt32BE(stsc.dataOffset + 4);
|
|
186
|
+
/** @type {{ firstChunk: number, perChunk: number }[]} */
|
|
187
|
+
const runs = [];
|
|
188
|
+
let at = stsc.dataOffset + 8;
|
|
189
|
+
for (let entry = 0; entry < entries && at + 12 <= stsc.end; entry += 1, at += 12) {
|
|
190
|
+
runs.push({ firstChunk: moov.readUInt32BE(at), perChunk: moov.readUInt32BE(at + 4) });
|
|
191
|
+
}
|
|
192
|
+
let sample = 0;
|
|
193
|
+
for (let run = 0; run < runs.length && sample < sizes.length; run += 1) {
|
|
194
|
+
const from = runs[run].firstChunk;
|
|
195
|
+
const to = run + 1 < runs.length ? runs[run + 1].firstChunk - 1 : chunkOffsets.length;
|
|
196
|
+
for (let chunk = from; chunk <= to && sample < sizes.length; chunk += 1) {
|
|
197
|
+
let inChunk = chunkOffsets[chunk - 1];
|
|
198
|
+
if (inChunk === undefined) {
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
for (let index = 0; index < runs[run].perChunk && sample < sizes.length; index += 1, sample += 1) {
|
|
202
|
+
offsets[sample] = inChunk;
|
|
203
|
+
inChunk += sizes[sample];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return offsets;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @typedef {object} Mp4SubtitleSample
|
|
212
|
+
* @property {number} startSeconds
|
|
213
|
+
* @property {number} endSeconds
|
|
214
|
+
* @property {number} offset - Where the sample's bytes are in the file.
|
|
215
|
+
* @property {number} size
|
|
216
|
+
*/
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @typedef {object} Mp4SubtitleTrack
|
|
220
|
+
* @property {number} trackId
|
|
221
|
+
* @property {string} format - `tx3g`, `text` or `wvtt`.
|
|
222
|
+
* @property {string} language - Three letters, as the file declares them.
|
|
223
|
+
* @property {Mp4SubtitleSample[]} samples - In time order.
|
|
224
|
+
*/
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The text subtitle tracks of an MP4, with every cue's time and byte range.
|
|
228
|
+
*
|
|
229
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
230
|
+
* @param {number} fileSize
|
|
231
|
+
* @returns {Promise<{ tracks: Mp4SubtitleTrack[] } | null>}
|
|
232
|
+
*/
|
|
233
|
+
export async function readMp4SubtitlePlan(readRange, fileSize) {
|
|
234
|
+
const found = await findMoov(readRange, fileSize);
|
|
235
|
+
if (!found || found.size > MAX_MOOV_BYTES) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const moov = await readRange(found.offset, Math.min(fileSize - 1, found.offset + found.size - 1));
|
|
239
|
+
if (!moov || moov.length < HEADER_BYTES) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
const moovBox = readBox(moov, 0);
|
|
243
|
+
if (!moovBox) {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** @type {Mp4SubtitleTrack[]} */
|
|
248
|
+
const tracks = [];
|
|
249
|
+
for (const trak of childrenOf(moov, moovBox.dataOffset, moov.length, "trak")) {
|
|
250
|
+
const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
|
|
251
|
+
if (!mdia) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const hdlr = childOf(moov, mdia.dataOffset, mdia.end, "hdlr");
|
|
255
|
+
const handler = hdlr ? moov.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) : "";
|
|
256
|
+
if (!TEXT_HANDLERS.has(handler)) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
|
|
260
|
+
if (!mdhd) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
const version = moov[mdhd.dataOffset];
|
|
264
|
+
const timescale = moov.readUInt32BE(version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12);
|
|
265
|
+
if (!timescale) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
// The language is five bits per letter, offset from 0x60, packed into two
|
|
269
|
+
// bytes after the times (ISO/IEC 14496-12 §8.4.2.3).
|
|
270
|
+
const languageAt = version === 1 ? mdhd.dataOffset + 32 : mdhd.dataOffset + 20;
|
|
271
|
+
let language = "";
|
|
272
|
+
if (languageAt + 2 <= mdhd.end) {
|
|
273
|
+
const packed = moov.readUInt16BE(languageAt);
|
|
274
|
+
language = [10, 5, 0]
|
|
275
|
+
.map((shift) => String.fromCharCode(((packed >> shift) & 0x1f) + 0x60))
|
|
276
|
+
.join("")
|
|
277
|
+
.replace(/[^a-z]/g, "");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
|
|
281
|
+
const trackId = tkhd
|
|
282
|
+
? moov.readUInt32BE(moov[tkhd.dataOffset] === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12)
|
|
283
|
+
: tracks.length + 1;
|
|
284
|
+
|
|
285
|
+
const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
|
|
286
|
+
const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
|
|
287
|
+
if (!stbl) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const stsd = childOf(moov, stbl.dataOffset, stbl.end, "stsd");
|
|
291
|
+
const first = stsd && readBox(moov, stsd.dataOffset + 8);
|
|
292
|
+
const format = first ? first.type : "";
|
|
293
|
+
if (!TEXT_FORMATS.has(format)) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const stts = childOf(moov, stbl.dataOffset, stbl.end, "stts");
|
|
297
|
+
const stsz = childOf(moov, stbl.dataOffset, stbl.end, "stsz");
|
|
298
|
+
const stsc = childOf(moov, stbl.dataOffset, stbl.end, "stsc");
|
|
299
|
+
const stco = childOf(moov, stbl.dataOffset, stbl.end, "stco");
|
|
300
|
+
const co64 = childOf(moov, stbl.dataOffset, stbl.end, "co64");
|
|
301
|
+
if (!stts || !stsz || !stsc || (!stco && !co64)) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const sizes = sampleSizes(moov, stsz);
|
|
306
|
+
const durations = sampleDurations(moov, stts, sizes.length);
|
|
307
|
+
const chunkOffsets = [];
|
|
308
|
+
if (stco) {
|
|
309
|
+
const count = moov.readUInt32BE(stco.dataOffset + 4);
|
|
310
|
+
let at = stco.dataOffset + 8;
|
|
311
|
+
for (let index = 0; index < count && at + 4 <= stco.end; index += 1, at += 4) {
|
|
312
|
+
chunkOffsets.push(moov.readUInt32BE(at));
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
const count = moov.readUInt32BE(co64.dataOffset + 4);
|
|
316
|
+
let at = co64.dataOffset + 8;
|
|
317
|
+
for (let index = 0; index < count && at + 8 <= co64.end; index += 1, at += 8) {
|
|
318
|
+
chunkOffsets.push(Number(moov.readBigUInt64BE(at)));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const offsets = sampleOffsets(moov, stsc, chunkOffsets, sizes);
|
|
322
|
+
|
|
323
|
+
/** @type {Mp4SubtitleSample[]} */
|
|
324
|
+
const samples = [];
|
|
325
|
+
let ticks = 0;
|
|
326
|
+
for (let index = 0; index < sizes.length; index += 1) {
|
|
327
|
+
const start = ticks / timescale;
|
|
328
|
+
ticks += durations[index];
|
|
329
|
+
// An empty sample is a gap between cues, which the format uses to say
|
|
330
|
+
// "nothing on screen"; it is not a cue and would show as a blank line.
|
|
331
|
+
if (sizes[index] > 2) {
|
|
332
|
+
samples.push({
|
|
333
|
+
startSeconds: start,
|
|
334
|
+
endSeconds: ticks / timescale,
|
|
335
|
+
offset: offsets[index],
|
|
336
|
+
size: sizes[index]
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
tracks.push({ trackId, format, language, samples });
|
|
341
|
+
}
|
|
342
|
+
return { tracks };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The text of one sample.
|
|
347
|
+
*
|
|
348
|
+
* `tx3g` is a two-byte length followed by UTF-8; anything after that is styling
|
|
349
|
+
* boxes, which this deliberately drops. `wvtt` is a sequence of boxes, and the
|
|
350
|
+
* text lives in the `payl` inside a `vttc`.
|
|
351
|
+
*
|
|
352
|
+
* @param {Buffer} bytes
|
|
353
|
+
* @param {string} format
|
|
354
|
+
* @returns {string}
|
|
355
|
+
*/
|
|
356
|
+
export function decodeSubtitleSample(bytes, format) {
|
|
357
|
+
if (format === "wvtt") {
|
|
358
|
+
let at = 0;
|
|
359
|
+
const parts = [];
|
|
360
|
+
while (at + HEADER_BYTES <= bytes.length) {
|
|
361
|
+
const box = readBox(bytes, at);
|
|
362
|
+
if (!box) {
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (box.type === "vttc") {
|
|
366
|
+
const payl = childOf(bytes, box.dataOffset, Math.min(bytes.length, box.end), "payl");
|
|
367
|
+
if (payl) {
|
|
368
|
+
parts.push(bytes.toString("utf8", payl.dataOffset, Math.min(bytes.length, payl.end)));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
at = box.end;
|
|
372
|
+
}
|
|
373
|
+
return parts.join("\n").trim();
|
|
374
|
+
}
|
|
375
|
+
if (bytes.length < 2) {
|
|
376
|
+
return "";
|
|
377
|
+
}
|
|
378
|
+
const length = bytes.readUInt16BE(0);
|
|
379
|
+
return bytes.toString("utf8", 2, Math.min(bytes.length, 2 + length)).trim();
|
|
380
|
+
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { readSubtitlePlan, harvestCluster } from "../container-index/matroska-subtitles.js";
|
|
21
|
+
import { decodeSubtitleSample, readMp4SubtitlePlan } from "../container-index/mp4-subtitles.js";
|
|
21
22
|
import { iterateElements } from "../container-index/ebml-reader.js";
|
|
22
23
|
import { logger } from "../../utils/logger.js";
|
|
23
24
|
|
|
@@ -105,15 +106,42 @@ async function planFor(torrent, fileIndex, key) {
|
|
|
105
106
|
return state.plan;
|
|
106
107
|
}
|
|
107
108
|
const file = torrent?.files?.[fileIndex];
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
state.plan = { tracks: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
|
|
109
|
+
const empty = { tracks: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
|
|
110
|
+
if (!file) {
|
|
111
|
+
state.plan = empty;
|
|
112
112
|
return state.plan;
|
|
113
113
|
}
|
|
114
114
|
const readRange = async (start, end) => readHeld(file, start, Math.min(end, file.length - 1));
|
|
115
|
+
const name = String(file.name);
|
|
116
|
+
if (/\.mp4$/i.test(name) || /\.m4v$/i.test(name)) {
|
|
117
|
+
// An MP4 states every sample's byte range in its own table, so a cue costs
|
|
118
|
+
// its own few dozen bytes rather than the cluster around it. The samples
|
|
119
|
+
// are carried as `clusterPositions` of one byte range each, so the harvest
|
|
120
|
+
// treats both containers the same way.
|
|
121
|
+
const mp4 = await readMp4SubtitlePlan(readRange, file.length);
|
|
122
|
+
state.plan = mp4
|
|
123
|
+
? {
|
|
124
|
+
...empty,
|
|
125
|
+
tracks: mp4.tracks.map((track, order) => ({
|
|
126
|
+
trackNumber: track.trackId,
|
|
127
|
+
codecId: track.format,
|
|
128
|
+
language: track.language,
|
|
129
|
+
name: "",
|
|
130
|
+
isDefault: order === 0,
|
|
131
|
+
codecPrivate: "",
|
|
132
|
+
clusterPositions: [],
|
|
133
|
+
samples: track.samples
|
|
134
|
+
}))
|
|
135
|
+
}
|
|
136
|
+
: empty;
|
|
137
|
+
return state.plan;
|
|
138
|
+
}
|
|
139
|
+
if (!/\.mkv$/i.test(name) && !/\.webm$/i.test(name)) {
|
|
140
|
+
state.plan = empty;
|
|
141
|
+
return state.plan;
|
|
142
|
+
}
|
|
115
143
|
const plan = await readSubtitlePlan(readRange, file.length);
|
|
116
|
-
state.plan = plan ??
|
|
144
|
+
state.plan = plan ?? empty;
|
|
117
145
|
if (state.plan.tracks.length > 0) {
|
|
118
146
|
logger.info(
|
|
119
147
|
`subtitles: "${String(file.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s) — ` +
|
|
@@ -155,6 +183,36 @@ export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
|
|
|
155
183
|
state.cues.set(trackNumber, cues);
|
|
156
184
|
}
|
|
157
185
|
|
|
186
|
+
if (Array.isArray(track.samples)) {
|
|
187
|
+
// An MP4: every cue's bytes are stated, so only those bytes are read, and
|
|
188
|
+
// only where they are already downloaded.
|
|
189
|
+
for (const sample of track.samples) {
|
|
190
|
+
if (harvested.has(sample.offset)) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const last = Math.min(file.length - 1, sample.offset + sample.size - 1);
|
|
194
|
+
if (!rangeIsHeld(torrent, file, sample.offset, last)) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const bytes = await readHeld(file, sample.offset, last);
|
|
198
|
+
if (!bytes) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
harvested.add(sample.offset);
|
|
202
|
+
const text = decodeSubtitleSample(bytes, track.codecId);
|
|
203
|
+
if (text) {
|
|
204
|
+
cues.push({ startSeconds: sample.startSeconds, endSeconds: sample.endSeconds, text });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
cues.sort((left, right) => left.startSeconds - right.startSeconds);
|
|
208
|
+
return {
|
|
209
|
+
cues,
|
|
210
|
+
coveredClusters: harvested.size,
|
|
211
|
+
indexedClusters: track.samples.length,
|
|
212
|
+
track
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
158
216
|
for (const position of track.clusterPositions) {
|
|
159
217
|
if (harvested.has(position)) {
|
|
160
218
|
continue;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Reading an MP4's text subtitle track out of its sample table.
|
|
3
|
+
*
|
|
4
|
+
* The file is built here, so every answer is known in advance: two cues at
|
|
5
|
+
* stated times, in stated places, with an empty sample between them — the way
|
|
6
|
+
* the format says "nothing on screen just now".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { decodeSubtitleSample, readMp4SubtitlePlan } from "../services/container-index/mp4-subtitles.js";
|
|
12
|
+
|
|
13
|
+
function box(type, payload) {
|
|
14
|
+
const header = Buffer.alloc(8);
|
|
15
|
+
header.writeUInt32BE(payload.length + 8, 0);
|
|
16
|
+
header.write(type, 4, "latin1");
|
|
17
|
+
return Buffer.concat([header, payload]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fullBox(type, payload) {
|
|
21
|
+
return box(type, Buffer.concat([Buffer.from([0, 0, 0, 0]), payload]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function u32(...values) {
|
|
25
|
+
const buffer = Buffer.alloc(values.length * 4);
|
|
26
|
+
values.forEach((value, index) => buffer.writeUInt32BE(value, index * 4));
|
|
27
|
+
return buffer;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A `tx3g` sample: a two-byte length, then the text. */
|
|
31
|
+
function textSample(text) {
|
|
32
|
+
const bytes = Buffer.from(text, "utf8");
|
|
33
|
+
const length = Buffer.alloc(2);
|
|
34
|
+
length.writeUInt16BE(bytes.length, 0);
|
|
35
|
+
return Buffer.concat([length, bytes]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A file with one text track: three samples, the middle one empty.
|
|
40
|
+
*
|
|
41
|
+
* @returns {{ file: Buffer, samples: Buffer[] }}
|
|
42
|
+
*/
|
|
43
|
+
function buildFile() {
|
|
44
|
+
const samples = [textSample("First line"), Buffer.alloc(2), textSample("Second line")];
|
|
45
|
+
const timescale = 1000;
|
|
46
|
+
|
|
47
|
+
const mdhd = fullBox("mdhd", Buffer.concat([
|
|
48
|
+
u32(0, 0, timescale, 60_000),
|
|
49
|
+
// Language "eng", five bits a letter offset from 0x60, then a spare word.
|
|
50
|
+
Buffer.from([0x15, 0xc7, 0, 0])
|
|
51
|
+
]));
|
|
52
|
+
const hdlr = fullBox("hdlr", Buffer.concat([u32(0), Buffer.from("sbtl", "latin1"), u32(0, 0, 0)]));
|
|
53
|
+
const tkhd = fullBox("tkhd", u32(0, 0, 7, 0, 60_000));
|
|
54
|
+
|
|
55
|
+
const stsd = fullBox("stsd", Buffer.concat([u32(1), box("tx3g", Buffer.alloc(24))]));
|
|
56
|
+
// Two seconds a sample, so the cues sit at 0-2, 2-4 and 4-6 seconds.
|
|
57
|
+
const stts = fullBox("stts", Buffer.concat([u32(1), u32(3, 2000)]));
|
|
58
|
+
const stsz = fullBox("stsz", Buffer.concat([u32(0, 3), u32(...samples.map((s) => s.length))]));
|
|
59
|
+
const stsc = fullBox("stsc", Buffer.concat([u32(1), u32(1, 3, 1)]));
|
|
60
|
+
|
|
61
|
+
const stbl = box("stbl", Buffer.concat([stsd, stts, stsz, stsc, fullBox("stco", Buffer.concat([u32(1), u32(0)]))]));
|
|
62
|
+
const minf = box("minf", stbl);
|
|
63
|
+
const mdia = box("mdia", Buffer.concat([mdhd, hdlr, minf]));
|
|
64
|
+
const trak = box("trak", Buffer.concat([tkhd, mdia]));
|
|
65
|
+
const moovDraft = box("moov", trak);
|
|
66
|
+
|
|
67
|
+
// The samples sit after moov, so the chunk offset is known only now. Its
|
|
68
|
+
// length does not change when the placeholder becomes the real value.
|
|
69
|
+
const mdatStart = moovDraft.length + 8;
|
|
70
|
+
const stcoReal = fullBox("stco", Buffer.concat([u32(1), u32(mdatStart)]));
|
|
71
|
+
const stblReal = box("stbl", Buffer.concat([stsd, stts, stsz, stsc, stcoReal]));
|
|
72
|
+
const moov = box("moov", box("trak", Buffer.concat([
|
|
73
|
+
tkhd,
|
|
74
|
+
box("mdia", Buffer.concat([mdhd, hdlr, box("minf", stblReal)]))
|
|
75
|
+
])));
|
|
76
|
+
const mdat = box("mdat", Buffer.concat(samples));
|
|
77
|
+
return { file: Buffer.concat([moov, mdat]), samples };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readerOver(file) {
|
|
81
|
+
return async (start, end) => {
|
|
82
|
+
const last = Math.min(end, file.length - 1);
|
|
83
|
+
return start > last ? null : file.subarray(start, last + 1);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test("a text track's cues are found with their times and their byte ranges", async () => {
|
|
88
|
+
const { file, samples } = buildFile();
|
|
89
|
+
|
|
90
|
+
const plan = await readMp4SubtitlePlan(readerOver(file), file.length);
|
|
91
|
+
|
|
92
|
+
assert.equal(plan.tracks.length, 1);
|
|
93
|
+
const track = plan.tracks[0];
|
|
94
|
+
assert.equal(track.format, "tx3g");
|
|
95
|
+
assert.equal(track.language, "eng");
|
|
96
|
+
assert.equal(track.samples.length, 2, "the empty sample is a gap, not a cue");
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
track.samples.map((sample) => [sample.startSeconds, sample.endSeconds]),
|
|
99
|
+
[[0, 2], [4, 6]],
|
|
100
|
+
"times come from the duration table, and the gap keeps its place in it"
|
|
101
|
+
);
|
|
102
|
+
// The bytes the plan points at are the ones that hold the text.
|
|
103
|
+
const first = file.subarray(track.samples[0].offset, track.samples[0].offset + track.samples[0].size);
|
|
104
|
+
assert.equal(decodeSubtitleSample(first, "tx3g"), "First line");
|
|
105
|
+
const second = file.subarray(track.samples[1].offset, track.samples[1].offset + track.samples[1].size);
|
|
106
|
+
assert.equal(decodeSubtitleSample(second, "tx3g"), "Second line");
|
|
107
|
+
assert.equal(second.length, samples[2].length);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a file with no text track offers nothing", async () => {
|
|
111
|
+
const moov = box("moov", box("trak", box("mdia", Buffer.concat([
|
|
112
|
+
fullBox("mdhd", Buffer.concat([u32(0, 0, 1000, 100), Buffer.from([0, 0, 0, 0])])),
|
|
113
|
+
fullBox("hdlr", Buffer.concat([u32(0), Buffer.from("vide", "latin1"), u32(0, 0, 0)]))
|
|
114
|
+
]))));
|
|
115
|
+
const file = Buffer.concat([moov, box("mdat", Buffer.alloc(4))]);
|
|
116
|
+
|
|
117
|
+
const plan = await readMp4SubtitlePlan(readerOver(file), file.length);
|
|
118
|
+
|
|
119
|
+
assert.deepEqual(plan.tracks, []);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a WebVTT sample gives up the text inside its payload box", () => {
|
|
123
|
+
const payl = box("payl", Buffer.from("Hello there", "utf8"));
|
|
124
|
+
const sample = box("vttc", payl);
|
|
125
|
+
|
|
126
|
+
assert.equal(decodeSubtitleSample(sample, "wvtt"), "Hello there");
|
|
127
|
+
});
|