@torrent-tv/proxy 2.73.1 → 2.74.1

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +1453 -1437
  2. package/CLAUDE.md +165 -160
  3. package/docs/container-architecture.md +192 -184
  4. package/package.json +1 -1
  5. package/routes/api/subtitles/get.js +205 -205
  6. package/services/container/Container.js +400 -135
  7. package/services/container/ContainerFactory.js +55 -31
  8. package/services/container/MatroskaContainer.js +1166 -516
  9. package/services/container/Mp4Container.js +898 -392
  10. package/services/container/SubtitleFileContainer.js +323 -261
  11. package/services/controllers/SubtitleController.js +128 -127
  12. package/services/delivery-probe.js +64 -6
  13. package/services/hls-session-manager.js +32 -35
  14. package/services/language-detect.js +174 -228
  15. package/services/playback-planner.js +747 -747
  16. package/services/produced-index.js +300 -0
  17. package/services/torrent-worker/subtitle-cues.js +549 -633
  18. package/services/tracks/TextSubtitleTrack.js +287 -47
  19. package/services/tracks/index.js +14 -14
  20. package/test/delivery-probe.test.js +67 -0
  21. package/test/matroska-blocks.test.js +0 -0
  22. package/test/mp4-subtitles.test.js +173 -127
  23. package/test/produced-index.test.js +188 -0
  24. package/test/subtitle-cue-framing.test.js +200 -202
  25. package/test/subtitle-cue-walk.test.js +369 -0
  26. package/test/subtitle-defaults.test.js +97 -97
  27. package/test/subtitle-language.test.js +252 -252
  28. package/test/subtitle-track-numbering.test.js +370 -370
  29. package/services/container-index/matroska-blocks.js +0 -202
  30. package/services/container-index/matroska-subtitles.js +0 -372
  31. package/services/container-index/mp4-subtitles.js +0 -404
  32. package/services/subtitle-convert.js +0 -144
  33. package/services/subtitle-defaults.js +0 -157
  34. package/services/tracks/subtitle-markup.js +0 -104
