@torrent-tv/proxy 2.88.5 → 2.89.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/AGENTS.md +21 -12
- package/CHANGELOG.md +18 -0
- package/assets/calibration/audio/NOTICE.md +19 -19
- package/assets/calibration/audio/manifest.json +98 -98
- package/docs/container-architecture.md +77 -22
- package/docs/playback-start-forecast.md +56 -56
- package/main2.cpuprofile +1 -0
- package/package.json +1 -1
- package/scripts/build-audio-samples.mjs +31 -31
- package/server.js +42 -27
- package/services/encode/EncodeRuns.js +3 -1
- package/services/encode/OutputOpening.js +5 -4
- package/services/encode/audio-calibration.js +67 -67
- package/services/encode/audio-work-rate.js +15 -15
- package/services/encode/output/index.js +4 -0
- package/services/encode/run-command.js +2 -1
- package/services/media/ContainerOrchestrator.js +99 -46
- package/services/media/KeyframeTables.js +61 -21
- package/services/media/SubtitleCues.js +255 -183
- package/services/media/container/AviContainer.js +5 -3
- package/services/media/container/Container.js +66 -43
- package/services/media/container/ContainerFactory.js +8 -26
- package/services/media/container/MatroskaContainer.js +719 -1182
- package/services/media/container/Mp4Container.js +83 -73
- package/services/media/container/ebml-stream.js +174 -0
- package/services/media/container/matroska-clusters.js +1165 -0
- package/services/media/container/unavailable.js +77 -0
- package/services/media/tracks/ContainerTrack.js +9 -0
- package/services/media/tracks/TextSubtitleTrack.js +9 -1
- package/services/server/ViewerRequests.js +3 -2
- package/services/server/playback-readiness.js +516 -516
- package/services/torrent/worker/client.js +6 -2
- package/services/torrent/worker/held-bytes.js +84 -8
- package/services/torrent/worker/worker.js +10 -2
- package/services/transport/data-channel-handler.js +7 -3
- package/services/viewer/PriorityMap.js +31 -0
- package/test/audio-calibration.test.js +23 -23
- package/test/coalesce.test.js +39 -0
- package/test/held-read-ownership.test.js +77 -0
- package/test/helpers/held-file.js +50 -2
- package/test/helpers/matroska-file.js +296 -0
- package/test/keyframe-table-budget.test.js +21 -0
- package/test/matroska-cluster-discovery.test.js +323 -0
- package/test/reads-not-yet-arrived.test.js +218 -0
- package/test/segment-store.test.js +14 -0
- package/test/subtitle-cue-walk.test.js +16 -1
- package/test/subtitle-plan-waits.test.js +125 -0
- package/test/subtitle-track-numbering.test.js +8 -3
- package/test/viewer-starts-on-map.test.js +27 -0
- package/utils/coalesce.js +41 -0
|
@@ -7,15 +7,29 @@
|
|
|
7
7
|
* - FlagDefault 0x88 default 1, declaresDefault tracks whether element was written
|
|
8
8
|
* - FlagForced 0x55AA only for subtitles, FlagHearingImpaired 0x55AB, FlagVisualImpaired 0x55AC,
|
|
9
9
|
* FlagTextDescriptions 0x55AD, FlagOriginal 0x55AE, FlagCommentary 0x55AF
|
|
10
|
-
* - Language 0x22B59C default "eng"
|
|
10
|
+
* - Language 0x22B59C default "eng" (RFC 8794 §11.1.19: an element equal to its
|
|
11
|
+
* default need not be written, and the reader MUST read the default),
|
|
12
|
+
* LanguageBCP47 0x22B59D MUST — when present, Language ignored
|
|
11
13
|
* - CodecID 0x86, CodecPrivate 0x63A2, Name 0x536E, TrackType 0x83 (1 video, 2 audio, 17 subtitle)
|
|
12
14
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* **One reading of each thing the file states, kept on the container.** The
|
|
16
|
+
* layout of the Segment, the Tracks element and the Cues table are each read
|
|
17
|
+
* once and kept — and only once they have been read whole: a read whose bytes
|
|
18
|
+
* have not arrived throws `BytesUnavailable` and leaves nothing behind
|
|
19
|
+
* (`unavailable.js`). The keyframe times and the subtitle plan are both taken
|
|
20
|
+
* from the one Cues reading. Until 2026-10-01 the Cues table was read three
|
|
21
|
+
* times by three functions, the subtitle one kept a read that had not arrived
|
|
22
|
+
* as "no clusters", and an embedded track showed nothing for a whole session.
|
|
23
|
+
*
|
|
24
|
+
* **Read where it lies.** Elements are found by walking the Segment's top-level
|
|
25
|
+
* headers, not by hoping they fit a head window, and read a portion at a time
|
|
26
|
+
* through `ebml-stream.js` — so a large Tracks element or Cues table is read by
|
|
27
|
+
* its own size and never cut short by a bound of ours.
|
|
28
|
+
*
|
|
29
|
+
* Byte-level EBML grammar is `ebml-reader.js` and `ebml-stream.js`; the clusters
|
|
30
|
+
* found from downloaded bytes are `matroska-clusters.js`. Everything Matroska
|
|
31
|
+
* states about itself is read in this module or that one, and the class is the
|
|
32
|
+
* only way in.
|
|
19
33
|
*/
|
|
20
34
|
|
|
21
35
|
import { Container } from "./Container.js";
|
|
@@ -24,18 +38,14 @@ import { AudioTrack } from "../tracks/AudioTrack.js";
|
|
|
24
38
|
import { TextSubtitleTrack, TEXT_CODECS_MATROSKA } from "../tracks/TextSubtitleTrack.js";
|
|
25
39
|
import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
|
|
26
40
|
import { ContainerTrack } from "../tracks/ContainerTrack.js";
|
|
27
|
-
import {
|
|
41
|
+
import { iterateElements, readFloat, readUint, readVint } from "./ebml-reader.js";
|
|
42
|
+
import { ElementReader, sizeIsUnknown } from "./ebml-stream.js";
|
|
43
|
+
import { firstFrameOffset, readBlockHeader, walkHeldClusters } from "./matroska-clusters.js";
|
|
28
44
|
|
|
45
|
+
/** Enough to hold the EBML header and the Segment's own header. */
|
|
29
46
|
const HEAD_BYTES = 64 * 1024;
|
|
30
|
-
/** Enough to read any cluster's own element header. */
|
|
31
|
-
const CLUSTER_HEADER_PROBE = 64;
|
|
32
|
-
/**
|
|
33
|
-
* The largest cluster this will read whole. Real muxers write clusters of a few
|
|
34
|
-
* megabytes; anything past this is not a cluster boundary we recognised and
|
|
35
|
-
* reading it would be a large read for nothing.
|
|
36
|
-
*/
|
|
37
|
-
const MAX_CLUSTER_BYTES = 32 * 1024 * 1024;
|
|
38
47
|
|
|
48
|
+
const ID_EBML = 0x1a45dfa3;
|
|
39
49
|
const ID_SEGMENT = 0x18538067;
|
|
40
50
|
const ID_SEEK_HEAD = 0x114d9b74;
|
|
41
51
|
const ID_SEEK = 0x4dbb;
|
|
@@ -45,17 +55,17 @@ const ID_INFO = 0x1549a966;
|
|
|
45
55
|
const ID_TIMESTAMP_SCALE = 0x2ad7b1;
|
|
46
56
|
const ID_DURATION = 0x4489;
|
|
47
57
|
const ID_CLUSTER = 0x1f43b675;
|
|
58
|
+
const ID_CUES = 0x1c53bb6b;
|
|
59
|
+
const ID_CUE_POINT = 0xbb;
|
|
60
|
+
const ID_CUE_TIME = 0xb3;
|
|
61
|
+
const ID_CUE_TRACK_POSITIONS = 0xb7;
|
|
62
|
+
const ID_CUE_TRACK = 0xf7;
|
|
63
|
+
const ID_CUE_CLUSTER_POSITION = 0xf1;
|
|
48
64
|
const ID_TIMESTAMP = 0xe7;
|
|
49
65
|
const ID_SIMPLE_BLOCK = 0xa3;
|
|
50
66
|
const ID_BLOCK_GROUP = 0xa0;
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
/**
|
|
54
|
-
* How much to read at a cluster whose position came from the SeekHead. A
|
|
55
|
-
* cluster's Timestamp is the first child every muxer writes, so this only has
|
|
56
|
-
* to cover the element header and that one field.
|
|
57
|
-
*/
|
|
58
|
-
const CLUSTER_PROBE_BYTES = 4 * 1024;
|
|
67
|
+
const ID_BLOCK = 0xa1;
|
|
68
|
+
const ID_BLOCK_DURATION = 0x9b;
|
|
59
69
|
const ID_TRACKS = 0x1654ae6b;
|
|
60
70
|
const ID_TRACK_ENTRY = 0xae;
|
|
61
71
|
const ID_TRACK_NUMBER = 0xd7;
|
|
@@ -81,6 +91,16 @@ const ID_DISPLAY_WIDTH = 0x54b0;
|
|
|
81
91
|
const ID_DISPLAY_HEIGHT = 0x54ba;
|
|
82
92
|
const ID_SAMPLING_FREQUENCY = 0xb5;
|
|
83
93
|
const ID_CHANNELS = 0x9f;
|
|
94
|
+
|
|
95
|
+
/** RFC 9559 §5.1.2.1: nanoseconds per tick when Info omits TimestampScale. */
|
|
96
|
+
const DEFAULT_TIMESTAMP_SCALE = 1_000_000;
|
|
97
|
+
/** RFC 9559 §5.1.4.1.19: what an absent or empty `Language` element means. */
|
|
98
|
+
const DEFAULT_LANGUAGE = "eng";
|
|
99
|
+
|
|
100
|
+
const TRACK_TYPE_VIDEO = 1;
|
|
101
|
+
const TRACK_TYPE_AUDIO = 2;
|
|
102
|
+
const TRACK_TYPE_SUBTITLE = 17;
|
|
103
|
+
|
|
84
104
|
/**
|
|
85
105
|
* ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect — the eight
|
|
86
106
|
* fields Matroska writes before the text of an SSA/ASS event. See
|
|
@@ -92,7 +112,35 @@ function readString(buf, el) {
|
|
|
92
112
|
return buf.toString("utf8", el.dataOffset, el.dataOffset + el.size).replace(/\0+$/, "");
|
|
93
113
|
}
|
|
94
114
|
|
|
115
|
+
/**
|
|
116
|
+
* @typedef {object} SegmentLayout
|
|
117
|
+
* @property {number} segmentDataOffset - Where the Segment's data begins.
|
|
118
|
+
* @property {number} segmentEnd - One past its last byte (the file's end for an
|
|
119
|
+
* unknown-sized Segment).
|
|
120
|
+
* @property {number} secondsPerTick
|
|
121
|
+
* @property {number | null} infoAt - Positions of the top-level elements, from
|
|
122
|
+
* the walk to the first cluster or from the SeekHead.
|
|
123
|
+
* @property {number | null} tracksAt
|
|
124
|
+
* @property {number | null} cuesAt
|
|
125
|
+
* @property {number | null} firstClusterAt
|
|
126
|
+
*/
|
|
127
|
+
|
|
95
128
|
export class MatroskaContainer extends Container {
|
|
129
|
+
/** @type {Buffer | null} */
|
|
130
|
+
#head = null;
|
|
131
|
+
|
|
132
|
+
/** @type {SegmentLayout | null} */
|
|
133
|
+
#layout = null;
|
|
134
|
+
|
|
135
|
+
/** @type {Array<import("../tracks/ContainerTrack.js").ContainerTrack> | null} */
|
|
136
|
+
#tracks = null;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The Cues reading: undefined until read, null when the file has none.
|
|
140
|
+
* @type {{ points: Array<{ ticks: number, positions: Array<{ track: number, clusterAt: number }> }> } | null | undefined}
|
|
141
|
+
*/
|
|
142
|
+
#cues = undefined;
|
|
143
|
+
|
|
96
144
|
get formatName() {
|
|
97
145
|
return "matroska";
|
|
98
146
|
}
|
|
@@ -104,47 +152,29 @@ export class MatroskaContainer extends Container {
|
|
|
104
152
|
/**
|
|
105
153
|
* The keyframe times this container's own index states, in ascending seconds.
|
|
106
154
|
*
|
|
107
|
-
* Static so a caller that has bytes and no container can ask; the
|
|
108
|
-
*
|
|
155
|
+
* Static so a caller that has bytes and no container can ask; the reading is
|
|
156
|
+
* the instance's, over a container built for the purpose.
|
|
109
157
|
*
|
|
110
158
|
* @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
|
|
111
159
|
* @param {number} fileSize
|
|
112
160
|
* @returns {Promise<number[]|null>} Null where the container has no index.
|
|
113
161
|
*/
|
|
114
|
-
static readKeyframeTimes(readRange, fileSize) {
|
|
115
|
-
|
|
162
|
+
static async readKeyframeTimes(readRange, fileSize) {
|
|
163
|
+
const index = await new MatroskaContainer({ readRange, fileSize }).parseKeyframeIndex();
|
|
164
|
+
return index ? index.times : null;
|
|
116
165
|
}
|
|
117
166
|
|
|
118
167
|
/**
|
|
119
|
-
* This container's subtitle tracks, its Cues table and the cluster positions
|
|
120
|
-
* they name — RFC 9559 §5.1.4 and §5.1.3.
|
|
121
|
-
*
|
|
122
168
|
* @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
|
|
123
169
|
* @param {number} fileSize
|
|
124
170
|
* @returns {Promise<object|null>}
|
|
125
171
|
*/
|
|
126
172
|
static readSubtitlePlan(readRange, fileSize) {
|
|
127
|
-
return
|
|
173
|
+
return new MatroskaContainer({ readRange, fileSize }).readSubtitlePlan();
|
|
128
174
|
}
|
|
129
175
|
|
|
130
176
|
/**
|
|
131
|
-
* The
|
|
132
|
-
*
|
|
133
|
-
* The static form exists for a caller that has bytes and no container; this
|
|
134
|
-
* is the one to use otherwise, because the reader is already here.
|
|
135
|
-
*
|
|
136
|
-
* @returns {Promise<object|null>}
|
|
137
|
-
*/
|
|
138
|
-
readSubtitlePlan() {
|
|
139
|
-
return MatroskaContainer.readSubtitlePlan(this.readRange, this.fileSize);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* The blocks one track has inside a cluster, with their times.
|
|
144
|
-
*
|
|
145
|
-
* The payload is handed back as BYTES: what those bytes mean is
|
|
146
|
-
* {@link MatroskaContainer.cueTextOf}'s answer, and this method's subject is
|
|
147
|
-
* only where a block sits and how long it lasts.
|
|
177
|
+
* The blocks one track has inside a cluster held whole in a buffer.
|
|
148
178
|
*
|
|
149
179
|
* @param {Buffer} bytes - The cluster, from its own element header onward.
|
|
150
180
|
* @param {number} trackNumber
|
|
@@ -159,9 +189,6 @@ export class MatroskaContainer extends Container {
|
|
|
159
189
|
* The blocks one track has inside a cluster whose bounds are already known —
|
|
160
190
|
* RFC 9559 §5.1.3.4 (SimpleBlock) and §5.1.3.5 (BlockGroup).
|
|
161
191
|
*
|
|
162
|
-
* The same reading as {@link MatroskaContainer.blocksInCluster}, entered where
|
|
163
|
-
* the caller has already parsed the cluster's own header.
|
|
164
|
-
*
|
|
165
192
|
* @param {Buffer} buffer
|
|
166
193
|
* @param {{ dataOffset: number, size: number }} cluster
|
|
167
194
|
* @param {number} trackNumber
|
|
@@ -172,13 +199,132 @@ export class MatroskaContainer extends Container {
|
|
|
172
199
|
return blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick);
|
|
173
200
|
}
|
|
174
201
|
|
|
202
|
+
/**
|
|
203
|
+
* A reader for the inside of one element: a portion at a time, never past
|
|
204
|
+
* the element's end — so a Cues table of thousands of CuePoints costs a few
|
|
205
|
+
* reads rather than one per CuePoint, and nothing after the element is
|
|
206
|
+
* fetched for it.
|
|
207
|
+
*
|
|
208
|
+
* @param {number} end - One past the element's last byte.
|
|
209
|
+
* @returns {ElementReader}
|
|
210
|
+
*/
|
|
211
|
+
#readerWithin(end) {
|
|
212
|
+
return new ElementReader({
|
|
213
|
+
read: this.readRange,
|
|
214
|
+
fileSize: this.fileSize,
|
|
215
|
+
portionBytes: this.portionBytes,
|
|
216
|
+
prefetch: true,
|
|
217
|
+
readableUntil: () => end
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @returns {Promise<Buffer>} */
|
|
222
|
+
async #headBytes() {
|
|
223
|
+
if (this.#head === null) {
|
|
224
|
+
this.#head = await this.readRange(0, Math.min(HEAD_BYTES, this.fileSize) - 1);
|
|
225
|
+
}
|
|
226
|
+
return this.#head;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Where the Segment's top-level elements are.
|
|
231
|
+
*
|
|
232
|
+
* Found by walking their HEADERS from the Segment's start to the first
|
|
233
|
+
* cluster — each one costs the bytes of its header and nothing of its data —
|
|
234
|
+
* and, for what lies after the clusters, from the SeekHead. RFC 9559 §6.4: a
|
|
235
|
+
* Cues element SHOULD be stored before the first cluster or be referenced by
|
|
236
|
+
* a SeekHead, so the two together find it wherever a conforming file puts it.
|
|
237
|
+
*
|
|
238
|
+
* @returns {Promise<SegmentLayout | null>} Null for bytes that are not a
|
|
239
|
+
* Matroska Segment.
|
|
240
|
+
*/
|
|
241
|
+
async #segmentLayout() {
|
|
242
|
+
if (this.#layout !== null) {
|
|
243
|
+
return this.#layout;
|
|
244
|
+
}
|
|
245
|
+
const head = await this.#headBytes();
|
|
246
|
+
if (!isMatroska(head)) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
const segment = segmentHeaderIn(head);
|
|
250
|
+
if (!segment) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
const segmentDataOffset = segment.dataOffset;
|
|
254
|
+
const segmentEnd = segment.size === null ? this.fileSize : Math.min(this.fileSize, segmentDataOffset + segment.size);
|
|
255
|
+
|
|
256
|
+
const reader = new ElementReader({ read: this.readRange, fileSize: this.fileSize, portionBytes: this.portionBytes });
|
|
257
|
+
/** @type {Map<number, number>} */
|
|
258
|
+
const found = new Map();
|
|
259
|
+
/** @type {Map<number, number>} */
|
|
260
|
+
const seeks = new Map();
|
|
261
|
+
let firstClusterAt = null;
|
|
262
|
+
let at = segmentDataOffset;
|
|
263
|
+
while (at < segmentEnd) {
|
|
264
|
+
const header = await reader.header(at, segmentEnd);
|
|
265
|
+
if (!header) {
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
if (header.id === ID_CLUSTER) {
|
|
269
|
+
firstClusterAt = at;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
if (!found.has(header.id)) {
|
|
273
|
+
found.set(header.id, at);
|
|
274
|
+
}
|
|
275
|
+
if (header.id === ID_SEEK_HEAD && header.size !== null) {
|
|
276
|
+
const data = await reader.data(header);
|
|
277
|
+
if (data) {
|
|
278
|
+
for (const [id, position] of readSeekEntries(data)) {
|
|
279
|
+
seeks.set(id, segmentDataOffset + position);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (header.end === null) {
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
at = header.end;
|
|
287
|
+
}
|
|
288
|
+
const positionOf = (id) => found.get(id) ?? seeks.get(id) ?? null;
|
|
289
|
+
|
|
290
|
+
let secondsPerTick = DEFAULT_TIMESTAMP_SCALE / 1e9;
|
|
291
|
+
const infoAt = positionOf(ID_INFO);
|
|
292
|
+
if (infoAt !== null) {
|
|
293
|
+
const info = await reader.header(infoAt, segmentEnd);
|
|
294
|
+
const data = info && info.id === ID_INFO ? await reader.data(info) : null;
|
|
295
|
+
if (data) {
|
|
296
|
+
for (const field of iterateElements(data)) {
|
|
297
|
+
if (field.id === ID_TIMESTAMP_SCALE) {
|
|
298
|
+
const scale = readUint(data, field.dataOffset, field.size);
|
|
299
|
+
if (scale > 0) {
|
|
300
|
+
secondsPerTick = scale / 1e9;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
this.#layout = {
|
|
307
|
+
segmentDataOffset,
|
|
308
|
+
segmentEnd,
|
|
309
|
+
secondsPerTick,
|
|
310
|
+
infoAt,
|
|
311
|
+
tracksAt: positionOf(ID_TRACKS),
|
|
312
|
+
cuesAt: positionOf(ID_CUES),
|
|
313
|
+
firstClusterAt: firstClusterAt ?? seeks.get(ID_CLUSTER) ?? null
|
|
314
|
+
};
|
|
315
|
+
return this.#layout;
|
|
316
|
+
}
|
|
317
|
+
|
|
175
318
|
/**
|
|
176
319
|
* Duration and the start of this file's own timeline, per RFC 9559 §5.1.2.
|
|
177
320
|
*
|
|
178
|
-
* Duration is stated in `Info` as a FLOAT in ticks
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
321
|
+
* Duration is stated in `Info` as a FLOAT in ticks. The start of the timeline
|
|
322
|
+
* is not stated anywhere — Matroska has no such element — so it is the
|
|
323
|
+
* timestamp of the first Cluster, which is what the first frame is placed
|
|
324
|
+
* against.
|
|
325
|
+
*
|
|
326
|
+
* Kept only once it has been read: a head that had not arrived used to leave
|
|
327
|
+
* an empty answer here for the life of the container.
|
|
182
328
|
*
|
|
183
329
|
* @returns {Promise<import("./Container.js").ContainerMediaInfo>}
|
|
184
330
|
*/
|
|
@@ -188,467 +334,243 @@ export class MatroskaContainer extends Container {
|
|
|
188
334
|
}
|
|
189
335
|
/** @type {import("./Container.js").ContainerMediaInfo} */
|
|
190
336
|
const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: null };
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
return info;
|
|
195
|
-
}
|
|
196
|
-
const segment = findElement(head, ID_SEGMENT, []);
|
|
197
|
-
if (!segment) {
|
|
337
|
+
const layout = await this.#segmentLayout();
|
|
338
|
+
if (!layout) {
|
|
339
|
+
this.mediaInfo = info;
|
|
198
340
|
return info;
|
|
199
341
|
}
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
342
|
+
const reader = new ElementReader({ read: this.readRange, fileSize: this.fileSize, portionBytes: this.portionBytes });
|
|
343
|
+
if (layout.infoAt !== null) {
|
|
344
|
+
const header = await reader.header(layout.infoAt, layout.segmentEnd);
|
|
345
|
+
const data = header && header.id === ID_INFO ? await reader.data(header) : null;
|
|
346
|
+
if (data) {
|
|
347
|
+
for (const field of iterateElements(data)) {
|
|
348
|
+
if (field.id === ID_DURATION) {
|
|
349
|
+
const ticks = readFloat(data, field.dataOffset, field.size);
|
|
350
|
+
if (ticks !== null && ticks > 0) {
|
|
351
|
+
info.durationSeconds = ticks * layout.secondsPerTick;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
211
354
|
}
|
|
212
|
-
break;
|
|
213
355
|
}
|
|
214
356
|
}
|
|
215
|
-
|
|
357
|
+
if (layout.firstClusterAt !== null) {
|
|
358
|
+
info.startTimeSeconds = await clusterTimestampSeconds(reader, layout.firstClusterAt, layout);
|
|
359
|
+
}
|
|
360
|
+
this.mediaInfo = info;
|
|
216
361
|
return info;
|
|
217
362
|
}
|
|
218
363
|
|
|
219
364
|
/**
|
|
220
|
-
*
|
|
365
|
+
* Every track the file declares, in its order, read once.
|
|
221
366
|
*
|
|
222
|
-
* @
|
|
223
|
-
* @param {number} segmentDataOffset
|
|
224
|
-
* @returns {number} Nanoseconds per tick.
|
|
367
|
+
* @returns {Promise<import("../tracks/index.js").ContainerTrack[]>}
|
|
225
368
|
*/
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
369
|
+
async readTracks() {
|
|
370
|
+
if (this.#tracks !== null) {
|
|
371
|
+
return this.#tracks;
|
|
372
|
+
}
|
|
373
|
+
const layout = await this.#segmentLayout();
|
|
374
|
+
if (!layout || layout.tracksAt === null) {
|
|
375
|
+
this.#tracks = [];
|
|
376
|
+
return this.#tracks;
|
|
230
377
|
}
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
378
|
+
const reader = new ElementReader({ read: this.readRange, fileSize: this.fileSize, portionBytes: this.portionBytes });
|
|
379
|
+
const tracksHeader = await reader.header(layout.tracksAt, layout.segmentEnd);
|
|
380
|
+
if (!tracksHeader || tracksHeader.id !== ID_TRACKS || tracksHeader.end === null) {
|
|
381
|
+
this.#tracks = [];
|
|
382
|
+
return this.#tracks;
|
|
383
|
+
}
|
|
384
|
+
/** @type {import("../tracks/index.js").ContainerTrack[]} */
|
|
385
|
+
const result = [];
|
|
386
|
+
const counters = { video: -1, audio: -1, subtitle: -1 };
|
|
387
|
+
const within = this.#readerWithin(tracksHeader.end);
|
|
388
|
+
let at = tracksHeader.dataOffset;
|
|
389
|
+
while (at < tracksHeader.end) {
|
|
390
|
+
const entry = await within.header(at, tracksHeader.end);
|
|
391
|
+
if (!entry || entry.end === null) {
|
|
392
|
+
break;
|
|
236
393
|
}
|
|
394
|
+
if (entry.id === ID_TRACK_ENTRY) {
|
|
395
|
+
const data = await within.data(entry);
|
|
396
|
+
// An entry larger than one portion is refused rather than assembled.
|
|
397
|
+
// Its type cannot be known without its data, so it is kept as a track
|
|
398
|
+
// of no kind — which makes the count of its kind short by one, and
|
|
399
|
+
// `alignWithBanner` then refuses to line the two readings up rather
|
|
400
|
+
// than pairing them wrongly.
|
|
401
|
+
const fields = data ? parseTrackEntry(data) : { refused: true, trackNumber: null };
|
|
402
|
+
const track = trackFrom(fields, counters);
|
|
403
|
+
if (track) {
|
|
404
|
+
result.push(track);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
at = entry.end;
|
|
237
408
|
}
|
|
238
|
-
|
|
409
|
+
this.#tracks = result;
|
|
410
|
+
return result;
|
|
239
411
|
}
|
|
240
412
|
|
|
241
413
|
/**
|
|
242
|
-
* The
|
|
414
|
+
* The Cues table, read once, every CuePoint with every track it names.
|
|
243
415
|
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
* short read at the position it names.
|
|
248
|
-
*
|
|
249
|
-
* @param {Buffer} head
|
|
250
|
-
* @param {number} segmentDataOffset
|
|
251
|
-
* @param {number} scale - Nanoseconds per tick.
|
|
252
|
-
* @returns {Promise<number | null>} Null when no cluster could be read.
|
|
416
|
+
* @returns {Promise<{ points: Array<{ ticks: number, positions: Array<{ track: number, clusterAt: number }> }> } | null>}
|
|
417
|
+
* Null where the file has no Cues element — a statement about the file,
|
|
418
|
+
* kept. A Cues element that has not downloaded throws and is not kept.
|
|
253
419
|
*/
|
|
254
|
-
async
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
* @param {number} dataOffset
|
|
258
|
-
* @param {number} end
|
|
259
|
-
* @returns {number | null}
|
|
260
|
-
*/
|
|
261
|
-
const timestampIn = (buffer, dataOffset, end) => {
|
|
262
|
-
for (const field of iterateElements(buffer, dataOffset, end)) {
|
|
263
|
-
if (field.id === ID_TIMESTAMP) {
|
|
264
|
-
const ticks = readUint(buffer, field.dataOffset, field.size);
|
|
265
|
-
return Number.isFinite(ticks) ? (ticks * scale) / 1e9 : null;
|
|
266
|
-
}
|
|
267
|
-
// Timestamp is written before any frame. Stopping at the first one keeps
|
|
268
|
-
// this from walking a cluster's whole payload, which is megabytes and
|
|
269
|
-
// usually not in the buffer at all.
|
|
270
|
-
if (field.id === ID_SIMPLE_BLOCK || field.id === ID_BLOCK_GROUP) {
|
|
271
|
-
return null;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
return null;
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
for (const element of iterateElements(head, segmentDataOffset, head.length)) {
|
|
278
|
-
if (element.id !== ID_CLUSTER) {
|
|
279
|
-
continue;
|
|
280
|
-
}
|
|
281
|
-
return timestampIn(head, element.dataOffset, Math.min(head.length, element.dataOffset + element.size));
|
|
420
|
+
async readCues() {
|
|
421
|
+
if (this.#cues !== undefined) {
|
|
422
|
+
return this.#cues;
|
|
282
423
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
424
|
+
const layout = await this.#segmentLayout();
|
|
425
|
+
if (!layout || layout.cuesAt === null) {
|
|
426
|
+
this.#cues = null;
|
|
286
427
|
return null;
|
|
287
428
|
}
|
|
288
|
-
const
|
|
289
|
-
|
|
429
|
+
const reader = new ElementReader({ read: this.readRange, fileSize: this.fileSize, portionBytes: this.portionBytes });
|
|
430
|
+
const cues = await reader.header(layout.cuesAt, layout.segmentEnd);
|
|
431
|
+
if (!cues || cues.id !== ID_CUES || cues.end === null) {
|
|
432
|
+
// The SeekHead names a position with no Cues element at it. The file is
|
|
433
|
+
// wrong there, and the same bytes will say the same thing next time.
|
|
434
|
+
this.#cues = null;
|
|
290
435
|
return null;
|
|
291
436
|
}
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (
|
|
298
|
-
|
|
437
|
+
const points = [];
|
|
438
|
+
const within = this.#readerWithin(cues.end);
|
|
439
|
+
let at = cues.dataOffset;
|
|
440
|
+
while (at < cues.end) {
|
|
441
|
+
const point = await within.header(at, cues.end);
|
|
442
|
+
if (!point || point.end === null) {
|
|
443
|
+
break;
|
|
299
444
|
}
|
|
300
|
-
|
|
445
|
+
if (point.id === ID_CUE_POINT) {
|
|
446
|
+
const data = await within.data(point);
|
|
447
|
+
if (data) {
|
|
448
|
+
const parsed = parseCuePoint(data, layout.segmentDataOffset);
|
|
449
|
+
if (parsed) {
|
|
450
|
+
points.push(parsed);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
at = point.end;
|
|
301
455
|
}
|
|
302
|
-
|
|
456
|
+
this.#cues = { points };
|
|
457
|
+
return this.#cues;
|
|
303
458
|
}
|
|
304
459
|
|
|
305
460
|
/**
|
|
306
|
-
*
|
|
461
|
+
* This container's subtitle tracks, the Cues table and the clusters it
|
|
462
|
+
* names — everything the cue walk starts from.
|
|
307
463
|
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
* @returns {
|
|
464
|
+
* `cuesState` says whether the file has a Cues table at all; where it has
|
|
465
|
+
* none, the walk finds clusters from the downloaded bytes themselves.
|
|
466
|
+
*
|
|
467
|
+
* @returns {Promise<object | null>} Null for bytes that are not Matroska.
|
|
312
468
|
*/
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
if (!
|
|
469
|
+
async readSubtitlePlan() {
|
|
470
|
+
const layout = await this.#segmentLayout();
|
|
471
|
+
if (!layout) {
|
|
316
472
|
return null;
|
|
317
473
|
}
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
474
|
+
const allTracks = await this.readTracks();
|
|
475
|
+
const cues = await this.readCues();
|
|
476
|
+
const subtitles = allTracks.filter((track) => track.type === "subtitle");
|
|
477
|
+
const declared = subtitles.map((track) => ({
|
|
478
|
+
trackNumber: track.trackNumber,
|
|
479
|
+
codecId: track.codecId,
|
|
480
|
+
// The three-letter code: this list is lined up against ffmpeg's banner,
|
|
481
|
+
// which prints that form. The RFC 5646 tag rides beside it.
|
|
482
|
+
language: track.languageCode || track.language,
|
|
483
|
+
languageBcp47: track.languageBcp47,
|
|
484
|
+
declaresLanguage: track.declaresLanguage,
|
|
485
|
+
languageSource: track.languageSource,
|
|
486
|
+
name: track.name,
|
|
487
|
+
isDefault: track.isDefault,
|
|
488
|
+
declaresDefault: track.declaresDefault,
|
|
489
|
+
isEnabled: track.isEnabled,
|
|
490
|
+
isForced: track.isForced === true,
|
|
491
|
+
isHearingImpaired: track.isHearingImpaired === true
|
|
492
|
+
}));
|
|
493
|
+
/** @type {Map<number, Set<number>>} */
|
|
494
|
+
const byTrack = new Map();
|
|
495
|
+
/** @type {Map<number, number>} */
|
|
496
|
+
const entrySeconds = new Map();
|
|
497
|
+
for (const point of cues?.points ?? []) {
|
|
498
|
+
const seconds = point.ticks * layout.secondsPerTick;
|
|
499
|
+
for (const { track, clusterAt } of point.positions) {
|
|
500
|
+
if (!byTrack.has(track)) {
|
|
501
|
+
byTrack.set(track, new Set());
|
|
502
|
+
}
|
|
503
|
+
byTrack.get(track).add(clusterAt);
|
|
504
|
+
// The cluster begins at or before the earliest frame that names it.
|
|
505
|
+
const known = entrySeconds.get(clusterAt);
|
|
506
|
+
if (known === undefined || seconds < known) {
|
|
507
|
+
entrySeconds.set(clusterAt, seconds);
|
|
331
508
|
}
|
|
332
|
-
}
|
|
333
|
-
if (targetId === wantedId && position !== null) {
|
|
334
|
-
return position;
|
|
335
509
|
}
|
|
336
510
|
}
|
|
337
|
-
|
|
511
|
+
const tracks = subtitles
|
|
512
|
+
.filter((track) => TEXT_CODECS_MATROSKA.has(track.codecId) && track.isEnabled)
|
|
513
|
+
.map((track) => ({
|
|
514
|
+
trackNumber: track.trackNumber,
|
|
515
|
+
declaredIndex: track.declaredIndex,
|
|
516
|
+
codecId: track.codecId,
|
|
517
|
+
language: track.language,
|
|
518
|
+
languageBcp47: track.languageBcp47,
|
|
519
|
+
declaresLanguage: track.declaresLanguage,
|
|
520
|
+
languageSource: track.languageSource,
|
|
521
|
+
name: track.name,
|
|
522
|
+
isDefault: track.isDefault,
|
|
523
|
+
isForced: track.isForced === true,
|
|
524
|
+
isHearingImpaired: track.isHearingImpaired === true,
|
|
525
|
+
codecPrivate: track.codecPrivateB64,
|
|
526
|
+
clusterPositions: [...(byTrack.get(track.trackNumber) ?? [])].sort((left, right) => left - right)
|
|
527
|
+
}));
|
|
528
|
+
return {
|
|
529
|
+
tracks,
|
|
530
|
+
declared,
|
|
531
|
+
secondsPerTick: layout.secondsPerTick,
|
|
532
|
+
segmentDataOffset: layout.segmentDataOffset,
|
|
533
|
+
fileSize: this.fileSize,
|
|
534
|
+
segmentEnd: layout.segmentEnd,
|
|
535
|
+
firstClusterAt: layout.firstClusterAt,
|
|
536
|
+
cuesState: cues ? "complete" : "absent",
|
|
537
|
+
entryPoints: [...entrySeconds].map(([at, seconds]) => ({ at, seconds })).sort((left, right) => left.at - right.at),
|
|
538
|
+
subtitleTrackNumbers: tracks.map((track) => track.trackNumber),
|
|
539
|
+
allTrackNumbers: allTracks.map((track) => track.trackNumber).filter((number) => Number.isFinite(number))
|
|
540
|
+
};
|
|
338
541
|
}
|
|
339
542
|
|
|
340
|
-
|
|
341
543
|
/**
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* One walk for the whole file, not one per track: a Matroska cluster carries
|
|
346
|
-
* the blocks of every track that has anything to say over its span, so the
|
|
347
|
-
* bytes that answer one track answer them all. Reading them once per track
|
|
348
|
-
* meant the same cluster was fetched and parsed as many times as the film has
|
|
349
|
-
* subtitle tracks — measured 2026-08-20 on a film with five: five requests
|
|
350
|
-
* every fifteen seconds, each costing 0.2-5.2 s, for a few kilobytes of cues.
|
|
351
|
-
*
|
|
352
|
-
* Nothing is fetched. `isHeld` decides whether a cluster can be read at all,
|
|
353
|
-
* and one that is not here yet is left for the next call — turning subtitles
|
|
354
|
-
* on must not pull bytes the viewer is not waiting for.
|
|
544
|
+
* The cues this file holds now, for every text track at once — see
|
|
545
|
+
* `matroska-clusters.js` for how clusters are found and in what order read.
|
|
355
546
|
*
|
|
356
|
-
* @param {object} plan
|
|
357
|
-
* @param {
|
|
358
|
-
* @
|
|
359
|
-
*
|
|
547
|
+
* @param {object} plan
|
|
548
|
+
* @param {object} track
|
|
549
|
+
* @param {object} progress
|
|
550
|
+
* @param {import("./Container.js").HeldReader} held
|
|
551
|
+
* @returns {Promise<{ found: Map<number, object[]>, covered: number, indexed: number, withdrawn: number[], stats: object }>}
|
|
360
552
|
*/
|
|
361
|
-
async readHeldCues(plan, track, progress) {
|
|
362
|
-
const
|
|
363
|
-
return {
|
|
364
|
-
found,
|
|
365
|
-
// Every track is filled by the same walk, so this is a fact about the
|
|
366
|
-
// FILE and reads the same whichever track asked.
|
|
367
|
-
covered: progress.walked.size,
|
|
368
|
-
indexed: track?.clusterPositions?.length ?? 0
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
async walkHeldClusters(plan, walked) {
|
|
373
|
-
/** @type {Map<number, object[]>} */
|
|
553
|
+
async readHeldCues(plan, track, progress, held) {
|
|
554
|
+
const walked = await walkHeldClusters({ plan, progress, held });
|
|
374
555
|
const found = new Map();
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
for (const candidate of plan?.tracks ?? []) {
|
|
379
|
-
for (const position of candidate.clusterPositions ?? []) {
|
|
380
|
-
positions.add(position);
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
for (const position of [...positions].sort((left, right) => left - right)) {
|
|
384
|
-
if (walked.has(position)) {
|
|
385
|
-
continue;
|
|
386
|
-
}
|
|
387
|
-
// The header first: it says how long the cluster is, and a cluster whose
|
|
388
|
-
// bytes are not all here is left for the next time round.
|
|
389
|
-
const probeEnd = Math.min(this.fileSize - 1, position + CLUSTER_HEADER_PROBE - 1);
|
|
390
|
-
if (!this.isHeld(position, probeEnd)) {
|
|
391
|
-
continue;
|
|
392
|
-
}
|
|
393
|
-
const probe = await this.readHeld(position, probeEnd);
|
|
394
|
-
const header = probe && [...iterateElements(probe, 0, probe.length)][0];
|
|
395
|
-
if (!header || header.size <= 0 || header.size > MAX_CLUSTER_BYTES) {
|
|
396
|
-
walked.add(position); // not a cluster this can read; do not look again
|
|
397
|
-
continue;
|
|
398
|
-
}
|
|
399
|
-
const last = Math.min(this.fileSize - 1, position + header.dataOffset + header.size - 1);
|
|
400
|
-
if (!this.isHeld(position, last)) {
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
403
|
-
const bytes = await this.readHeld(position, last);
|
|
404
|
-
if (!bytes) {
|
|
556
|
+
for (const candidate of plan.tracks) {
|
|
557
|
+
const blocks = walked.found.get(candidate.trackNumber);
|
|
558
|
+
if (!blocks) {
|
|
405
559
|
continue;
|
|
406
560
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
// out of, and guessing from the field count is what showed the
|
|
419
|
-
// dialogue row's own fields to the viewer.
|
|
420
|
-
into.push({
|
|
421
|
-
startSeconds: block.startSeconds,
|
|
422
|
-
endSeconds: block.endSeconds,
|
|
423
|
-
text: MatroskaContainer.cueTextOf(block.payload, candidate.codecId)
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
found.set(candidate.trackNumber, into);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
return found;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
async readTracks() {
|
|
433
|
-
const head = await this.readRange(0, Math.min(HEAD_BYTES - 1, this.fileSize - 1));
|
|
434
|
-
if (!head || !isMatroska(head)) return [];
|
|
435
|
-
|
|
436
|
-
// Delegate to subtitle plan reader for subtitle tracks (already handles Flags spec-correct),
|
|
437
|
-
// but we need video/audio tracks too. Do a dedicated Tracks walk here for all types,
|
|
438
|
-
// then merge subtitle detail (clusterPositions, declaresDefault etc.) from the plan.
|
|
439
|
-
const seg = findElement(head, 0x18538067, []);
|
|
440
|
-
if (!seg) return [];
|
|
441
|
-
const tracksEl = findElement(head, ID_TRACKS, [], seg.dataOffset);
|
|
442
|
-
if (!tracksEl) return [];
|
|
443
|
-
|
|
444
|
-
const tracksEnd = Math.min(head.length, tracksEl.dataOffset + tracksEl.size);
|
|
445
|
-
/** @type {import("../tracks/index.js").ContainerTrack[]} */
|
|
446
|
-
const result = [];
|
|
447
|
-
// Subtitle declaredIndex is position among subtitle tracks, not global — track per-type counters.
|
|
448
|
-
let subtitleDeclaredIndex = -1;
|
|
449
|
-
let audioDeclaredIndex = -1;
|
|
450
|
-
let videoDeclaredIndex = -1;
|
|
451
|
-
|
|
452
|
-
// For subtitle flag enrichment, read the existing plan (it already does Cues walk)
|
|
453
|
-
let subtitlePlan = null;
|
|
454
|
-
try {
|
|
455
|
-
const shim = async (s, e) => this.readRange(s, Math.min(e, this.fileSize - 1));
|
|
456
|
-
subtitlePlan = await readSubtitlePlan(shim, this.fileSize);
|
|
457
|
-
} catch {
|
|
458
|
-
subtitlePlan = null;
|
|
459
|
-
}
|
|
460
|
-
const declaredByNumber = new Map();
|
|
461
|
-
const planTracksByNumber = new Map();
|
|
462
|
-
if (subtitlePlan?.declared) {
|
|
463
|
-
for (const d of subtitlePlan.declared) declaredByNumber.set(d.trackNumber, d);
|
|
464
|
-
}
|
|
465
|
-
if (subtitlePlan?.tracks) {
|
|
466
|
-
for (const t of subtitlePlan.tracks) planTracksByNumber.set(t.trackNumber, t);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
for (const entry of iterateElements(head, tracksEl.dataOffset, tracksEnd)) {
|
|
470
|
-
if (entry.id !== ID_TRACK_ENTRY) continue;
|
|
471
|
-
const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
|
|
472
|
-
let trackNumber = null;
|
|
473
|
-
let typeNum = null;
|
|
474
|
-
let codecId = "";
|
|
475
|
-
let language = "";
|
|
476
|
-
let languageBcp47 = "";
|
|
477
|
-
let name = "";
|
|
478
|
-
let codecPrivateB64 = "";
|
|
479
|
-
let isEnabled = true;
|
|
480
|
-
let isDefault = true;
|
|
481
|
-
let declaresDefault = false;
|
|
482
|
-
let isForced = false;
|
|
483
|
-
let isHearing = false;
|
|
484
|
-
let isVisual = false;
|
|
485
|
-
let isOriginal = false;
|
|
486
|
-
let isCommentary = false;
|
|
487
|
-
let pixelWidth = null;
|
|
488
|
-
let pixelHeight = null;
|
|
489
|
-
let displayWidth = null;
|
|
490
|
-
let displayHeight = null;
|
|
491
|
-
let samplingFreq = null;
|
|
492
|
-
let channels = null;
|
|
493
|
-
|
|
494
|
-
for (const f of iterateElements(head, entry.dataOffset, entryEnd)) {
|
|
495
|
-
switch (f.id) {
|
|
496
|
-
case ID_TRACK_NUMBER: trackNumber = readUint(head, f.dataOffset, f.size); break;
|
|
497
|
-
case ID_TRACK_TYPE: typeNum = readUint(head, f.dataOffset, f.size); break;
|
|
498
|
-
case ID_CODEC_ID: codecId = readString(head, f); break;
|
|
499
|
-
case ID_CODEC_PRIVATE: codecPrivateB64 = head.toString("base64", f.dataOffset, f.dataOffset + f.size); break;
|
|
500
|
-
case ID_LANGUAGE: language = readString(head, f); break;
|
|
501
|
-
case ID_LANGUAGE_BCP47: languageBcp47 = readString(head, f); break;
|
|
502
|
-
case ID_NAME: name = readString(head, f); break;
|
|
503
|
-
case ID_FLAG_ENABLED: isEnabled = f.size === 0 || readUint(head, f.dataOffset, f.size) !== 0; break;
|
|
504
|
-
case ID_FLAG_DEFAULT: isDefault = f.size === 0 || readUint(head, f.dataOffset, f.size) === 1; declaresDefault = true; break;
|
|
505
|
-
case ID_FLAG_FORCED: isForced = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
|
|
506
|
-
case ID_FLAG_HEARING: isHearing = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
|
|
507
|
-
case ID_FLAG_VISUAL: isVisual = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
|
|
508
|
-
case ID_FLAG_TEXT_DESCR: break;
|
|
509
|
-
case ID_FLAG_ORIGINAL: isOriginal = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
|
|
510
|
-
case ID_FLAG_COMMENTARY: isCommentary = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
|
|
511
|
-
default: break;
|
|
512
|
-
}
|
|
513
|
-
// Video/Audio sub-elements are nested, not at entry level — read separately below.
|
|
514
|
-
}
|
|
515
|
-
if (trackNumber === null) continue;
|
|
516
|
-
|
|
517
|
-
// Parse Video/Audio sub-elements if present
|
|
518
|
-
const videoEl = findElement(head, ID_VIDEO, [], entry.dataOffset, entryEnd);
|
|
519
|
-
if (videoEl) {
|
|
520
|
-
for (const vf of iterateElements(head, videoEl.dataOffset, Math.min(entryEnd, videoEl.dataOffset + videoEl.size))) {
|
|
521
|
-
if (vf.id === ID_PIXEL_WIDTH) pixelWidth = readUint(head, vf.dataOffset, vf.size);
|
|
522
|
-
else if (vf.id === ID_PIXEL_HEIGHT) pixelHeight = readUint(head, vf.dataOffset, vf.size);
|
|
523
|
-
else if (vf.id === ID_DISPLAY_WIDTH) displayWidth = readUint(head, vf.dataOffset, vf.size);
|
|
524
|
-
else if (vf.id === ID_DISPLAY_HEIGHT) displayHeight = readUint(head, vf.dataOffset, vf.size);
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
const audioEl = findElement(head, ID_AUDIO, [], entry.dataOffset, entryEnd);
|
|
528
|
-
if (audioEl) {
|
|
529
|
-
for (const af of iterateElements(head, audioEl.dataOffset, Math.min(entryEnd, audioEl.dataOffset + audioEl.size))) {
|
|
530
|
-
if (af.id === ID_SAMPLING_FREQUENCY) {
|
|
531
|
-
// SamplingFrequency is float64
|
|
532
|
-
if (af.size === 8) samplingFreq = head.readDoubleBE(af.dataOffset);
|
|
533
|
-
else samplingFreq = readUint(head, af.dataOffset, af.size);
|
|
534
|
-
} else if (af.id === ID_CHANNELS) channels = readUint(head, af.dataOffset, af.size);
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// RFC 9559 LanguageBCP47 MUST — when present, Language ignored
|
|
539
|
-
const resolvedLang = languageBcp47 || language;
|
|
540
|
-
const bcpTag = languageBcp47;
|
|
541
|
-
const declaresLanguage = resolvedLang.length > 0;
|
|
542
|
-
|
|
543
|
-
if (typeNum === 1) {
|
|
544
|
-
videoDeclaredIndex += 1;
|
|
545
|
-
result.push(new VideoTrack({
|
|
546
|
-
trackNumber,
|
|
547
|
-
declaredIndex: videoDeclaredIndex,
|
|
548
|
-
codecId,
|
|
549
|
-
language: resolvedLang,
|
|
550
|
-
languageBcp47: bcpTag,
|
|
551
|
-
declaresLanguage,
|
|
552
|
-
name,
|
|
553
|
-
isEnabled,
|
|
554
|
-
isDefault,
|
|
555
|
-
declaresDefault,
|
|
556
|
-
codecPrivateB64,
|
|
557
|
-
width: pixelWidth,
|
|
558
|
-
height: pixelHeight,
|
|
559
|
-
displayWidth,
|
|
560
|
-
displayHeight
|
|
561
|
-
}));
|
|
562
|
-
} else if (typeNum === 2) {
|
|
563
|
-
audioDeclaredIndex += 1;
|
|
564
|
-
result.push(new AudioTrack({
|
|
565
|
-
trackNumber,
|
|
566
|
-
declaredIndex: audioDeclaredIndex,
|
|
567
|
-
codecId,
|
|
568
|
-
language: resolvedLang,
|
|
569
|
-
languageBcp47: bcpTag,
|
|
570
|
-
declaresLanguage,
|
|
571
|
-
name,
|
|
572
|
-
isEnabled,
|
|
573
|
-
isDefault,
|
|
574
|
-
declaresDefault,
|
|
575
|
-
codecPrivateB64,
|
|
576
|
-
isOriginal,
|
|
577
|
-
isCommentary,
|
|
578
|
-
isVisualImpaired: isVisual,
|
|
579
|
-
channels,
|
|
580
|
-
samplingFrequency: samplingFreq
|
|
581
|
-
}));
|
|
582
|
-
} else if (typeNum === 17) {
|
|
583
|
-
subtitleDeclaredIndex += 1;
|
|
584
|
-
// Only Forced/Hearing belong to subtitles; Original/Commentary must not leak.
|
|
585
|
-
const declared = declaredByNumber.get(trackNumber);
|
|
586
|
-
const planTrack = planTracksByNumber.get(trackNumber);
|
|
587
|
-
// Prefer plan's flags when available (already spec-correct), else use parsed.
|
|
588
|
-
const finalForced = planTrack ? !!planTrack.isForced : isForced;
|
|
589
|
-
const finalHearing = planTrack ? !!planTrack.isHearingImpaired : isHearing;
|
|
590
|
-
const finalEnabled = declared ? declared.isEnabled !== false : isEnabled;
|
|
591
|
-
const finalDefault = declared ? !!declared.isDefault : isDefault;
|
|
592
|
-
const finalDeclares = declared ? !!declared.declaresDefault : declaresDefault;
|
|
593
|
-
const clusterPositions = planTrack ? planTrack.clusterPositions ?? [] : [];
|
|
594
|
-
const isText = TEXT_CODECS_MATROSKA.has(codecId);
|
|
595
|
-
// disabled image/text tracks still counted (declaredIndex above) — offerable flag controls visibility
|
|
596
|
-
if (isText && finalEnabled) {
|
|
597
|
-
result.push(new TextSubtitleTrack({
|
|
598
|
-
trackNumber,
|
|
599
|
-
declaredIndex: subtitleDeclaredIndex,
|
|
600
|
-
codecId,
|
|
601
|
-
language: resolvedLang,
|
|
602
|
-
languageBcp47: bcpTag,
|
|
603
|
-
declaresLanguage,
|
|
604
|
-
name,
|
|
605
|
-
isEnabled: finalEnabled,
|
|
606
|
-
isDefault: finalDefault,
|
|
607
|
-
declaresDefault: finalDeclares,
|
|
608
|
-
codecPrivateB64,
|
|
609
|
-
isForced: finalForced,
|
|
610
|
-
isHearingImpaired: finalHearing,
|
|
611
|
-
clusterPositions
|
|
612
|
-
}));
|
|
613
|
-
} else {
|
|
614
|
-
// Image or disabled — keep declaredIndex, not offerable if disabled
|
|
615
|
-
const Target = isText ? TextSubtitleTrack : ImageSubtitleTrack;
|
|
616
|
-
result.push(new Target({
|
|
617
|
-
trackNumber,
|
|
618
|
-
declaredIndex: subtitleDeclaredIndex,
|
|
619
|
-
codecId,
|
|
620
|
-
language: resolvedLang,
|
|
621
|
-
languageBcp47: bcpTag,
|
|
622
|
-
declaresLanguage,
|
|
623
|
-
name,
|
|
624
|
-
isEnabled: finalEnabled,
|
|
625
|
-
isDefault: finalDefault,
|
|
626
|
-
declaresDefault: finalDeclares,
|
|
627
|
-
codecPrivateB64,
|
|
628
|
-
isForced: finalForced,
|
|
629
|
-
isHearingImpaired: finalHearing,
|
|
630
|
-
clusterPositions: isText ? clusterPositions : []
|
|
631
|
-
}));
|
|
632
|
-
}
|
|
633
|
-
} else {
|
|
634
|
-
// Other TrackType (complex, logo, buttons, control) — keep as generic, not video
|
|
635
|
-
result.push(new ContainerTrack({
|
|
636
|
-
trackNumber,
|
|
637
|
-
declaredIndex: -1,
|
|
638
|
-
type: "other",
|
|
639
|
-
codecId,
|
|
640
|
-
language: resolvedLang,
|
|
641
|
-
languageBcp47: bcpTag,
|
|
642
|
-
declaresLanguage,
|
|
643
|
-
name,
|
|
644
|
-
isEnabled,
|
|
645
|
-
isDefault,
|
|
646
|
-
declaresDefault,
|
|
647
|
-
codecPrivateB64
|
|
648
|
-
}));
|
|
649
|
-
}
|
|
561
|
+
found.set(
|
|
562
|
+
candidate.trackNumber,
|
|
563
|
+
blocks.map((block) => ({
|
|
564
|
+
startSeconds: block.startSeconds,
|
|
565
|
+
endSeconds: block.endSeconds,
|
|
566
|
+
// The block's bytes become text HERE, where the container that
|
|
567
|
+
// framed them is known.
|
|
568
|
+
text: MatroskaContainer.cueTextOf(block.payload, candidate.codecId),
|
|
569
|
+
source: block.source
|
|
570
|
+
}))
|
|
571
|
+
);
|
|
650
572
|
}
|
|
651
|
-
return
|
|
573
|
+
return { ...walked, found };
|
|
652
574
|
}
|
|
653
575
|
|
|
654
576
|
/**
|
|
@@ -670,9 +592,7 @@ export class MatroskaContainer extends Container {
|
|
|
670
592
|
* back together.
|
|
671
593
|
*
|
|
672
594
|
* `S_TEXT/UTF8` and `S_TEXT/WEBVTT` have no such framing: the block holds the
|
|
673
|
-
* cue text and nothing else.
|
|
674
|
-
* preceding comments live in a BlockAddition, which this proxy does not read;
|
|
675
|
-
* losing them costs positioning, not words.)
|
|
595
|
+
* cue text and nothing else.
|
|
676
596
|
*
|
|
677
597
|
* @param {Buffer} payload - The block's own bytes.
|
|
678
598
|
* @param {string} codecId - Matroska CodecID of the track the block belongs to.
|
|
@@ -687,146 +607,361 @@ export class MatroskaContainer extends Container {
|
|
|
687
607
|
return fields.length > ASS_FIELDS_BEFORE_TEXT ? fields.slice(ASS_FIELDS_BEFORE_TEXT).join(",") : "";
|
|
688
608
|
}
|
|
689
609
|
|
|
610
|
+
/**
|
|
611
|
+
* The keyframe times of the first video track, from the one Cues reading.
|
|
612
|
+
*
|
|
613
|
+
* A CuePoint belongs to the track named inside its CueTrackPositions, and a
|
|
614
|
+
* muxer indexes whatever tracks it likes — field files index their subtitle
|
|
615
|
+
* tracks too, and read without the track those times entered the cut list as
|
|
616
|
+
* though they were keyframes (2026-08-18). Where the filter leaves nothing
|
|
617
|
+
* although a table exists, the unfiltered table is used: less exact than the
|
|
618
|
+
* picture's own keyframes, better than an even grid that has nothing to do
|
|
619
|
+
* with the file.
|
|
620
|
+
*
|
|
621
|
+
* @returns {Promise<{ times: number[], tolerance: number } | null>}
|
|
622
|
+
*/
|
|
690
623
|
async parseKeyframeIndex() {
|
|
691
|
-
const
|
|
692
|
-
if (!
|
|
693
|
-
|
|
694
|
-
|
|
624
|
+
const layout = await this.#segmentLayout();
|
|
625
|
+
if (!layout) {
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
const cues = await this.readCues();
|
|
629
|
+
if (!cues) {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
const tracks = await this.readTracks();
|
|
633
|
+
const video = tracks.find((track) => track.type === "video")?.trackNumber ?? null;
|
|
634
|
+
const timesFor = (wanted) =>
|
|
635
|
+
cues.points
|
|
636
|
+
.filter((point) => wanted === null || point.positions.some((position) => position.track === wanted))
|
|
637
|
+
.map((point) => point.ticks * layout.secondsPerTick)
|
|
638
|
+
.sort((left, right) => left - right);
|
|
639
|
+
const times = timesFor(video);
|
|
640
|
+
if (times.length > 0) {
|
|
641
|
+
return { times, tolerance: 0 };
|
|
642
|
+
}
|
|
643
|
+
if (video === null) {
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
const unfiltered = timesFor(null);
|
|
647
|
+
return unfiltered.length > 0 ? { times: unfiltered, tolerance: 0 } : null;
|
|
695
648
|
}
|
|
696
649
|
}
|
|
697
650
|
|
|
698
|
-
// ---------------------------------------------------------------------------
|
|
699
|
-
// Matroska's own reading of its subtitle tracks and its clusters. It lives in
|
|
700
|
-
// this module because every line of it is a statement of RFC 9559 about how
|
|
701
|
-
// this container stores a subtitle, and the class is the only way in.
|
|
702
|
-
// ---------------------------------------------------------------------------
|
|
703
|
-
|
|
704
|
-
const ID_BLOCK = 0xa1;
|
|
705
|
-
const ID_BLOCK_DURATION = 0x9b;
|
|
706
|
-
|
|
707
|
-
/** Bits 1-2 of the flags byte say how a block is laced, or that it is not. */
|
|
708
|
-
const LACING_MASK = 0x06;
|
|
709
|
-
const LACING_NONE = 0x00;
|
|
710
|
-
const LACING_XIPH = 0x02;
|
|
711
|
-
const LACING_FIXED = 0x04;
|
|
712
|
-
const LACING_EBML = 0x06;
|
|
713
|
-
|
|
714
651
|
/**
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
* @
|
|
718
|
-
*
|
|
719
|
-
* @property {Buffer} payload - The block's own bytes, still in the codec's form.
|
|
652
|
+
* Whether this looks like a Matroska file (the EBML magic `0x1A45DFA3`).
|
|
653
|
+
*
|
|
654
|
+
* @param {Buffer} head
|
|
655
|
+
* @returns {boolean}
|
|
720
656
|
*/
|
|
657
|
+
function isMatroska(head) {
|
|
658
|
+
return head.length >= 4 && head.readUInt32BE(0) === ID_EBML;
|
|
659
|
+
}
|
|
721
660
|
|
|
722
661
|
/**
|
|
723
|
-
*
|
|
662
|
+
* The Segment element's header, from the top-level elements of the head.
|
|
724
663
|
*
|
|
725
|
-
* @param {Buffer}
|
|
726
|
-
* @
|
|
727
|
-
*
|
|
728
|
-
* @returns {{ trackNumber: number, relativeTicks: number, flags: number, dataOffset: number } | null}
|
|
664
|
+
* @param {Buffer} head
|
|
665
|
+
* @returns {{ dataOffset: number, size: number | null } | null} `size` is null
|
|
666
|
+
* for an unknown-sized Segment (RFC 8794 §6.2), which runs to the file's end.
|
|
729
667
|
*/
|
|
730
|
-
function
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
668
|
+
function segmentHeaderIn(head) {
|
|
669
|
+
let offset = 0;
|
|
670
|
+
while (offset < head.length) {
|
|
671
|
+
const id = readVint(head, offset, true);
|
|
672
|
+
const size = id && readVint(head, offset + id.length, false);
|
|
673
|
+
if (!id || !size) {
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
const dataOffset = offset + id.length + size.length;
|
|
677
|
+
if (id.value === ID_SEGMENT) {
|
|
678
|
+
return { dataOffset, size: sizeIsUnknown(size) ? null : size.value };
|
|
679
|
+
}
|
|
680
|
+
offset = dataOffset + size.value;
|
|
734
681
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* The SeekHead's entries, id to position relative to the Segment's data.
|
|
687
|
+
*
|
|
688
|
+
* @param {Buffer} data - The SeekHead's data.
|
|
689
|
+
* @returns {Map<number, number>}
|
|
690
|
+
*/
|
|
691
|
+
function readSeekEntries(data) {
|
|
692
|
+
const entries = new Map();
|
|
693
|
+
for (const seek of iterateElements(data)) {
|
|
694
|
+
if (seek.id !== ID_SEEK) {
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const end = Math.min(data.length, seek.dataOffset + seek.size);
|
|
698
|
+
let targetId = null;
|
|
699
|
+
let position = null;
|
|
700
|
+
for (const field of iterateElements(data, seek.dataOffset, end)) {
|
|
701
|
+
if (field.id === ID_SEEK_ID) {
|
|
702
|
+
targetId = readUint(data, field.dataOffset, field.size);
|
|
703
|
+
} else if (field.id === ID_SEEK_POSITION) {
|
|
704
|
+
position = readUint(data, field.dataOffset, field.size);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (targetId !== null && position !== null && !entries.has(targetId)) {
|
|
708
|
+
entries.set(targetId, position);
|
|
709
|
+
}
|
|
740
710
|
}
|
|
741
|
-
return
|
|
742
|
-
trackNumber: Number(track.value),
|
|
743
|
-
relativeTicks: buffer.readInt16BE(timestampAt),
|
|
744
|
-
flags: buffer[timestampAt + 2],
|
|
745
|
-
dataOffset: timestampAt + 3
|
|
746
|
-
};
|
|
711
|
+
return entries;
|
|
747
712
|
}
|
|
748
713
|
|
|
749
714
|
/**
|
|
750
|
-
*
|
|
715
|
+
* The timestamp of a cluster, from its leading children.
|
|
751
716
|
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
717
|
+
* §5.1.3.1: the Timestamp SHOULD be the first child, or the second after a
|
|
718
|
+
* CRC-32. Children before it are stepped over by their headers; reading stops
|
|
719
|
+
* at the first block, so a cluster's megabytes are never pulled for this.
|
|
755
720
|
*
|
|
756
|
-
* @param {
|
|
757
|
-
* @param {number}
|
|
758
|
-
* @param {
|
|
759
|
-
* @
|
|
760
|
-
* @returns {number | null} The offset of the first frame, or null when the
|
|
761
|
-
* lacing cannot be read.
|
|
721
|
+
* @param {ElementReader} reader
|
|
722
|
+
* @param {number} at
|
|
723
|
+
* @param {SegmentLayout} layout
|
|
724
|
+
* @returns {Promise<number | null>}
|
|
762
725
|
*/
|
|
763
|
-
function
|
|
764
|
-
const
|
|
765
|
-
if (
|
|
766
|
-
return dataOffset;
|
|
767
|
-
}
|
|
768
|
-
if (dataOffset >= end) {
|
|
726
|
+
async function clusterTimestampSeconds(reader, at, layout) {
|
|
727
|
+
const cluster = await reader.header(at, layout.segmentEnd);
|
|
728
|
+
if (!cluster || cluster.id !== ID_CLUSTER) {
|
|
769
729
|
return null;
|
|
770
730
|
}
|
|
771
|
-
const
|
|
772
|
-
let
|
|
773
|
-
|
|
774
|
-
|
|
731
|
+
const limit = cluster.end ?? layout.segmentEnd;
|
|
732
|
+
let cursor = cluster.dataOffset;
|
|
733
|
+
while (cursor < limit) {
|
|
734
|
+
const child = await reader.header(cursor, limit);
|
|
735
|
+
if (!child || child.size === null) {
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
if (child.id === ID_TIMESTAMP) {
|
|
739
|
+
const bytes = await reader.bytes(child.dataOffset, child.size);
|
|
740
|
+
return readUint(bytes, 0, child.size) * layout.secondsPerTick;
|
|
741
|
+
}
|
|
742
|
+
if (child.id === ID_SIMPLE_BLOCK || child.id === ID_BLOCK_GROUP) {
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
cursor = child.end;
|
|
775
746
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* One CuePoint: its time and every track position it names.
|
|
752
|
+
*
|
|
753
|
+
* @param {Buffer} data
|
|
754
|
+
* @param {number} segmentDataOffset - CueClusterPosition is relative to it.
|
|
755
|
+
* @returns {{ ticks: number, positions: Array<{ track: number, clusterAt: number }> } | null}
|
|
756
|
+
*/
|
|
757
|
+
function parseCuePoint(data, segmentDataOffset) {
|
|
758
|
+
let ticks = null;
|
|
759
|
+
const positions = [];
|
|
760
|
+
for (const field of iterateElements(data)) {
|
|
761
|
+
if (field.id === ID_CUE_TIME) {
|
|
762
|
+
ticks = readUint(data, field.dataOffset, field.size);
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
if (field.id !== ID_CUE_TRACK_POSITIONS) {
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
const end = Math.min(data.length, field.dataOffset + field.size);
|
|
769
|
+
let track = null;
|
|
770
|
+
let cluster = null;
|
|
771
|
+
for (const inner of iterateElements(data, field.dataOffset, end)) {
|
|
772
|
+
if (inner.id === ID_CUE_TRACK) {
|
|
773
|
+
track = readUint(data, inner.dataOffset, inner.size);
|
|
774
|
+
} else if (inner.id === ID_CUE_CLUSTER_POSITION) {
|
|
775
|
+
cluster = readUint(data, inner.dataOffset, inner.size);
|
|
781
776
|
}
|
|
782
|
-
at += 1;
|
|
783
777
|
}
|
|
784
|
-
|
|
778
|
+
if (track !== null && cluster !== null) {
|
|
779
|
+
positions.push({ track, clusterAt: segmentDataOffset + cluster });
|
|
780
|
+
}
|
|
785
781
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
782
|
+
return ticks === null ? null : { ticks, positions };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Every field of one TrackEntry this proxy reads, with RFC 9559's defaults.
|
|
787
|
+
*
|
|
788
|
+
* @param {Buffer} data - The TrackEntry's data.
|
|
789
|
+
* @returns {object}
|
|
790
|
+
*/
|
|
791
|
+
function parseTrackEntry(data) {
|
|
792
|
+
const fields = {
|
|
793
|
+
refused: false,
|
|
794
|
+
trackNumber: null,
|
|
795
|
+
type: null,
|
|
796
|
+
codecId: "",
|
|
797
|
+
language: null,
|
|
798
|
+
languageBcp47: "",
|
|
799
|
+
name: "",
|
|
800
|
+
codecPrivateB64: "",
|
|
801
|
+
isEnabled: true,
|
|
802
|
+
// FlagDefault DEFAULTS TO 1; whether the file wrote it is kept apart,
|
|
803
|
+
// because once the default is applied "no track marked" and "every track
|
|
804
|
+
// marked" look the same.
|
|
805
|
+
isDefault: true,
|
|
806
|
+
declaresDefault: false,
|
|
807
|
+
isForced: false,
|
|
808
|
+
isHearing: false,
|
|
809
|
+
isVisual: false,
|
|
810
|
+
isOriginal: false,
|
|
811
|
+
isCommentary: false,
|
|
812
|
+
pixelWidth: null,
|
|
813
|
+
pixelHeight: null,
|
|
814
|
+
displayWidth: null,
|
|
815
|
+
displayHeight: null,
|
|
816
|
+
samplingFreq: null,
|
|
817
|
+
channels: null
|
|
818
|
+
};
|
|
819
|
+
for (const f of iterateElements(data)) {
|
|
820
|
+
switch (f.id) {
|
|
821
|
+
case ID_TRACK_NUMBER: fields.trackNumber = readUint(data, f.dataOffset, f.size); break;
|
|
822
|
+
case ID_TRACK_TYPE: fields.type = readUint(data, f.dataOffset, f.size); break;
|
|
823
|
+
case ID_CODEC_ID: fields.codecId = readString(data, f); break;
|
|
824
|
+
case ID_CODEC_PRIVATE: fields.codecPrivateB64 = data.toString("base64", f.dataOffset, f.dataOffset + f.size); break;
|
|
825
|
+
// An empty element carries its default (RFC 8794 §6.3 and §11.1.19).
|
|
826
|
+
case ID_LANGUAGE: fields.language = f.size === 0 ? DEFAULT_LANGUAGE : readString(data, f); break;
|
|
827
|
+
case ID_LANGUAGE_BCP47: fields.languageBcp47 = readString(data, f); break;
|
|
828
|
+
case ID_NAME: fields.name = readString(data, f); break;
|
|
829
|
+
// Zero length carries the default, which is 1; only an explicit zero
|
|
830
|
+
// takes a track away.
|
|
831
|
+
case ID_FLAG_ENABLED: fields.isEnabled = f.size === 0 || readUint(data, f.dataOffset, f.size) !== 0; break;
|
|
832
|
+
case ID_FLAG_DEFAULT: fields.isDefault = f.size === 0 || readUint(data, f.dataOffset, f.size) === 1; fields.declaresDefault = true; break;
|
|
833
|
+
case ID_FLAG_FORCED: fields.isForced = f.size > 0 && readUint(data, f.dataOffset, f.size) !== 0; break;
|
|
834
|
+
case ID_FLAG_HEARING: fields.isHearing = f.size > 0 && readUint(data, f.dataOffset, f.size) !== 0; break;
|
|
835
|
+
case ID_FLAG_VISUAL: fields.isVisual = f.size > 0 && readUint(data, f.dataOffset, f.size) !== 0; break;
|
|
836
|
+
case ID_FLAG_TEXT_DESCR: break;
|
|
837
|
+
case ID_FLAG_ORIGINAL: fields.isOriginal = f.size > 0 && readUint(data, f.dataOffset, f.size) !== 0; break;
|
|
838
|
+
case ID_FLAG_COMMENTARY: fields.isCommentary = f.size > 0 && readUint(data, f.dataOffset, f.size) !== 0; break;
|
|
839
|
+
case ID_VIDEO: {
|
|
840
|
+
const end = Math.min(data.length, f.dataOffset + f.size);
|
|
841
|
+
for (const vf of iterateElements(data, f.dataOffset, end)) {
|
|
842
|
+
if (vf.id === ID_PIXEL_WIDTH) fields.pixelWidth = readUint(data, vf.dataOffset, vf.size);
|
|
843
|
+
else if (vf.id === ID_PIXEL_HEIGHT) fields.pixelHeight = readUint(data, vf.dataOffset, vf.size);
|
|
844
|
+
else if (vf.id === ID_DISPLAY_WIDTH) fields.displayWidth = readUint(data, vf.dataOffset, vf.size);
|
|
845
|
+
else if (vf.id === ID_DISPLAY_HEIGHT) fields.displayHeight = readUint(data, vf.dataOffset, vf.size);
|
|
846
|
+
}
|
|
847
|
+
break;
|
|
793
848
|
}
|
|
794
|
-
|
|
849
|
+
case ID_AUDIO: {
|
|
850
|
+
const end = Math.min(data.length, f.dataOffset + f.size);
|
|
851
|
+
for (const af of iterateElements(data, f.dataOffset, end)) {
|
|
852
|
+
if (af.id === ID_SAMPLING_FREQUENCY) {
|
|
853
|
+
fields.samplingFreq = af.size === 8 ? data.readDoubleBE(af.dataOffset) : readFloat(data, af.dataOffset, af.size);
|
|
854
|
+
} else if (af.id === ID_CHANNELS) {
|
|
855
|
+
fields.channels = readUint(data, af.dataOffset, af.size);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
break;
|
|
859
|
+
}
|
|
860
|
+
default: break;
|
|
795
861
|
}
|
|
796
|
-
return at <= end ? at : null;
|
|
797
862
|
}
|
|
798
|
-
return
|
|
863
|
+
return fields;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* The track object one TrackEntry describes.
|
|
868
|
+
*
|
|
869
|
+
* The language follows RFC 9559's order: a `LanguageBCP47` element wins and
|
|
870
|
+
* `Language` is then ignored; otherwise `Language`; otherwise its default,
|
|
871
|
+
* `eng` — which a reader MUST apply (RFC 8794 §11.1.19). Where it came from is
|
|
872
|
+
* kept as `languageSource`.
|
|
873
|
+
*
|
|
874
|
+
* @param {object} fields - From {@link parseTrackEntry}.
|
|
875
|
+
* @param {{ video: number, audio: number, subtitle: number }} counters
|
|
876
|
+
* @returns {import("../tracks/ContainerTrack.js").ContainerTrack | null}
|
|
877
|
+
*/
|
|
878
|
+
function trackFrom(fields, counters) {
|
|
879
|
+
if (fields.trackNumber === null && !fields.refused) {
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
const declaresLanguage = fields.languageBcp47.length > 0 || fields.language !== null;
|
|
883
|
+
const languageSource = fields.languageBcp47 ? "bcp47" : fields.language !== null ? "language" : "default";
|
|
884
|
+
const language = fields.languageBcp47 || (fields.language !== null ? fields.language : DEFAULT_LANGUAGE);
|
|
885
|
+
const common = {
|
|
886
|
+
trackNumber: fields.trackNumber,
|
|
887
|
+
codecId: fields.codecId,
|
|
888
|
+
language,
|
|
889
|
+
languageBcp47: fields.languageBcp47,
|
|
890
|
+
declaresLanguage,
|
|
891
|
+
languageSource,
|
|
892
|
+
languageCode: fields.language !== null ? fields.language : DEFAULT_LANGUAGE,
|
|
893
|
+
name: fields.name,
|
|
894
|
+
isEnabled: fields.isEnabled,
|
|
895
|
+
isDefault: fields.isDefault,
|
|
896
|
+
declaresDefault: fields.declaresDefault,
|
|
897
|
+
codecPrivateB64: fields.codecPrivateB64
|
|
898
|
+
};
|
|
899
|
+
if (fields.type === TRACK_TYPE_VIDEO) {
|
|
900
|
+
counters.video += 1;
|
|
901
|
+
return new VideoTrack({
|
|
902
|
+
...common,
|
|
903
|
+
declaredIndex: counters.video,
|
|
904
|
+
width: fields.pixelWidth,
|
|
905
|
+
height: fields.pixelHeight,
|
|
906
|
+
displayWidth: fields.displayWidth,
|
|
907
|
+
displayHeight: fields.displayHeight
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
if (fields.type === TRACK_TYPE_AUDIO) {
|
|
911
|
+
counters.audio += 1;
|
|
912
|
+
return new AudioTrack({
|
|
913
|
+
...common,
|
|
914
|
+
declaredIndex: counters.audio,
|
|
915
|
+
isOriginal: fields.isOriginal,
|
|
916
|
+
isCommentary: fields.isCommentary,
|
|
917
|
+
isVisualImpaired: fields.isVisual,
|
|
918
|
+
channels: fields.channels,
|
|
919
|
+
samplingFrequency: fields.samplingFreq
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
if (fields.type === TRACK_TYPE_SUBTITLE) {
|
|
923
|
+
counters.subtitle += 1;
|
|
924
|
+
// A track the file marks unusable is still COUNTED: ffmpeg creates and
|
|
925
|
+
// numbers a stream for it, so leaving it out would shift `declaredIndex`
|
|
926
|
+
// off `0:s:N` for every track after it. It is refused where it is offered.
|
|
927
|
+
const isText = TEXT_CODECS_MATROSKA.has(fields.codecId);
|
|
928
|
+
const Target = isText ? TextSubtitleTrack : ImageSubtitleTrack;
|
|
929
|
+
return new Target({
|
|
930
|
+
...common,
|
|
931
|
+
declaredIndex: counters.subtitle,
|
|
932
|
+
isForced: fields.isForced,
|
|
933
|
+
isHearingImpaired: fields.isHearing,
|
|
934
|
+
clusterPositions: []
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
return new ContainerTrack({ ...common, declaredIndex: -1, type: "other" });
|
|
799
938
|
}
|
|
800
939
|
|
|
801
940
|
/**
|
|
802
|
-
* Every block of one track inside one cluster.
|
|
941
|
+
* Every block of one track inside one cluster held whole in a buffer.
|
|
803
942
|
*
|
|
804
943
|
* @param {Buffer} buffer - Bytes holding the cluster's payload.
|
|
805
944
|
* @param {{ dataOffset: number, size: number }} cluster - Where that payload is.
|
|
806
945
|
* @param {number} trackNumber - The track to keep.
|
|
807
946
|
* @param {number} secondsPerTick - From the segment's timestamp scale.
|
|
808
|
-
* @returns {
|
|
947
|
+
* @returns {{ startSeconds: number, durationSeconds: number | null, payload: Buffer }[]}
|
|
809
948
|
*/
|
|
810
949
|
function blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick) {
|
|
811
950
|
const end = Math.min(buffer.length, cluster.dataOffset + cluster.size);
|
|
812
|
-
/** @type {SubtitleBlock[]} */
|
|
813
951
|
const blocks = [];
|
|
814
952
|
let clusterTicks = null;
|
|
953
|
+
const pending = [];
|
|
815
954
|
|
|
816
955
|
const take = (blockStart, blockEnd, durationTicks) => {
|
|
817
956
|
const header = readBlockHeader(buffer, blockStart, blockEnd);
|
|
818
|
-
if (!header || header.trackNumber !== trackNumber
|
|
957
|
+
if (!header || header.trackNumber !== trackNumber) {
|
|
819
958
|
return;
|
|
820
959
|
}
|
|
821
960
|
const payloadAt = firstFrameOffset(buffer, header.dataOffset, blockEnd, header.flags);
|
|
822
961
|
if (payloadAt === null || payloadAt >= blockEnd) {
|
|
823
962
|
return;
|
|
824
963
|
}
|
|
825
|
-
|
|
826
|
-
startSeconds: (clusterTicks + header.relativeTicks) * secondsPerTick,
|
|
827
|
-
durationSeconds: durationTicks === null ? null : durationTicks * secondsPerTick,
|
|
828
|
-
payload: buffer.subarray(payloadAt, blockEnd)
|
|
829
|
-
});
|
|
964
|
+
pending.push({ relativeTicks: header.relativeTicks, durationTicks, payload: buffer.subarray(payloadAt, blockEnd) });
|
|
830
965
|
};
|
|
831
966
|
|
|
832
967
|
for (const element of iterateElements(buffer, cluster.dataOffset, end)) {
|
|
@@ -842,9 +977,6 @@ function blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick) {
|
|
|
842
977
|
if (element.id !== ID_BLOCK_GROUP) {
|
|
843
978
|
continue;
|
|
844
979
|
}
|
|
845
|
-
// A group holds the block and, for a subtitle, the duration that says when
|
|
846
|
-
// the cue leaves the screen. Both are read before either is used, because
|
|
847
|
-
// the duration may be written after the block.
|
|
848
980
|
let blockStart = null;
|
|
849
981
|
let blockEnd = null;
|
|
850
982
|
let durationTicks = null;
|
|
@@ -861,634 +993,39 @@ function blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick) {
|
|
|
861
993
|
take(blockStart, blockEnd, durationTicks);
|
|
862
994
|
}
|
|
863
995
|
}
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
/**
|
|
869
|
-
* The rest of what a TrackEntry says about itself, RFC 9559 §5.1.4.1. Read
|
|
870
|
-
* because the file states them and a releaser's own wording in `Name` is the
|
|
871
|
-
* only thing we had before: "fors" and "SDH" in a menu were whatever text
|
|
872
|
-
* someone happened to type.
|
|
873
|
-
*
|
|
874
|
-
* `FlagEnabled` defaults to 1 and means "the track is usable"; a track that
|
|
875
|
-
* says 0 is counted but not offered. `FlagForced` applies only to subtitles and
|
|
876
|
-
* defaults to 0. `FlagHearingImpaired` is set "if and only if the track is
|
|
877
|
-
* suitable for users with hearing impairments". `FlagVisualImpaired`,
|
|
878
|
-
* `FlagOriginal` and `FlagCommentary` bear on the AUDIO choice and are read
|
|
879
|
-
* with that work, not here — see roadmap item 55.
|
|
880
|
-
*/
|
|
881
|
-
const ID_FLAG_HEARING_IMPAIRED = 0x55ab;
|
|
882
|
-
/**
|
|
883
|
-
* The language as RFC 5646 writes it. The specification is a MUST: "If this
|
|
884
|
-
* element is used, then any Language elements used in the same TrackEntry MUST
|
|
885
|
-
* be ignored" — so where both are present, this one is the answer and the
|
|
886
|
-
* three-letter code is not.
|
|
887
|
-
*/
|
|
888
|
-
const ID_CUES = 0x1c53bb6b;
|
|
889
|
-
const ID_CUE_POINT = 0xbb;
|
|
890
|
-
const ID_CUE_TRACK_POSITIONS = 0xb7;
|
|
891
|
-
const ID_CUE_TRACK = 0xf7;
|
|
892
|
-
const ID_CUE_CLUSTER_POSITION = 0xf1;
|
|
893
|
-
|
|
894
|
-
/** TrackType 17 is subtitles; 1 is video and 2 audio. */
|
|
895
|
-
const TRACK_TYPE_SUBTITLE = 17;
|
|
896
|
-
/** How much of the file start to read: the same window the keyframe reader uses. */
|
|
897
|
-
/** Cap on the Cues read; a long film indexes to tens of KB. */
|
|
898
|
-
const MAX_CUES_BYTES = 8 * 1024 * 1024;
|
|
899
|
-
|
|
900
|
-
/**
|
|
901
|
-
* The codecs whose blocks are text this proxy can turn into WebVTT.
|
|
902
|
-
*
|
|
903
|
-
* `S_TEXT/UTF8` is a plain line of text and needs nothing. `S_TEXT/ASS` and
|
|
904
|
-
* `S_TEXT/SSA` carry a dialogue row whose fields have to be stripped, and their
|
|
905
|
-
* header lives in CodecPrivate — supported, with the stripping done where the
|
|
906
|
-
* cue is turned into WebVTT. `S_HDMV/PGS` and `S_VOBSUB` are pictures, not
|
|
907
|
-
* text, and are deliberately absent: offering them would promise something this
|
|
908
|
-
* path cannot deliver.
|
|
909
|
-
*/
|
|
910
|
-
const TEXT_CODECS = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA"]);
|
|
911
|
-
|
|
912
|
-
/**
|
|
913
|
-
* @typedef {object} SubtitleTrackPlan
|
|
914
|
-
* @property {number} trackNumber - As the blocks name it.
|
|
915
|
-
* @property {number} declaredIndex - Its position among ALL of the file's
|
|
916
|
-
* subtitle tracks, picture-based ones included — which is the number ffmpeg
|
|
917
|
-
* gives the same stream in `0:s:N`, and therefore the only number the browser
|
|
918
|
-
* ever names. Text tracks alone are not a numbering: a file whose PGS track
|
|
919
|
-
* comes first would have every text track one lower here than in the browser.
|
|
920
|
-
* @property {string} codecId
|
|
921
|
-
* @property {string} language - The language the file declares: its RFC 5646
|
|
922
|
-
* tag where it writes one, and the three-letter code otherwise. The
|
|
923
|
-
* specification requires that order — where `LanguageBCP47` is present, the
|
|
924
|
-
* `Language` element MUST be ignored.
|
|
925
|
-
* @property {string} languageBcp47 - The RFC 5646 tag alone, or "".
|
|
926
|
-
* @property {string} name - What the file calls the track, if anything.
|
|
927
|
-
* @property {boolean} isDefault
|
|
928
|
-
* @property {boolean} isForced - `FlagForced`: the track carries what a viewer
|
|
929
|
-
* needs even when they asked for no subtitles — signs, and dialogue in
|
|
930
|
-
* another language. It does NOT carry the film's own dialogue.
|
|
931
|
-
* @property {boolean} isHearingImpaired - `FlagHearingImpaired`: suitable for
|
|
932
|
-
* viewers who cannot hear, so it carries non-speech sound as well as speech.
|
|
933
|
-
* @property {string} codecPrivate - The ASS/SSA header, base64, or "".
|
|
934
|
-
* @property {number[]} clusterPositions - File offsets of clusters whose cue
|
|
935
|
-
* points name this track, ascending. Empty when the file indexes only its
|
|
936
|
-
* picture, and then the caller has to walk clusters as they arrive instead.
|
|
937
|
-
*/
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
/**
|
|
941
|
-
* Everything about a file's text subtitle tracks that can be learned without
|
|
942
|
-
* reading the film.
|
|
943
|
-
*
|
|
944
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
945
|
-
* @param {number} fileSize
|
|
946
|
-
* @returns {Promise<{ tracks: SubtitleTrackPlan[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
|
|
947
|
-
*/
|
|
948
|
-
async function readSubtitlePlan(readRange, fileSize) {
|
|
949
|
-
const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
|
|
950
|
-
if (!head || head.length < 4 || head.readUInt32BE(0) !== 0x1a45dfa3) {
|
|
951
|
-
return null;
|
|
952
|
-
}
|
|
953
|
-
const segment = findElement(head, ID_SEGMENT, []);
|
|
954
|
-
if (!segment) {
|
|
955
|
-
return null;
|
|
956
|
-
}
|
|
957
|
-
const base = segment.dataOffset;
|
|
958
|
-
|
|
959
|
-
const info = findElement(head, ID_INFO, [], base);
|
|
960
|
-
let scale = DEFAULT_TIMESTAMP_SCALE;
|
|
961
|
-
if (info) {
|
|
962
|
-
const declared = findElement(head, ID_TIMESTAMP_SCALE, [], info.dataOffset, info.dataOffset + info.size);
|
|
963
|
-
if (declared) {
|
|
964
|
-
const value = readUint(head, declared.dataOffset, declared.size);
|
|
965
|
-
if (value > 0) {
|
|
966
|
-
scale = value;
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
const tracksElement = findElement(head, ID_TRACKS, [], base);
|
|
972
|
-
if (!tracksElement) {
|
|
973
|
-
return null;
|
|
996
|
+
// The Timestamp SHOULD come first (§5.1.3.1), not MUST: blocks before it are
|
|
997
|
+
// placed once the cluster's time is known.
|
|
998
|
+
if (clusterTicks === null) {
|
|
999
|
+
return blocks;
|
|
974
1000
|
}
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
* names them, text or picture. This is not for extraction — `tracks` is —
|
|
981
|
-
* but for lining ffmpeg's `0:s:N` numbering up against the container, which
|
|
982
|
-
* only holds while nothing is missing from the middle of the list.
|
|
983
|
-
*
|
|
984
|
-
* @type {Array<{ trackNumber: number, codecId: string, language: string, name: string, isDefault: boolean, declaresDefault: boolean }>}
|
|
985
|
-
*/
|
|
986
|
-
const declared = [];
|
|
987
|
-
for (const entry of iterateElements(head, tracksElement.dataOffset, tracksEnd)) {
|
|
988
|
-
if (entry.id !== ID_TRACK_ENTRY) {
|
|
989
|
-
continue;
|
|
990
|
-
}
|
|
991
|
-
const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
|
|
992
|
-
let trackNumber = null;
|
|
993
|
-
let type = null;
|
|
994
|
-
let codecId = "";
|
|
995
|
-
let language = "";
|
|
996
|
-
let name = "";
|
|
997
|
-
let codecPrivate = "";
|
|
998
|
-
// Matroska's `FlagDefault` DEFAULTS TO 1, so a file whose muxer wrote it on
|
|
999
|
-
// no track is indistinguishable, once the default has been applied, from
|
|
1000
|
-
// one that wrote it on every track — which is how ffmpeg's banner prints it
|
|
1001
|
-
// and why the banner cannot answer this. Both are kept: what the flag
|
|
1002
|
-
// amounts to, and whether the file said anything at all.
|
|
1003
|
-
let isDefault = true;
|
|
1004
|
-
let declaresDefault = false;
|
|
1005
|
-
// Defaults straight from RFC 9559: a track is usable and not forced unless
|
|
1006
|
-
// the file says otherwise, and the impaired flags are absent until claimed.
|
|
1007
|
-
let isEnabled = true;
|
|
1008
|
-
let isForced = false;
|
|
1009
|
-
let isHearingImpaired = false;
|
|
1010
|
-
let languageBcp47 = "";
|
|
1011
|
-
for (const field of iterateElements(head, entry.dataOffset, entryEnd)) {
|
|
1012
|
-
if (field.id === ID_TRACK_NUMBER) {
|
|
1013
|
-
trackNumber = readUint(head, field.dataOffset, field.size);
|
|
1014
|
-
} else if (field.id === ID_TRACK_TYPE) {
|
|
1015
|
-
type = readUint(head, field.dataOffset, field.size);
|
|
1016
|
-
} else if (field.id === ID_CODEC_ID) {
|
|
1017
|
-
codecId = readString(head, field);
|
|
1018
|
-
} else if (field.id === ID_LANGUAGE) {
|
|
1019
|
-
language = readString(head, field);
|
|
1020
|
-
} else if (field.id === ID_LANGUAGE_BCP47) {
|
|
1021
|
-
languageBcp47 = readString(head, field);
|
|
1022
|
-
} else if (field.id === ID_NAME) {
|
|
1023
|
-
name = readString(head, field);
|
|
1024
|
-
} else if (field.id === ID_FLAG_DEFAULT) {
|
|
1025
|
-
isDefault = readUint(head, field.dataOffset, field.size) === 1;
|
|
1026
|
-
declaresDefault = true;
|
|
1027
|
-
} else if (field.id === ID_FLAG_ENABLED) {
|
|
1028
|
-
// An element written with zero length carries its default, which for
|
|
1029
|
-
// this one is 1 — so an empty element must not read as "unusable", and
|
|
1030
|
-
// neither must a value outside the declared 0-1 range. Only an explicit
|
|
1031
|
-
// zero takes a track away.
|
|
1032
|
-
isEnabled = field.size === 0 || readUint(head, field.dataOffset, field.size) !== 0;
|
|
1033
|
-
} else if (field.id === ID_FLAG_FORCED) {
|
|
1034
|
-
isForced = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
|
|
1035
|
-
} else if (field.id === ID_FLAG_HEARING_IMPAIRED) {
|
|
1036
|
-
isHearingImpaired = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
|
|
1037
|
-
} else if (field.id === ID_CODEC_PRIVATE) {
|
|
1038
|
-
codecPrivate = head.toString("base64", field.dataOffset, field.dataOffset + field.size);
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
if (type !== TRACK_TYPE_SUBTITLE || trackNumber === null) {
|
|
1042
|
-
continue;
|
|
1043
|
-
}
|
|
1044
|
-
// A track the file marks unusable is still COUNTED. FlagEnabled says "the
|
|
1045
|
-
// track is usable", and a player should not offer it — but ffmpeg does not
|
|
1046
|
-
// drop it: `matroskadec.c` parses `MATROSKA_ID_TRACKFLAGENABLED` as
|
|
1047
|
-
// `EBML_NONE`, reading the element and keeping nothing, so the stream is
|
|
1048
|
-
// created and numbered like any other. Leaving it out of this list would
|
|
1049
|
-
// therefore shift `declaredIndex` off ffmpeg's `0:s:N` for every track
|
|
1050
|
-
// after it, which is the numbering defect this file was fixed for a day
|
|
1051
|
-
// earlier. It is counted here and refused where it is offered instead.
|
|
1052
|
-
//
|
|
1053
|
-
// `language` here stays the three-letter code, because this list exists to
|
|
1054
|
-
// be lined up against ffmpeg's banner, which prints that code. The RFC 5646
|
|
1055
|
-
// tag rides beside it for whoever displays the track.
|
|
1056
|
-
const declaresLanguage = languageBcp47.length > 0 || language.length > 0;
|
|
1057
|
-
declared.push({
|
|
1058
|
-
trackNumber,
|
|
1059
|
-
codecId,
|
|
1060
|
-
language,
|
|
1061
|
-
languageBcp47,
|
|
1062
|
-
declaresLanguage,
|
|
1063
|
-
name,
|
|
1064
|
-
isDefault,
|
|
1065
|
-
declaresDefault,
|
|
1066
|
-
isEnabled,
|
|
1067
|
-
isForced,
|
|
1068
|
-
isHearingImpaired
|
|
1069
|
-
});
|
|
1070
|
-
if (!TEXT_CODECS.has(codecId) || !isEnabled) {
|
|
1071
|
-
continue;
|
|
1072
|
-
}
|
|
1073
|
-
tracks.push({
|
|
1074
|
-
trackNumber,
|
|
1075
|
-
declaredIndex: declared.length - 1,
|
|
1076
|
-
codecId,
|
|
1077
|
-
// This list is ours and is not compared with ffmpeg's, so it carries the
|
|
1078
|
-
// language the file states most precisely: where RFC 5646 is written, the
|
|
1079
|
-
// three-letter code MUST be ignored.
|
|
1080
|
-
language: languageBcp47 || language,
|
|
1081
|
-
languageBcp47,
|
|
1082
|
-
declaresLanguage,
|
|
1083
|
-
name,
|
|
1084
|
-
isDefault,
|
|
1085
|
-
isForced,
|
|
1086
|
-
isHearingImpaired,
|
|
1087
|
-
codecPrivate,
|
|
1088
|
-
clusterPositions: []
|
|
1001
|
+
for (const block of pending) {
|
|
1002
|
+
blocks.push({
|
|
1003
|
+
startSeconds: (clusterTicks + block.relativeTicks) * secondsPerTick,
|
|
1004
|
+
durationSeconds: block.durationTicks === null ? null : block.durationTicks * secondsPerTick,
|
|
1005
|
+
payload: block.payload
|
|
1089
1006
|
});
|
|
1090
1007
|
}
|
|
1091
|
-
|
|
1092
|
-
return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
// Where the clusters holding those tracks are. A file that indexes only its
|
|
1096
|
-
// picture leaves these empty, which is not a failure: the caller then reads
|
|
1097
|
-
// the clusters the viewer's own playback brings in.
|
|
1098
|
-
const seekHead = findElement(head, ID_SEEK_HEAD, [], base);
|
|
1099
|
-
let cuesRelative;
|
|
1100
|
-
if (seekHead) {
|
|
1101
|
-
const seekEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
|
|
1102
|
-
for (const seek of iterateElements(head, seekHead.dataOffset, seekEnd)) {
|
|
1103
|
-
if (seek.id !== ID_SEEK) {
|
|
1104
|
-
continue;
|
|
1105
|
-
}
|
|
1106
|
-
let target = null;
|
|
1107
|
-
let position = null;
|
|
1108
|
-
for (const field of iterateElements(head, seek.dataOffset, Math.min(seekEnd, seek.dataOffset + seek.size))) {
|
|
1109
|
-
if (field.id === ID_SEEK_ID) {
|
|
1110
|
-
target = readUint(head, field.dataOffset, field.size);
|
|
1111
|
-
} else if (field.id === ID_SEEK_POSITION) {
|
|
1112
|
-
position = readUint(head, field.dataOffset, field.size);
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
if (target === ID_CUES && position !== null) {
|
|
1116
|
-
cuesRelative = position;
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
if (cuesRelative !== undefined) {
|
|
1121
|
-
const cuesAt = base + cuesRelative;
|
|
1122
|
-
if (cuesAt > 0 && cuesAt < fileSize) {
|
|
1123
|
-
const chunk = await readRange(cuesAt, Math.min(fileSize - 1, cuesAt + MAX_CUES_BYTES));
|
|
1124
|
-
const element = chunk && [...iterateElements(chunk, 0, chunk.length)][0];
|
|
1125
|
-
if (element && element.id === ID_CUES) {
|
|
1126
|
-
const body = chunk.subarray(element.dataOffset, Math.min(chunk.length, element.dataOffset + element.size));
|
|
1127
|
-
const byTrack = new Map(tracks.map((track) => [track.trackNumber, new Set()]));
|
|
1128
|
-
for (const point of iterateElements(body, 0, body.length)) {
|
|
1129
|
-
if (point.id !== ID_CUE_POINT) {
|
|
1130
|
-
continue;
|
|
1131
|
-
}
|
|
1132
|
-
const pointEnd = Math.min(body.length, point.dataOffset + point.size);
|
|
1133
|
-
for (const field of iterateElements(body, point.dataOffset, pointEnd)) {
|
|
1134
|
-
if (field.id !== ID_CUE_TRACK_POSITIONS) {
|
|
1135
|
-
continue;
|
|
1136
|
-
}
|
|
1137
|
-
let cueTrack = null;
|
|
1138
|
-
let position = null;
|
|
1139
|
-
for (const inner of iterateElements(body, field.dataOffset, Math.min(pointEnd, field.dataOffset + field.size))) {
|
|
1140
|
-
if (inner.id === ID_CUE_TRACK) {
|
|
1141
|
-
cueTrack = readUint(body, inner.dataOffset, inner.size);
|
|
1142
|
-
} else if (inner.id === ID_CUE_CLUSTER_POSITION) {
|
|
1143
|
-
position = readUint(body, inner.dataOffset, inner.size);
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
if (position !== null && byTrack.has(cueTrack)) {
|
|
1147
|
-
byTrack.get(cueTrack).add(base + position);
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
for (const track of tracks) {
|
|
1152
|
-
track.clusterPositions = [...byTrack.get(track.trackNumber)].sort((left, right) => left - right);
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
1008
|
+
return blocks;
|
|
1158
1009
|
}
|
|
1159
1010
|
|
|
1160
1011
|
/**
|
|
1161
|
-
* The cues of one track inside one cluster.
|
|
1012
|
+
* The cues of one track inside one cluster held whole in a buffer.
|
|
1162
1013
|
*
|
|
1163
1014
|
* @param {Buffer} bytes - The cluster, from its own element header onward.
|
|
1164
1015
|
* @param {number} trackNumber
|
|
1165
1016
|
* @param {number} secondsPerTick
|
|
1166
|
-
* @returns {{ startSeconds: number, endSeconds: number | null,
|
|
1017
|
+
* @returns {{ startSeconds: number, endSeconds: number | null, payload: Buffer }[]}
|
|
1167
1018
|
*/
|
|
1168
1019
|
function harvestCluster(bytes, trackNumber, secondsPerTick) {
|
|
1169
1020
|
const header = [...iterateElements(bytes, 0, bytes.length)][0];
|
|
1170
1021
|
if (!header) {
|
|
1171
1022
|
return [];
|
|
1172
1023
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1024
|
+
return blocksOfTrack(bytes, { dataOffset: header.dataOffset, size: header.size }, trackNumber, secondsPerTick).map(
|
|
1025
|
+
(block) => ({
|
|
1026
|
+
startSeconds: block.startSeconds,
|
|
1027
|
+
endSeconds: block.durationSeconds === null ? null : block.startSeconds + block.durationSeconds,
|
|
1028
|
+
payload: block.payload
|
|
1029
|
+
})
|
|
1178
1030
|
);
|
|
1179
|
-
// The payload is handed on as BYTES. What those bytes mean — which of them
|
|
1180
|
-
// are the text and which are the eight fields Matroska puts before it — is
|
|
1181
|
-
// stated by the container's specification and answered by
|
|
1182
|
-
// `MatroskaContainer.cueTextOf`, not here: this function's subject is where a
|
|
1183
|
-
// block sits and how long it lasts.
|
|
1184
|
-
return blocks.map((block) => ({
|
|
1185
|
-
startSeconds: block.startSeconds,
|
|
1186
|
-
endSeconds: block.durationSeconds === null ? null : block.startSeconds + block.durationSeconds,
|
|
1187
|
-
payload: block.payload
|
|
1188
|
-
}));
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
// ---------------------------------------------------------------------------
|
|
1192
|
-
// RFC 9559 speaking about Matroska: the Cues table that says where a keyframe is.
|
|
1193
|
-
// Here because the class is the only way in.
|
|
1194
|
-
// ---------------------------------------------------------------------------
|
|
1195
|
-
|
|
1196
|
-
// Element ids, from the Matroska specification.
|
|
1197
|
-
const ID_CUE_TIME = 0xb3;
|
|
1198
|
-
// TrackType 1 is video; 2 is audio, 17 subtitles, and the rest are rarer still.
|
|
1199
|
-
const TRACK_TYPE_VIDEO = 1;
|
|
1200
|
-
|
|
1201
|
-
// How much of the file start to read. Must cover the EBML header, the SeekHead
|
|
1202
|
-
// and Info; 64 KB is generous for every real muxer (the file measured needed
|
|
1203
|
-
// under 4 KB) while still trivial to fetch.
|
|
1204
|
-
// Cap on the Cues read. A two-hour film indexes to tens of KB; anything beyond
|
|
1205
|
-
// this is not a normal index and not worth pulling over a torrent.
|
|
1206
|
-
// Cap on a Tracks read, for the rare file whose Tracks element sits outside the
|
|
1207
|
-
// head window. Track entries are small, so a file with dozens of them still
|
|
1208
|
-
// fits well inside this.
|
|
1209
|
-
const MAX_TRACKS_BYTES = 1024 * 1024;
|
|
1210
|
-
// Matroska's default timestamp scale (nanoseconds per tick) when Info omits it.
|
|
1211
|
-
|
|
1212
|
-
/**
|
|
1213
|
-
* Whether this looks like a Matroska file (the EBML magic `0x1A45DFA3`).
|
|
1214
|
-
*
|
|
1215
|
-
* @param {Buffer} head
|
|
1216
|
-
* @returns {boolean}
|
|
1217
|
-
*/
|
|
1218
|
-
function isMatroska(head) {
|
|
1219
|
-
return head.length >= 4 && head.readUInt32BE(0) === 0x1a45dfa3;
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
/**
|
|
1223
|
-
* Locate the Segment element and the SeekHead entries inside it.
|
|
1224
|
-
*
|
|
1225
|
-
* Seek positions are relative to the start of Segment's payload, not to the
|
|
1226
|
-
* file, so that base has to come back with them.
|
|
1227
|
-
*
|
|
1228
|
-
* @param {Buffer} head
|
|
1229
|
-
* @returns {{ segmentDataOffset: number, entries: Map<number, number> } | null}
|
|
1230
|
-
*/
|
|
1231
|
-
function readSeekHead(head) {
|
|
1232
|
-
let segmentDataOffset = -1;
|
|
1233
|
-
for (const element of iterateElements(head)) {
|
|
1234
|
-
if (element.id === ID_SEGMENT) {
|
|
1235
|
-
segmentDataOffset = element.dataOffset;
|
|
1236
|
-
break;
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
if (segmentDataOffset < 0) {
|
|
1240
|
-
return null;
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
const seekHead = findElement(head, ID_SEEK_HEAD, [], segmentDataOffset);
|
|
1244
|
-
if (!seekHead) {
|
|
1245
|
-
return null;
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
const entries = new Map();
|
|
1249
|
-
const seekHeadEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
|
|
1250
|
-
for (const seek of iterateElements(head, seekHead.dataOffset, seekHeadEnd)) {
|
|
1251
|
-
if (seek.id !== ID_SEEK) {
|
|
1252
|
-
continue;
|
|
1253
|
-
}
|
|
1254
|
-
const seekEnd = Math.min(seekHeadEnd, seek.dataOffset + seek.size);
|
|
1255
|
-
let targetId = null;
|
|
1256
|
-
let position = null;
|
|
1257
|
-
for (const field of iterateElements(head, seek.dataOffset, seekEnd)) {
|
|
1258
|
-
if (field.id === ID_SEEK_ID) {
|
|
1259
|
-
targetId = readUint(head, field.dataOffset, field.size);
|
|
1260
|
-
} else if (field.id === ID_SEEK_POSITION) {
|
|
1261
|
-
position = readUint(head, field.dataOffset, field.size);
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
if (targetId !== null && position !== null) {
|
|
1265
|
-
entries.set(targetId, position);
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
return { segmentDataOffset, entries };
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
/**
|
|
1272
|
-
* Timestamp scale (nanoseconds per tick) declared in Info, or the default.
|
|
1273
|
-
*
|
|
1274
|
-
* @param {Buffer} head
|
|
1275
|
-
* @param {number} segmentDataOffset
|
|
1276
|
-
* @returns {number}
|
|
1277
|
-
*/
|
|
1278
|
-
function readTimestampScale(head, segmentDataOffset) {
|
|
1279
|
-
const info = findElement(head, ID_INFO, [], segmentDataOffset);
|
|
1280
|
-
if (!info) {
|
|
1281
|
-
return DEFAULT_TIMESTAMP_SCALE;
|
|
1282
|
-
}
|
|
1283
|
-
const infoEnd = Math.min(head.length, info.dataOffset + info.size);
|
|
1284
|
-
for (const field of iterateElements(head, info.dataOffset, infoEnd)) {
|
|
1285
|
-
if (field.id === ID_TIMESTAMP_SCALE) {
|
|
1286
|
-
const scale = readUint(head, field.dataOffset, field.size);
|
|
1287
|
-
return scale > 0 ? scale : DEFAULT_TIMESTAMP_SCALE;
|
|
1288
|
-
}
|
|
1289
|
-
}
|
|
1290
|
-
return DEFAULT_TIMESTAMP_SCALE;
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
/**
|
|
1294
|
-
* The number of the first video track, from a Tracks payload.
|
|
1295
|
-
*
|
|
1296
|
-
* The FIRST one, because that is the track ffmpeg is told to copy (`0:v:0`).
|
|
1297
|
-
*
|
|
1298
|
-
* @param {Buffer} buffer
|
|
1299
|
-
* @param {{ dataOffset: number, size: number }} tracks
|
|
1300
|
-
* @returns {number | null}
|
|
1301
|
-
*/
|
|
1302
|
-
function readVideoTrackNumber(buffer, tracks) {
|
|
1303
|
-
const tracksEnd = Math.min(buffer.length, tracks.dataOffset + tracks.size);
|
|
1304
|
-
for (const entry of iterateElements(buffer, tracks.dataOffset, tracksEnd)) {
|
|
1305
|
-
if (entry.id !== ID_TRACK_ENTRY) {
|
|
1306
|
-
continue;
|
|
1307
|
-
}
|
|
1308
|
-
const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
|
|
1309
|
-
let number = null;
|
|
1310
|
-
let type = null;
|
|
1311
|
-
for (const field of iterateElements(buffer, entry.dataOffset, entryEnd)) {
|
|
1312
|
-
if (field.id === ID_TRACK_NUMBER) {
|
|
1313
|
-
number = readUint(buffer, field.dataOffset, field.size);
|
|
1314
|
-
} else if (field.id === ID_TRACK_TYPE) {
|
|
1315
|
-
type = readUint(buffer, field.dataOffset, field.size);
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
if (number !== null && type === TRACK_TYPE_VIDEO) {
|
|
1319
|
-
return number;
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
return null;
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
/**
|
|
1326
|
-
* Cue times (seconds, ascending) of ONE track, from a Cues payload.
|
|
1327
|
-
*
|
|
1328
|
-
* The track is the whole point, and leaving it out is what this reader got
|
|
1329
|
-
* wrong until 2026-08-18. A CuePoint belongs to the track named inside its
|
|
1330
|
-
* CueTrackPositions, and a muxer indexes whatever tracks it likes: RFC 9559
|
|
1331
|
-
* says each keyframe of a video track SHOULD be referenced, and that the Cues
|
|
1332
|
-
* Element "can be used to index every single timestamp of every Block or they
|
|
1333
|
-
* can be indexed selectively". Both field files index their SUBTITLE tracks as
|
|
1334
|
-
* well — `Minions.and.Monsters.1080p.mkv` has 2778 video entries every 2.002 s
|
|
1335
|
-
* plus 4669 across four subtitle tracks; `Moana.2 … MegaPeer.mkv` has 1055
|
|
1336
|
-
* video entries plus 5007 across five.
|
|
1337
|
-
*
|
|
1338
|
-
* Read without the track, those extra times enter the cut list as though they
|
|
1339
|
-
* were keyframes. ffmpeg can only cut a COPIED picture at a real keyframe at or
|
|
1340
|
-
* after the time it is given, so every cut asked for at one of them lands late
|
|
1341
|
-
* — which is exactly what the field measured: on the first file every deviation
|
|
1342
|
-
* was 2.002 s, that file's own keyframe spacing, and on the second the median
|
|
1343
|
-
* was 6.3 s with a worst case of 21 s. Never once negative.
|
|
1344
|
-
*
|
|
1345
|
-
* @param {Buffer} cues
|
|
1346
|
-
* @param {number} timestampScale - Nanoseconds per tick.
|
|
1347
|
-
* @param {number | null} trackNumber - Null keeps every entry, which is right
|
|
1348
|
-
* only for a file that indexes one track.
|
|
1349
|
-
* @returns {number[]}
|
|
1350
|
-
*/
|
|
1351
|
-
function readCueTimes(cues, timestampScale, trackNumber) {
|
|
1352
|
-
const times = [];
|
|
1353
|
-
const secondsPerTick = timestampScale / 1e9;
|
|
1354
|
-
for (const point of iterateElements(cues)) {
|
|
1355
|
-
if (point.id !== ID_CUE_POINT) {
|
|
1356
|
-
continue;
|
|
1357
|
-
}
|
|
1358
|
-
const pointEnd = Math.min(cues.length, point.dataOffset + point.size);
|
|
1359
|
-
let time = null;
|
|
1360
|
-
let belongsToTrack = trackNumber === null;
|
|
1361
|
-
for (const field of iterateElements(cues, point.dataOffset, pointEnd)) {
|
|
1362
|
-
if (field.id === ID_CUE_TIME) {
|
|
1363
|
-
time = readUint(cues, field.dataOffset, field.size) * secondsPerTick;
|
|
1364
|
-
continue;
|
|
1365
|
-
}
|
|
1366
|
-
if (field.id !== ID_CUE_TRACK_POSITIONS || belongsToTrack) {
|
|
1367
|
-
continue;
|
|
1368
|
-
}
|
|
1369
|
-
const positionsEnd = Math.min(pointEnd, field.dataOffset + field.size);
|
|
1370
|
-
for (const inner of iterateElements(cues, field.dataOffset, positionsEnd)) {
|
|
1371
|
-
if (inner.id === ID_CUE_TRACK && readUint(cues, inner.dataOffset, inner.size) === trackNumber) {
|
|
1372
|
-
belongsToTrack = true;
|
|
1373
|
-
break;
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
if (time !== null && belongsToTrack) {
|
|
1378
|
-
times.push(time);
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
times.sort((left, right) => left - right);
|
|
1382
|
-
return times;
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
/**
|
|
1386
|
-
* Read the keyframe times of a Matroska file using only two point reads.
|
|
1387
|
-
*
|
|
1388
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
1389
|
-
* Inclusive byte range reader; returns null when the range is unavailable.
|
|
1390
|
-
* @param {number} fileSize
|
|
1391
|
-
* @returns {Promise<number[] | null>} Ascending seconds, or null when the file
|
|
1392
|
-
* carries no usable index (see the module doc for when that happens).
|
|
1393
|
-
*/
|
|
1394
|
-
async function readMatroskaKeyframeTimes(readRange, fileSize) {
|
|
1395
|
-
const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
|
|
1396
|
-
if (!head || !isMatroska(head)) {
|
|
1397
|
-
return null;
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
const seekHead = readSeekHead(head);
|
|
1401
|
-
if (!seekHead) {
|
|
1402
|
-
return null; // No SeekHead — a streamed or truncated mux.
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
const cuesRelative = seekHead.entries.get(ID_CUES);
|
|
1406
|
-
if (cuesRelative === undefined) {
|
|
1407
|
-
return null; // Indexless file: live capture, interrupted write, damaged upload.
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
// SeekHead positions are relative to Segment's payload.
|
|
1411
|
-
const cuesOffset = seekHead.segmentDataOffset + cuesRelative;
|
|
1412
|
-
if (!Number.isFinite(cuesOffset) || cuesOffset <= 0 || cuesOffset >= fileSize) {
|
|
1413
|
-
return null;
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
// The element header states the payload size, but reading it costs a round
|
|
1417
|
-
// trip; fetch a bounded window instead and let the parser stop at the end of
|
|
1418
|
-
// what it got. Cues sits near the file end, so the window is clamped there.
|
|
1419
|
-
const cuesEnd = Math.min(fileSize - 1, cuesOffset + MAX_CUES_BYTES);
|
|
1420
|
-
const cuesChunk = await readRange(cuesOffset, cuesEnd);
|
|
1421
|
-
if (!cuesChunk || cuesChunk.length === 0) {
|
|
1422
|
-
return null;
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
// The window starts exactly at the Cues element, so its own header comes
|
|
1426
|
-
// first; step over it to reach the CuePoints.
|
|
1427
|
-
const cuesElement = [...iterateElements(cuesChunk, 0, cuesChunk.length)][0];
|
|
1428
|
-
if (!cuesElement || cuesElement.id !== ID_CUES) {
|
|
1429
|
-
return null;
|
|
1430
|
-
}
|
|
1431
|
-
const payloadEnd = Math.min(cuesChunk.length, cuesElement.dataOffset + cuesElement.size);
|
|
1432
|
-
const payload = cuesChunk.subarray(cuesElement.dataOffset, payloadEnd);
|
|
1433
|
-
|
|
1434
|
-
// Whose entries to keep. Tracks sits near the head and is normally inside the
|
|
1435
|
-
// bytes already fetched; when it is not, SeekHead says where it is and one
|
|
1436
|
-
// more short read gets it. Nothing is fetched twice and nothing is scanned.
|
|
1437
|
-
const videoTrack = await readVideoTrack(readRange, head, seekHead, fileSize);
|
|
1438
|
-
const timestampScale = readTimestampScale(head, seekHead.segmentDataOffset);
|
|
1439
|
-
const times = readCueTimes(payload, timestampScale, videoTrack);
|
|
1440
|
-
if (times.length > 0) {
|
|
1441
|
-
return times;
|
|
1442
|
-
}
|
|
1443
|
-
if (videoTrack === null) {
|
|
1444
|
-
return null;
|
|
1445
|
-
}
|
|
1446
|
-
// The filter left nothing, and that is not an answer about the file: a table
|
|
1447
|
-
// exists, and this reader simply failed to recognise which of its entries
|
|
1448
|
-
// belong to the picture — a track numbered one way in Tracks and another in
|
|
1449
|
-
// the cue points, or an entry with no CueTrack at all. Returning null here
|
|
1450
|
-
// would put an EVEN grid on a copied picture, which is the failure this
|
|
1451
|
-
// module exists to prevent, so the unfiltered table is used instead: less
|
|
1452
|
-
// exact than the picture's own keyframes, better than a grid that has nothing
|
|
1453
|
-
// to do with the file.
|
|
1454
|
-
const unfiltered = readCueTimes(payload, timestampScale, null);
|
|
1455
|
-
return unfiltered.length > 0 ? unfiltered : null;
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
/**
|
|
1459
|
-
* The video track's number — from the head when it is there, and from one extra
|
|
1460
|
-
* short read when it is not.
|
|
1461
|
-
*
|
|
1462
|
-
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
1463
|
-
* @param {Buffer} head
|
|
1464
|
-
* @param {{ segmentDataOffset: number, entries: Map<number, number> }} seekHead
|
|
1465
|
-
* @param {number} fileSize
|
|
1466
|
-
* @returns {Promise<number | null>} Null when Tracks cannot be read at all, and
|
|
1467
|
-
* then every cue entry is kept — right for a file that indexes only its
|
|
1468
|
-
* picture, wrong for one that does not, and nothing here can tell them apart.
|
|
1469
|
-
* Refusing the index instead would put an even grid on a copied picture,
|
|
1470
|
-
* which is the failure this reader exists to prevent.
|
|
1471
|
-
*/
|
|
1472
|
-
async function readVideoTrack(readRange, head, seekHead, fileSize) {
|
|
1473
|
-
const inHead = findElement(head, ID_TRACKS, [], seekHead.segmentDataOffset);
|
|
1474
|
-
if (inHead && inHead.dataOffset + inHead.size <= head.length) {
|
|
1475
|
-
return readVideoTrackNumber(head, inHead);
|
|
1476
|
-
}
|
|
1477
|
-
const relative = seekHead.entries.get(ID_TRACKS);
|
|
1478
|
-
if (relative === undefined) {
|
|
1479
|
-
return null;
|
|
1480
|
-
}
|
|
1481
|
-
const offset = seekHead.segmentDataOffset + relative;
|
|
1482
|
-
if (!Number.isFinite(offset) || offset <= 0 || offset >= fileSize) {
|
|
1483
|
-
return null;
|
|
1484
|
-
}
|
|
1485
|
-
const chunk = await readRange(offset, Math.min(fileSize - 1, offset + MAX_TRACKS_BYTES));
|
|
1486
|
-
if (!chunk || chunk.length === 0) {
|
|
1487
|
-
return null;
|
|
1488
|
-
}
|
|
1489
|
-
const element = [...iterateElements(chunk, 0, chunk.length)][0];
|
|
1490
|
-
if (!element || element.id !== ID_TRACKS) {
|
|
1491
|
-
return null;
|
|
1492
|
-
}
|
|
1493
|
-
return readVideoTrackNumber(chunk, { dataOffset: element.dataOffset, size: element.size });
|
|
1494
1031
|
}
|