@torrent-tv/proxy 2.41.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 +10 -0
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +133 -0
- package/services/container-index/matroska-blocks.js +202 -0
- package/services/container-index/matroska-subtitles.js +262 -0
- package/services/container-index/mp4-subtitles.js +380 -0
- package/services/torrent-worker/client.js +20 -0
- package/services/torrent-worker/pool-adapter.js +32 -0
- package/services/torrent-worker/protocol.js +4 -0
- package/services/torrent-worker/subtitle-cues.js +291 -0
- package/services/torrent-worker/worker.js +19 -0
- package/test/matroska-blocks.test.js +0 -0
- package/test/mp4-subtitles.test.js +127 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Subtitle cues gathered from the clusters a viewer has already brought
|
|
3
|
+
* in, never from clusters they have not.
|
|
4
|
+
*
|
|
5
|
+
* The rule this file exists to keep (stated by the user 2026-08-20): subtitles
|
|
6
|
+
* arrive the way the picture does, or they are not offered. So nothing here
|
|
7
|
+
* requests a byte. It looks at what the torrent already holds, reads the
|
|
8
|
+
* clusters inside it, and returns what it found; the region the viewer is
|
|
9
|
+
* watching is downloaded before they reach it, so its cues are ready before
|
|
10
|
+
* they are needed. A region nobody has watched has no cues, and that is
|
|
11
|
+
* correct — there is nobody to show them to.
|
|
12
|
+
*
|
|
13
|
+
* Why not ffmpeg: measured 2026-08-19, extracting one subtitle track of
|
|
14
|
+
* `Minions.and.Monsters.1080p.mkv` took **752 seconds** and pulled the download
|
|
15
|
+
* from 2.7 % to 81 % of a 6.5 GB film, because a subtitle stream is sparse and
|
|
16
|
+
* the demuxer walks the container to the end whatever range is asked of it.
|
|
17
|
+
* Reading the clusters costs nothing extra at all.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readSubtitlePlan, harvestCluster } from "../container-index/matroska-subtitles.js";
|
|
21
|
+
import { decodeSubtitleSample, readMp4SubtitlePlan } from "../container-index/mp4-subtitles.js";
|
|
22
|
+
import { iterateElements } from "../container-index/ebml-reader.js";
|
|
23
|
+
import { logger } from "../../utils/logger.js";
|
|
24
|
+
|
|
25
|
+
/** Enough to read any cluster's own element header. */
|
|
26
|
+
const CLUSTER_HEADER_PROBE = 64;
|
|
27
|
+
/**
|
|
28
|
+
* The largest cluster this will read whole. Real muxers write clusters of a few
|
|
29
|
+
* megabytes; anything past this is not a cluster boundary we recognised and
|
|
30
|
+
* reading it would be a large read for nothing.
|
|
31
|
+
*/
|
|
32
|
+
const MAX_CLUSTER_BYTES = 32 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
/** @type {Map<string, { plan: object | null, harvested: Map<number, Set<number>>, cues: Map<number, object[]> }>} */
|
|
35
|
+
const byFile = new Map();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether every piece covering a byte range is already downloaded.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} torrent
|
|
41
|
+
* @param {object} file
|
|
42
|
+
* @param {number} start - Offset within the FILE.
|
|
43
|
+
* @param {number} end - Inclusive.
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
function rangeIsHeld(torrent, file, start, end) {
|
|
47
|
+
const pieceLength = Number(torrent?.pieceLength);
|
|
48
|
+
const offset = Number(file?.offset) || 0;
|
|
49
|
+
if (!Number.isFinite(pieceLength) || pieceLength <= 0 || !torrent?.bitfield) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const first = Math.floor((offset + start) / pieceLength);
|
|
53
|
+
const last = Math.floor((offset + end) / pieceLength);
|
|
54
|
+
for (let index = first; index <= last; index += 1) {
|
|
55
|
+
if (!torrent.bitfield.get(index)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read a byte range of a file straight from the store, without asking the swarm
|
|
64
|
+
* for anything.
|
|
65
|
+
*
|
|
66
|
+
* @param {object} file
|
|
67
|
+
* @param {number} start
|
|
68
|
+
* @param {number} end - Inclusive.
|
|
69
|
+
* @returns {Promise<Buffer | null>}
|
|
70
|
+
*/
|
|
71
|
+
function readHeld(file, start, end) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const chunks = [];
|
|
74
|
+
let stream;
|
|
75
|
+
try {
|
|
76
|
+
stream = file.createReadStream({ start, end });
|
|
77
|
+
} catch {
|
|
78
|
+
resolve(null);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
stream.on("data", (chunk) => chunks.push(chunk));
|
|
82
|
+
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
|
83
|
+
stream.on("error", () => resolve(null));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The subtitle tracks of a file, read once and kept.
|
|
89
|
+
*
|
|
90
|
+
* The head and the Cues table are two short reads, and they ARE fetched if
|
|
91
|
+
* missing — they are kilobytes, they are needed before anything can be offered,
|
|
92
|
+
* and the codec probe has already pulled the head for every file that plays.
|
|
93
|
+
*
|
|
94
|
+
* @param {object} torrent
|
|
95
|
+
* @param {number} fileIndex
|
|
96
|
+
* @param {string} key - `sourceKey:fileIndex`.
|
|
97
|
+
* @returns {Promise<object | null>}
|
|
98
|
+
*/
|
|
99
|
+
async function planFor(torrent, fileIndex, key) {
|
|
100
|
+
let state = byFile.get(key);
|
|
101
|
+
if (!state) {
|
|
102
|
+
state = { plan: null, harvested: new Map(), cues: new Map() };
|
|
103
|
+
byFile.set(key, state);
|
|
104
|
+
}
|
|
105
|
+
if (state.plan !== null) {
|
|
106
|
+
return state.plan;
|
|
107
|
+
}
|
|
108
|
+
const file = torrent?.files?.[fileIndex];
|
|
109
|
+
const empty = { tracks: [], secondsPerTick: 0.001, segmentDataOffset: 0 };
|
|
110
|
+
if (!file) {
|
|
111
|
+
state.plan = empty;
|
|
112
|
+
return state.plan;
|
|
113
|
+
}
|
|
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
|
+
}
|
|
143
|
+
const plan = await readSubtitlePlan(readRange, file.length);
|
|
144
|
+
state.plan = plan ?? empty;
|
|
145
|
+
if (state.plan.tracks.length > 0) {
|
|
146
|
+
logger.info(
|
|
147
|
+
`subtitles: "${String(file.name).slice(0, 40)}" has ${state.plan.tracks.length} text track(s) — ` +
|
|
148
|
+
state.plan.tracks
|
|
149
|
+
.map((track) => `${track.trackNumber}:${track.language || "?"}${track.name ? `/${track.name}` : ""}` +
|
|
150
|
+
`(${track.clusterPositions.length} indexed)`)
|
|
151
|
+
.join(" ")
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return state.plan;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Every cue of one track that can be read from what is already downloaded.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} torrent
|
|
161
|
+
* @param {number} fileIndex
|
|
162
|
+
* @param {string} sourceKey
|
|
163
|
+
* @param {number} trackNumber
|
|
164
|
+
* @returns {Promise<{ cues: object[], coveredClusters: number, indexedClusters: number, track: object | null }>}
|
|
165
|
+
*/
|
|
166
|
+
export async function cuesHeldFor(torrent, fileIndex, sourceKey, trackNumber) {
|
|
167
|
+
const key = `${sourceKey}:${fileIndex}`;
|
|
168
|
+
const plan = await planFor(torrent, fileIndex, key);
|
|
169
|
+
const state = byFile.get(key);
|
|
170
|
+
const track = plan?.tracks?.find((candidate) => candidate.trackNumber === trackNumber) ?? null;
|
|
171
|
+
if (!track) {
|
|
172
|
+
return { cues: [], coveredClusters: 0, indexedClusters: 0, track: null };
|
|
173
|
+
}
|
|
174
|
+
const file = torrent.files[fileIndex];
|
|
175
|
+
let harvested = state.harvested.get(trackNumber);
|
|
176
|
+
if (!harvested) {
|
|
177
|
+
harvested = new Set();
|
|
178
|
+
state.harvested.set(trackNumber, harvested);
|
|
179
|
+
}
|
|
180
|
+
let cues = state.cues.get(trackNumber);
|
|
181
|
+
if (!cues) {
|
|
182
|
+
cues = [];
|
|
183
|
+
state.cues.set(trackNumber, cues);
|
|
184
|
+
}
|
|
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
|
+
|
|
216
|
+
for (const position of track.clusterPositions) {
|
|
217
|
+
if (harvested.has(position)) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// The header first: it says how long the cluster is, and a cluster whose
|
|
221
|
+
// bytes are not all here is left for the next time round.
|
|
222
|
+
if (!rangeIsHeld(torrent, file, position, Math.min(file.length - 1, position + CLUSTER_HEADER_PROBE - 1))) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const probe = await readHeld(file, position, Math.min(file.length - 1, position + CLUSTER_HEADER_PROBE - 1));
|
|
226
|
+
const header = probe && [...iterateElements(probe, 0, probe.length)][0];
|
|
227
|
+
if (!header || header.size <= 0 || header.size > MAX_CLUSTER_BYTES) {
|
|
228
|
+
harvested.add(position); // not a cluster we can read; do not look again
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const last = Math.min(file.length - 1, position + header.dataOffset + header.size - 1);
|
|
232
|
+
if (!rangeIsHeld(torrent, file, position, last)) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const bytes = await readHeld(file, position, last);
|
|
236
|
+
if (!bytes) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
harvested.add(position);
|
|
240
|
+
for (const cue of harvestCluster(bytes, trackNumber, plan.secondsPerTick)) {
|
|
241
|
+
cues.push(cue);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
cues.sort((left, right) => left.startSeconds - right.startSeconds);
|
|
245
|
+
return {
|
|
246
|
+
cues,
|
|
247
|
+
coveredClusters: harvested.size,
|
|
248
|
+
indexedClusters: track.clusterPositions.length,
|
|
249
|
+
track
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The text subtitle tracks of a file, for the menu the viewer sees.
|
|
255
|
+
*
|
|
256
|
+
* @param {object} torrent
|
|
257
|
+
* @param {number} fileIndex
|
|
258
|
+
* @param {string} sourceKey
|
|
259
|
+
* @returns {Promise<object[]>}
|
|
260
|
+
*/
|
|
261
|
+
export async function subtitleTracksOf(torrent, fileIndex, sourceKey) {
|
|
262
|
+
const plan = await planFor(torrent, fileIndex, `${sourceKey}:${fileIndex}`);
|
|
263
|
+
return (plan?.tracks ?? []).map((track) => ({
|
|
264
|
+
trackNumber: track.trackNumber,
|
|
265
|
+
codecId: track.codecId,
|
|
266
|
+
language: track.language,
|
|
267
|
+
name: track.name,
|
|
268
|
+
isDefault: track.isDefault,
|
|
269
|
+
indexedClusters: track.clusterPositions.length
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Forget a file's cues — the torrent is gone, and holding them would keep the
|
|
275
|
+
* text of a film nobody is watching.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} sourceKey
|
|
278
|
+
* @param {number} [fileIndex]
|
|
279
|
+
* @returns {void}
|
|
280
|
+
*/
|
|
281
|
+
export function forgetSubtitles(sourceKey, fileIndex) {
|
|
282
|
+
if (fileIndex === undefined) {
|
|
283
|
+
for (const key of [...byFile.keys()]) {
|
|
284
|
+
if (key.startsWith(`${sourceKey}:`)) {
|
|
285
|
+
byFile.delete(key);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
byFile.delete(`${sourceKey}:${fileIndex}`);
|
|
291
|
+
}
|
|
@@ -27,6 +27,7 @@ import { parentPort, workerData } from "node:worker_threads";
|
|
|
27
27
|
import { createSendStream } from "./channel.js";
|
|
28
28
|
import { createFileClaims } from "./file-claims.js";
|
|
29
29
|
import { readFragments, supplyFiguresFor } from "./piece-reader.js";
|
|
30
|
+
import { cuesHeldFor, subtitleTracksOf } from "./subtitle-cues.js";
|
|
30
31
|
import { Command, Event } from "./protocol.js";
|
|
31
32
|
|
|
32
33
|
// Imported dynamically, and that is load-bearing: static imports are RESOLVED
|
|
@@ -356,6 +357,24 @@ async function runCommand(command, params, id) {
|
|
|
356
357
|
return { downloaded, uploaded };
|
|
357
358
|
}
|
|
358
359
|
|
|
360
|
+
case Command.SUBTITLE_TRACKS: {
|
|
361
|
+
const torrent = await requireTorrent(params.sourceKey);
|
|
362
|
+
return { tracks: await subtitleTracksOf(torrent, params.fileIndex, params.sourceKey) };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
case Command.SUBTITLE_CUES: {
|
|
366
|
+
const torrent = await requireTorrent(params.sourceKey);
|
|
367
|
+
const held = await cuesHeldFor(torrent, params.fileIndex, params.sourceKey, params.trackNumber);
|
|
368
|
+
return {
|
|
369
|
+
cues: held.cues,
|
|
370
|
+
coveredClusters: held.coveredClusters,
|
|
371
|
+
indexedClusters: held.indexedClusters,
|
|
372
|
+
codecId: held.track?.codecId ?? "",
|
|
373
|
+
codecPrivate: held.track?.codecPrivate ?? "",
|
|
374
|
+
language: held.track?.language ?? ""
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
359
378
|
case Command.FILE_STATS: {
|
|
360
379
|
const torrent = await requireTorrent(params.sourceKey);
|
|
361
380
|
const stats = pool.getFileStats(torrent, params.fileIndex, {
|
|
Binary file
|
|
@@ -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
|
+
});
|