@@ -1,202 +0,0 @@
1
- /**
2
- * @file Subtitle blocks out of Matroska clusters, from bytes already in hand.
3
- *
4
- * A subtitle track is sparse — a few kilobytes spread across a whole film — and
5
- * ffmpeg cannot extract a time range of one without walking the container to
6
- * the end. Measured 2026-08-19 on `Minions.and.Monsters.1080p.mkv`: asking for
7
- * four seconds at minute twenty read the file through and pulled the download
8
- * from 2.7 % to 81 %; asking with `-copyts -ss -to` took 154 s on a copy that
9
- * was already 81 % local and still emitted the whole track. So the subtitles
10
- * are not asked of ffmpeg.
11
- *
12
- * They do not have to be. A subtitle block sits in the same cluster as the
13
- * picture around it, and those clusters are being downloaded anyway for the
14
- * viewer to watch. Reading them as they arrive costs no network at all, and it
15
- * puts the cues ahead of the playhead by construction — which is the whole
16
- * requirement: subtitles arrive like the picture does, or they are not offered.
17
- *
18
- * This module is the byte-level half: given a cluster's bytes, it returns the
19
- * blocks of one track with their times. It reads element headers and skips
20
- * payloads by their declared length; nothing is decoded.
21
- *
22
- * Structure, from RFC 9559 §5.1.3 and §5.1.4:
23
- *
24
- * Cluster (0x1F43B675)
25
- * Timestamp (0xE7) — the cluster's own time, in ticks
26
- * SimpleBlock (0xA3) — a block with no duration of its own
27
- * BlockGroup (0xA0)
28
- * Block (0xA1) — the same header, and where subtitles live
29
- * BlockDuration (0x9B) — how long the cue stays on screen
30
- *
31
- * A block's header is: the track number as a variable-length integer, a signed
32
- * 16-bit timestamp relative to the cluster, and one byte of flags. Subtitles
33
- * are normally in a BlockGroup, because a cue without a duration has no end.
34
- */
35
-
36
- import { iterateElements, readUint, readVint } from "./ebml-reader.js";
37
-
38
- const ID_TIMESTAMP = 0xe7;
39
- const ID_SIMPLE_BLOCK = 0xa3;
40
- const ID_BLOCK_GROUP = 0xa0;
41
- const ID_BLOCK = 0xa1;
42
- const ID_BLOCK_DURATION = 0x9b;
43
-
44
- /** Bits 1-2 of the flags byte say how a block is laced, or that it is not. */
45
- const LACING_MASK = 0x06;
46
- const LACING_NONE = 0x00;
47
- const LACING_XIPH = 0x02;
48
- const LACING_FIXED = 0x04;
49
- const LACING_EBML = 0x06;
50
-
51
- /**
52
- * @typedef {object} SubtitleBlock
53
- * @property {number} startSeconds - When the cue appears.
54
- * @property {number | null} durationSeconds - How long it stays, or null when
55
- * the block carried no duration (a SimpleBlock; the caller decides).
56
- * @property {Buffer} payload - The block's own bytes, still in the codec's form.
57
- */
58
-
59
- /**
60
- * Read one block's header.
61
- *
62
- * @param {Buffer} buffer
63
- * @param {number} start - First byte of the block's payload.
64
- * @param {number} end - One past its last byte.
65
- * @returns {{ trackNumber: number, relativeTicks: number, flags: number, dataOffset: number } | null}
66
- */
67
- function readBlockHeader(buffer, start, end) {
68
- const track = readVint(buffer, start, false);
69
- if (!track || track.value === null) {
70
- return null;
71
- }
72
- const timestampAt = start + track.length;
73
- // Signed, and it can be negative: a block may belong slightly before the
74
- // cluster it is stored in.
75
- if (timestampAt + 3 > end) {
76
- return null;
77
- }
78
- return {
79
- trackNumber: Number(track.value),
80
- relativeTicks: buffer.readInt16BE(timestampAt),
81
- flags: buffer[timestampAt + 2],
82
- dataOffset: timestampAt + 3
83
- };
84
- }
85
-
86
- /**
87
- * Where a laced block's first frame begins.
88
- *
89
- * Subtitles are rarely laced, but a block that IS laced starts with a frame
90
- * count and a table of sizes, and reading the payload without stepping over
91
- * them yields the table as though it were text.
92
- *
93
- * @param {Buffer} buffer
94
- * @param {number} dataOffset - First byte after the block header.
95
- * @param {number} end
96
- * @param {number} flags
97
- * @returns {number | null} The offset of the first frame, or null when the
98
- * lacing cannot be read.
99
- */
100
- function firstFrameOffset(buffer, dataOffset, end, flags) {
101
- const lacing = flags & LACING_MASK;
102
- if (lacing === LACING_NONE) {
103
- return dataOffset;
104
- }
105
- if (dataOffset >= end) {
106
- return null;
107
- }
108
- const frames = buffer[dataOffset] + 1;
109
- let at = dataOffset + 1;
110
- if (lacing === LACING_FIXED) {
111
- return at;
112
- }
113
- if (lacing === LACING_XIPH) {
114
- // Each size but the last is a run of 0xFF bytes ending in a smaller one.
115
- for (let frame = 0; frame < frames - 1; frame += 1) {
116
- while (at < end && buffer[at] === 0xff) {
117
- at += 1;
118
- }
119
- at += 1;
120
- }
121
- return at <= end ? at : null;
122
- }
123
- if (lacing === LACING_EBML) {
124
- // The first size is a plain variable-length integer, the rest are signed
125
- // differences from it; either way each is one such integer to step over.
126
- for (let frame = 0; frame < frames - 1; frame += 1) {
127
- const size = readVint(buffer, at, false);
128
- if (!size) {
129
- return null;
130
- }
131
- at += size.length;
132
- }
133
- return at <= end ? at : null;
134
- }
135
- return null;
136
- }
137
-
138
- /**
139
- * Every block of one track inside one cluster.
140
- *
141
- * @param {Buffer} buffer - Bytes holding the cluster's payload.
142
- * @param {{ dataOffset: number, size: number }} cluster - Where that payload is.
143
- * @param {number} trackNumber - The track to keep.
144
- * @param {number} secondsPerTick - From the segment's timestamp scale.
145
- * @returns {SubtitleBlock[]}
146
- */
147
- export function blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick) {
148
- const end = Math.min(buffer.length, cluster.dataOffset + cluster.size);
149
- /** @type {SubtitleBlock[]} */
150
- const blocks = [];
151
- let clusterTicks = null;
152
-
153
- const take = (blockStart, blockEnd, durationTicks) => {
154
- const header = readBlockHeader(buffer, blockStart, blockEnd);
155
- if (!header || header.trackNumber !== trackNumber || clusterTicks === null) {
156
- return;
157
- }
158
- const payloadAt = firstFrameOffset(buffer, header.dataOffset, blockEnd, header.flags);
159
- if (payloadAt === null || payloadAt >= blockEnd) {
160
- return;
161
- }
162
- blocks.push({
163
- startSeconds: (clusterTicks + header.relativeTicks) * secondsPerTick,
164
- durationSeconds: durationTicks === null ? null : durationTicks * secondsPerTick,
165
- payload: buffer.subarray(payloadAt, blockEnd)
166
- });
167
- };
168
-
169
- for (const element of iterateElements(buffer, cluster.dataOffset, end)) {
170
- const elementEnd = Math.min(end, element.dataOffset + element.size);
171
- if (element.id === ID_TIMESTAMP) {
172
- clusterTicks = readUint(buffer, element.dataOffset, element.size);
173
- continue;
174
- }
175
- if (element.id === ID_SIMPLE_BLOCK) {
176
- take(element.dataOffset, elementEnd, null);
177
- continue;
178
- }
179
- if (element.id !== ID_BLOCK_GROUP) {
180
- continue;
181
- }
182
- // A group holds the block and, for a subtitle, the duration that says when
183
- // the cue leaves the screen. Both are read before either is used, because
184
- // the duration may be written after the block.
185
- let blockStart = null;
186
- let blockEnd = null;
187
- let durationTicks = null;
188
- for (const field of iterateElements(buffer, element.dataOffset, elementEnd)) {
189
- const fieldEnd = Math.min(elementEnd, field.dataOffset + field.size);
190
- if (field.id === ID_BLOCK) {
191
- blockStart = field.dataOffset;
192
- blockEnd = fieldEnd;
193
- } else if (field.id === ID_BLOCK_DURATION) {
194
- durationTicks = readUint(buffer, field.dataOffset, field.size);
195
- }
196
- }
197
- if (blockStart !== null) {
198
- take(blockStart, blockEnd, durationTicks);
199
- }
200
- }
201
- return blocks;
202
- }
@@ -1,372 +0,0 @@
1
- /**
2
- * @file The subtitle tracks of a Matroska file, and where their cues live.
3
- *
4
- * Two halves, both reading only what is asked for by byte range:
5
- *
6
- * - {@link readSubtitlePlan} — once per file: which tracks are text
7
- * subtitles, in what codec, and the cluster positions their cue points
8
- * name. Two short reads, the head and the Cues element, exactly as the
9
- * keyframe reader already does.
10
- * - {@link harvestCluster} — per cluster, over bytes already downloaded:
11
- * the cues inside it, ready to be shown.
12
- *
13
- * Kept apart from `matroska.js` because that file answers one question (where
14
- * are the keyframes) and is read on the path that starts playback; this one is
15
- * only ever consulted for a viewer who asked for subtitles.
16
- */
17
-
18
- import { findElement, iterateElements, readUint } from "./ebml-reader.js";
19
- import { blocksOfTrack } from "./matroska-blocks.js";
20
-
21
- const ID_SEGMENT = 0x18538067;
22
- const ID_SEEK_HEAD = 0x114d9b74;
23
- const ID_SEEK = 0x4dbb;
24
- const ID_SEEK_ID = 0x53ab;
25
- const ID_SEEK_POSITION = 0x53ac;
26
- const ID_INFO = 0x1549a966;
27
- const ID_TIMESTAMP_SCALE = 0x2ad7b1;
28
- const ID_TRACKS = 0x1654ae6b;
29
- const ID_TRACK_ENTRY = 0xae;
30
- const ID_TRACK_NUMBER = 0xd7;
31
- const ID_TRACK_TYPE = 0x83;
32
- const ID_CODEC_ID = 0x86;
33
- const ID_CODEC_PRIVATE = 0x63a2;
34
- const ID_LANGUAGE = 0x22b59c;
35
- const ID_NAME = 0x536e;
36
- const ID_FLAG_DEFAULT = 0x88;
37
- /**
38
- * The rest of what a TrackEntry says about itself, RFC 9559 §5.1.4.1. Read
39
- * because the file states them and a releaser's own wording in `Name` is the
40
- * only thing we had before: "fors" and "SDH" in a menu were whatever text
41
- * someone happened to type.
42
- *
43
- * `FlagEnabled` defaults to 1 and means "the track is usable"; a track that
44
- * says 0 is counted but not offered. `FlagForced` applies only to subtitles and
45
- * defaults to 0. `FlagHearingImpaired` is set "if and only if the track is
46
- * suitable for users with hearing impairments". `FlagVisualImpaired`,
47
- * `FlagOriginal` and `FlagCommentary` bear on the AUDIO choice and are read
48
- * with that work, not here — see roadmap item 55.
49
- */
50
- const ID_FLAG_ENABLED = 0xb9;
51
- const ID_FLAG_FORCED = 0x55aa;
52
- const ID_FLAG_HEARING_IMPAIRED = 0x55ab;
53
- /**
54
- * The language as RFC 5646 writes it. The specification is a MUST: "If this
55
- * element is used, then any Language elements used in the same TrackEntry MUST
56
- * be ignored" — so where both are present, this one is the answer and the
57
- * three-letter code is not.
58
- */
59
- const ID_LANGUAGE_BCP47 = 0x22b59d;
60
- const ID_CUES = 0x1c53bb6b;
61
- const ID_CUE_POINT = 0xbb;
62
- const ID_CUE_TRACK_POSITIONS = 0xb7;
63
- const ID_CUE_TRACK = 0xf7;
64
- const ID_CUE_CLUSTER_POSITION = 0xf1;
65
-
66
- /** TrackType 17 is subtitles; 1 is video and 2 audio. */
67
- const TRACK_TYPE_SUBTITLE = 17;
68
- /** How much of the file start to read: the same window the keyframe reader uses. */
69
- const HEAD_BYTES = 64 * 1024;
70
- /** Cap on the Cues read; a long film indexes to tens of KB. */
71
- const MAX_CUES_BYTES = 8 * 1024 * 1024;
72
- const DEFAULT_TIMESTAMP_SCALE = 1_000_000;
73
-
74
- /**
75
- * The codecs whose blocks are text this proxy can turn into WebVTT.
76
- *
77
- * `S_TEXT/UTF8` is a plain line of text and needs nothing. `S_TEXT/ASS` and
78
- * `S_TEXT/SSA` carry a dialogue row whose fields have to be stripped, and their
79
- * header lives in CodecPrivate — supported, with the stripping done where the
80
- * cue is turned into WebVTT. `S_HDMV/PGS` and `S_VOBSUB` are pictures, not
81
- * text, and are deliberately absent: offering them would promise something this
82
- * path cannot deliver.
83
- */
84
- const TEXT_CODECS = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA"]);
85
-
86
- /**
87
- * @typedef {object} SubtitleTrackPlan
88
- * @property {number} trackNumber - As the blocks name it.
89
- * @property {number} declaredIndex - Its position among ALL of the file's
90
- * subtitle tracks, picture-based ones included — which is the number ffmpeg
91
- * gives the same stream in `0:s:N`, and therefore the only number the browser
92
- * ever names. Text tracks alone are not a numbering: a file whose PGS track
93
- * comes first would have every text track one lower here than in the browser.
94
- * @property {string} codecId
95
- * @property {string} language - The language the file declares: its RFC 5646
96
- * tag where it writes one, and the three-letter code otherwise. The
97
- * specification requires that order — where `LanguageBCP47` is present, the
98
- * `Language` element MUST be ignored.
99
- * @property {string} languageBcp47 - The RFC 5646 tag alone, or "".
100
- * @property {string} name - What the file calls the track, if anything.
101
- * @property {boolean} isDefault
102
- * @property {boolean} isForced - `FlagForced`: the track carries what a viewer
103
- * needs even when they asked for no subtitles — signs, and dialogue in
104
- * another language. It does NOT carry the film's own dialogue.
105
- * @property {boolean} isHearingImpaired - `FlagHearingImpaired`: suitable for
106
- * viewers who cannot hear, so it carries non-speech sound as well as speech.
107
- * @property {string} codecPrivate - The ASS/SSA header, base64, or "".
108
- * @property {number[]} clusterPositions - File offsets of clusters whose cue
109
- * points name this track, ascending. Empty when the file indexes only its
110
- * picture, and then the caller has to walk clusters as they arrive instead.
111
- */
112
-
113
- /**
114
- * Read a string element, trimming the padding some muxers leave.
115
- *
116
- * @param {Buffer} buffer
117
- * @param {{ dataOffset: number, size: number }} element
118
- * @returns {string}
119
- */
120
- function readString(buffer, element) {
121
- return buffer
122
- .toString("utf8", element.dataOffset, element.dataOffset + element.size)
123
- .replace(/\0+$/, "");
124
- }
125
-
126
- /**
127
- * Everything about a file's text subtitle tracks that can be learned without
128
- * reading the film.
129
- *
130
- * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
131
- * @param {number} fileSize
132
- * @returns {Promise<{ tracks: SubtitleTrackPlan[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
133
- */
134
- export async function readSubtitlePlan(readRange, fileSize) {
135
- const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
136
- if (!head || head.length < 4 || head.readUInt32BE(0) !== 0x1a45dfa3) {
137
- return null;
138
- }
139
- const segment = findElement(head, ID_SEGMENT, []);
140
- if (!segment) {
141
- return null;
142
- }
143
- const base = segment.dataOffset;
144
-
145
- const info = findElement(head, ID_INFO, [], base);
146
- let scale = DEFAULT_TIMESTAMP_SCALE;
147
- if (info) {
148
- const declared = findElement(head, ID_TIMESTAMP_SCALE, [], info.dataOffset, info.dataOffset + info.size);
149
- if (declared) {
150
- const value = readUint(head, declared.dataOffset, declared.size);
151
- if (value > 0) {
152
- scale = value;
153
- }
154
- }
155
- }
156
-
157
- const tracksElement = findElement(head, ID_TRACKS, [], base);
158
- if (!tracksElement) {
159
- return null;
160
- }
161
- const tracksEnd = Math.min(head.length, tracksElement.dataOffset + tracksElement.size);
162
- /** @type {SubtitleTrackPlan[]} */
163
- const tracks = [];
164
- /**
165
- * Every subtitle track the file declares, in the order the Tracks element
166
- * names them, text or picture. This is not for extraction — `tracks` is —
167
- * but for lining ffmpeg's `0:s:N` numbering up against the container, which
168
- * only holds while nothing is missing from the middle of the list.
169
- *
170
- * @type {Array<{ trackNumber: number, codecId: string, language: string, name: string, isDefault: boolean, declaresDefault: boolean }>}
171
- */
172
- const declared = [];
173
- for (const entry of iterateElements(head, tracksElement.dataOffset, tracksEnd)) {
174
- if (entry.id !== ID_TRACK_ENTRY) {
175
- continue;
176
- }
177
- const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
178
- let trackNumber = null;
179
- let type = null;
180
- let codecId = "";
181
- let language = "";
182
- let name = "";
183
- let codecPrivate = "";
184
- // Matroska's `FlagDefault` DEFAULTS TO 1, so a file whose muxer wrote it on
185
- // no track is indistinguishable, once the default has been applied, from
186
- // one that wrote it on every track — which is how ffmpeg's banner prints it
187
- // and why the banner cannot answer this. Both are kept: what the flag
188
- // amounts to, and whether the file said anything at all.
189
- let isDefault = true;
190
- let declaresDefault = false;
191
- // Defaults straight from RFC 9559: a track is usable and not forced unless
192
- // the file says otherwise, and the impaired flags are absent until claimed.
193
- let isEnabled = true;
194
- let isForced = false;
195
- let isHearingImpaired = false;
196
- let languageBcp47 = "";
197
- for (const field of iterateElements(head, entry.dataOffset, entryEnd)) {
198
- if (field.id === ID_TRACK_NUMBER) {
199
- trackNumber = readUint(head, field.dataOffset, field.size);
200
- } else if (field.id === ID_TRACK_TYPE) {
201
- type = readUint(head, field.dataOffset, field.size);
202
- } else if (field.id === ID_CODEC_ID) {
203
- codecId = readString(head, field);
204
- } else if (field.id === ID_LANGUAGE) {
205
- language = readString(head, field);
206
- } else if (field.id === ID_LANGUAGE_BCP47) {
207
- languageBcp47 = readString(head, field);
208
- } else if (field.id === ID_NAME) {
209
- name = readString(head, field);
210
- } else if (field.id === ID_FLAG_DEFAULT) {
211
- isDefault = readUint(head, field.dataOffset, field.size) === 1;
212
- declaresDefault = true;
213
- } else if (field.id === ID_FLAG_ENABLED) {
214
- // An element written with zero length carries its default, which for
215
- // this one is 1 — so an empty element must not read as "unusable", and
216
- // neither must a value outside the declared 0-1 range. Only an explicit
217
- // zero takes a track away.
218
- isEnabled = field.size === 0 || readUint(head, field.dataOffset, field.size) !== 0;
219
- } else if (field.id === ID_FLAG_FORCED) {
220
- isForced = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
221
- } else if (field.id === ID_FLAG_HEARING_IMPAIRED) {
222
- isHearingImpaired = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
223
- } else if (field.id === ID_CODEC_PRIVATE) {
224
- codecPrivate = head.toString("base64", field.dataOffset, field.dataOffset + field.size);
225
- }
226
- }
227
- if (type !== TRACK_TYPE_SUBTITLE || trackNumber === null) {
228
- continue;
229
- }
230
- // A track the file marks unusable is still COUNTED. FlagEnabled says "the
231
- // track is usable", and a player should not offer it — but ffmpeg does not
232
- // drop it: `matroskadec.c` parses `MATROSKA_ID_TRACKFLAGENABLED` as
233
- // `EBML_NONE`, reading the element and keeping nothing, so the stream is
234
- // created and numbered like any other. Leaving it out of this list would
235
- // therefore shift `declaredIndex` off ffmpeg's `0:s:N` for every track
236
- // after it, which is the numbering defect this file was fixed for a day
237
- // earlier. It is counted here and refused where it is offered instead.
238
- //
239
- // `language` here stays the three-letter code, because this list exists to
240
- // be lined up against ffmpeg's banner, which prints that code. The RFC 5646
241
- // tag rides beside it for whoever displays the track.
242
- declared.push({
243
- trackNumber,
244
- codecId,
245
- language,
246
- languageBcp47,
247
- name,
248
- isDefault,
249
- declaresDefault,
250
- isEnabled,
251
- isForced,
252
- isHearingImpaired
253
- });
254
- if (!TEXT_CODECS.has(codecId) || !isEnabled) {
255
- continue;
256
- }
257
- tracks.push({
258
- trackNumber,
259
- declaredIndex: declared.length - 1,
260
- codecId,
261
- // This list is ours and is not compared with ffmpeg's, so it carries the
262
- // language the file states most precisely: where RFC 5646 is written, the
263
- // three-letter code MUST be ignored.
264
- language: languageBcp47 || language,
265
- languageBcp47,
266
- name,
267
- isDefault,
268
- isForced,
269
- isHearingImpaired,
270
- codecPrivate,
271
- clusterPositions: []
272
- });
273
- }
274
- if (tracks.length === 0) {
275
- return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
276
- }
277
-
278
- // Where the clusters holding those tracks are. A file that indexes only its
279
- // picture leaves these empty, which is not a failure: the caller then reads
280
- // the clusters the viewer's own playback brings in.
281
- const seekHead = findElement(head, ID_SEEK_HEAD, [], base);
282
- let cuesRelative;
283
- if (seekHead) {
284
- const seekEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
285
- for (const seek of iterateElements(head, seekHead.dataOffset, seekEnd)) {
286
- if (seek.id !== ID_SEEK) {
287
- continue;
288
- }
289
- let target = null;
290
- let position = null;
291
- for (const field of iterateElements(head, seek.dataOffset, Math.min(seekEnd, seek.dataOffset + seek.size))) {
292
- if (field.id === ID_SEEK_ID) {
293
- target = readUint(head, field.dataOffset, field.size);
294
- } else if (field.id === ID_SEEK_POSITION) {
295
- position = readUint(head, field.dataOffset, field.size);
296
- }
297
- }
298
- if (target === ID_CUES && position !== null) {
299
- cuesRelative = position;
300
- }
301
- }
302
- }
303
- if (cuesRelative !== undefined) {
304
- const cuesAt = base + cuesRelative;
305
- if (cuesAt > 0 && cuesAt < fileSize) {
306
- const chunk = await readRange(cuesAt, Math.min(fileSize - 1, cuesAt + MAX_CUES_BYTES));
307
- const element = chunk && [...iterateElements(chunk, 0, chunk.length)][0];
308
- if (element && element.id === ID_CUES) {
309
- const body = chunk.subarray(element.dataOffset, Math.min(chunk.length, element.dataOffset + element.size));
310
- const byTrack = new Map(tracks.map((track) => [track.trackNumber, new Set()]));
311
- for (const point of iterateElements(body, 0, body.length)) {
312
- if (point.id !== ID_CUE_POINT) {
313
- continue;
314
- }
315
- const pointEnd = Math.min(body.length, point.dataOffset + point.size);
316
- for (const field of iterateElements(body, point.dataOffset, pointEnd)) {
317
- if (field.id !== ID_CUE_TRACK_POSITIONS) {
318
- continue;
319
- }
320
- let cueTrack = null;
321
- let position = null;
322
- for (const inner of iterateElements(body, field.dataOffset, Math.min(pointEnd, field.dataOffset + field.size))) {
323
- if (inner.id === ID_CUE_TRACK) {
324
- cueTrack = readUint(body, inner.dataOffset, inner.size);
325
- } else if (inner.id === ID_CUE_CLUSTER_POSITION) {
326
- position = readUint(body, inner.dataOffset, inner.size);
327
- }
328
- }
329
- if (position !== null && byTrack.has(cueTrack)) {
330
- byTrack.get(cueTrack).add(base + position);
331
- }
332
- }
333
- }
334
- for (const track of tracks) {
335
- track.clusterPositions = [...byTrack.get(track.trackNumber)].sort((left, right) => left - right);
336
- }
337
- }
338
- }
339
- }
340
- return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
341
- }
342
-
343
- /**
344
- * The cues of one track inside one cluster.
345
- *
346
- * @param {Buffer} bytes - The cluster, from its own element header onward.
347
- * @param {number} trackNumber
348
- * @param {number} secondsPerTick
349
- * @returns {{ startSeconds: number, endSeconds: number | null, text: string }[]}
350
- */
351
- export function harvestCluster(bytes, trackNumber, secondsPerTick) {
352
- const header = [...iterateElements(bytes, 0, bytes.length)][0];
353
- if (!header) {
354
- return [];
355
- }
356
- const blocks = blocksOfTrack(
357
- bytes,
358
- { dataOffset: header.dataOffset, size: header.size },
359
- trackNumber,
360
- secondsPerTick
361
- );
362
- // The payload is handed on as BYTES. What those bytes mean — which of them
363
- // are the text and which are the eight fields Matroska puts before it — is
364
- // stated by the container's specification and answered by
365
- // `MatroskaContainer.cueTextOf`, not here: this function's subject is where a
366
- // block sits and how long it lasts.
367
- return blocks.map((block) => ({
368
- startSeconds: block.startSeconds,
369
- endSeconds: block.durationSeconds === null ? null : block.startSeconds + block.durationSeconds,
370
- payload: block.payload
371
- }));
372
- }