@torrent-tv/proxy 2.73.1 → 2.74.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1453 -1437
- package/CLAUDE.md +165 -160
- package/docs/container-architecture.md +192 -184
- package/package.json +1 -1
- package/routes/api/subtitles/get.js +205 -205
- package/services/container/Container.js +400 -135
- package/services/container/ContainerFactory.js +55 -31
- package/services/container/MatroskaContainer.js +1166 -516
- package/services/container/Mp4Container.js +898 -392
- package/services/container/SubtitleFileContainer.js +323 -261
- package/services/controllers/SubtitleController.js +128 -127
- package/services/delivery-probe.js +64 -6
- package/services/hls-session-manager.js +32 -35
- package/services/language-detect.js +174 -228
- package/services/playback-planner.js +747 -747
- package/services/produced-index.js +300 -0
- package/services/torrent-worker/subtitle-cues.js +549 -633
- package/services/tracks/TextSubtitleTrack.js +287 -47
- package/services/tracks/index.js +14 -14
- package/test/delivery-probe.test.js +67 -0
- package/test/matroska-blocks.test.js +0 -0
- package/test/mp4-subtitles.test.js +173 -127
- package/test/produced-index.test.js +188 -0
- package/test/subtitle-cue-framing.test.js +200 -202
- package/test/subtitle-cue-walk.test.js +369 -0
- package/test/subtitle-defaults.test.js +97 -97
- package/test/subtitle-language.test.js +252 -252
- package/test/subtitle-track-numbering.test.js +370 -370
- package/services/container-index/matroska-blocks.js +0 -202
- package/services/container-index/matroska-subtitles.js +0 -372
- package/services/container-index/mp4-subtitles.js +0 -404
- package/services/subtitle-convert.js +0 -144
- package/services/subtitle-defaults.js +0 -157
- package/services/tracks/subtitle-markup.js +0 -104
|
@@ -1,516 +1,1166 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Matroska/WebM container — RFC 9559.
|
|
3
|
-
*
|
|
4
|
-
* Reads Tracks in one pass for all media types (video, audio, subtitle).
|
|
5
|
-
* Implements spec-accurate flag handling:
|
|
6
|
-
* - FlagEnabled 0xB9 default 1, zero-length element = default (not disabled)
|
|
7
|
-
* - FlagDefault 0x88 default 1, declaresDefault tracks whether element was written
|
|
8
|
-
* - FlagForced 0x55AA only for subtitles, FlagHearingImpaired 0x55AB, FlagVisualImpaired 0x55AC,
|
|
9
|
-
* FlagTextDescriptions 0x55AD, FlagOriginal 0x55AE, FlagCommentary 0x55AF
|
|
10
|
-
* - Language 0x22B59C default "eng", LanguageBCP47 0x22B59D MUST — when present, Language ignored
|
|
11
|
-
* - CodecID 0x86, CodecPrivate 0x63A2, Name 0x536E, TrackType 0x83 (1 video, 2 audio, 17 subtitle)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
*
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* @
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
let
|
|
311
|
-
let
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file Matroska/WebM container — RFC 9559.
|
|
3
|
+
*
|
|
4
|
+
* Reads Tracks in one pass for all media types (video, audio, subtitle).
|
|
5
|
+
* Implements spec-accurate flag handling:
|
|
6
|
+
* - FlagEnabled 0xB9 default 1, zero-length element = default (not disabled)
|
|
7
|
+
* - FlagDefault 0x88 default 1, declaresDefault tracks whether element was written
|
|
8
|
+
* - FlagForced 0x55AA only for subtitles, FlagHearingImpaired 0x55AB, FlagVisualImpaired 0x55AC,
|
|
9
|
+
* FlagTextDescriptions 0x55AD, FlagOriginal 0x55AE, FlagCommentary 0x55AF
|
|
10
|
+
* - Language 0x22B59C default "eng", LanguageBCP47 0x22B59D MUST — when present, Language ignored
|
|
11
|
+
* - CodecID 0x86, CodecPrivate 0x63A2, Name 0x536E, TrackType 0x83 (1 video, 2 audio, 17 subtitle)
|
|
12
|
+
*
|
|
13
|
+
* Keyframe reading is delegated to matroska.js and byte-level EBML walking to
|
|
14
|
+
* ebml-reader.js. Everything this container states about its own subtitles —
|
|
15
|
+
* the Tracks walk, the Cues table, the cluster positions it names, and the
|
|
16
|
+
* blocks inside a cluster — is read in this module: each of those is RFC 9559
|
|
17
|
+
* speaking about Matroska, and the class is the only way in.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Container } from "./Container.js";
|
|
21
|
+
import { isMatroska, readMatroskaKeyframeTimes } from "../container-index/matroska.js";
|
|
22
|
+
import { VideoTrack } from "../tracks/VideoTrack.js";
|
|
23
|
+
import { AudioTrack } from "../tracks/AudioTrack.js";
|
|
24
|
+
import { TextSubtitleTrack, TEXT_CODECS_MATROSKA } from "../tracks/TextSubtitleTrack.js";
|
|
25
|
+
import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
|
|
26
|
+
import { ContainerTrack } from "../tracks/ContainerTrack.js";
|
|
27
|
+
import { findElement, iterateElements, readFloat, readUint, readVint } from "../container-index/ebml-reader.js";
|
|
28
|
+
|
|
29
|
+
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
|
+
|
|
39
|
+
const ID_SEGMENT = 0x18538067;
|
|
40
|
+
const ID_SEEK_HEAD = 0x114d9b74;
|
|
41
|
+
const ID_SEEK = 0x4dbb;
|
|
42
|
+
const ID_SEEK_ID = 0x53ab;
|
|
43
|
+
const ID_SEEK_POSITION = 0x53ac;
|
|
44
|
+
const ID_INFO = 0x1549a966;
|
|
45
|
+
const ID_TIMESTAMP_SCALE = 0x2ad7b1;
|
|
46
|
+
const ID_DURATION = 0x4489;
|
|
47
|
+
const ID_CLUSTER = 0x1f43b675;
|
|
48
|
+
const ID_TIMESTAMP = 0xe7;
|
|
49
|
+
const ID_SIMPLE_BLOCK = 0xa3;
|
|
50
|
+
const ID_BLOCK_GROUP = 0xa0;
|
|
51
|
+
/** RFC 9559 §5.1.2.1: nanoseconds per tick when Info omits TimestampScale. */
|
|
52
|
+
const DEFAULT_TIMESTAMP_SCALE = 1_000_000;
|
|
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;
|
|
59
|
+
const ID_TRACKS = 0x1654ae6b;
|
|
60
|
+
const ID_TRACK_ENTRY = 0xae;
|
|
61
|
+
const ID_TRACK_NUMBER = 0xd7;
|
|
62
|
+
const ID_TRACK_TYPE = 0x83;
|
|
63
|
+
const ID_FLAG_ENABLED = 0xb9;
|
|
64
|
+
const ID_FLAG_DEFAULT = 0x88;
|
|
65
|
+
const ID_FLAG_FORCED = 0x55aa;
|
|
66
|
+
const ID_FLAG_HEARING = 0x55ab;
|
|
67
|
+
const ID_FLAG_VISUAL = 0x55ac;
|
|
68
|
+
const ID_FLAG_TEXT_DESCR = 0x55ad;
|
|
69
|
+
const ID_FLAG_ORIGINAL = 0x55ae;
|
|
70
|
+
const ID_FLAG_COMMENTARY = 0x55af;
|
|
71
|
+
const ID_CODEC_ID = 0x86;
|
|
72
|
+
const ID_CODEC_PRIVATE = 0x63a2;
|
|
73
|
+
const ID_LANGUAGE = 0x22b59c;
|
|
74
|
+
const ID_LANGUAGE_BCP47 = 0x22b59d;
|
|
75
|
+
const ID_NAME = 0x536e;
|
|
76
|
+
const ID_VIDEO = 0xe0;
|
|
77
|
+
const ID_AUDIO = 0xe1;
|
|
78
|
+
const ID_PIXEL_WIDTH = 0xb0;
|
|
79
|
+
const ID_PIXEL_HEIGHT = 0xba;
|
|
80
|
+
const ID_DISPLAY_WIDTH = 0x54b0;
|
|
81
|
+
const ID_DISPLAY_HEIGHT = 0x54ba;
|
|
82
|
+
const ID_SAMPLING_FREQUENCY = 0xb5;
|
|
83
|
+
const ID_CHANNELS = 0x9f;
|
|
84
|
+
/**
|
|
85
|
+
* ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect — the eight
|
|
86
|
+
* fields Matroska writes before the text of an SSA/ASS event. See
|
|
87
|
+
* {@link MatroskaContainer.cueTextOf} for the quotation this comes from.
|
|
88
|
+
*/
|
|
89
|
+
const ASS_FIELDS_BEFORE_TEXT = 8;
|
|
90
|
+
|
|
91
|
+
function readString(buf, el) {
|
|
92
|
+
return buf.toString("utf8", el.dataOffset, el.dataOffset + el.size).replace(/\0+$/, "");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class MatroskaContainer extends Container {
|
|
96
|
+
get formatName() {
|
|
97
|
+
return "matroska";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
static detect(head) {
|
|
101
|
+
return isMatroska(head);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* This container's subtitle tracks, its Cues table and the cluster positions
|
|
106
|
+
* they name — RFC 9559 §5.1.4 and §5.1.3.
|
|
107
|
+
*
|
|
108
|
+
* @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
|
|
109
|
+
* @param {number} fileSize
|
|
110
|
+
* @returns {Promise<object|null>}
|
|
111
|
+
*/
|
|
112
|
+
static readSubtitlePlan(readRange, fileSize) {
|
|
113
|
+
return readSubtitlePlan(readRange, fileSize);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The same reading, over the file this container was built on.
|
|
118
|
+
*
|
|
119
|
+
* The static form exists for a caller that has bytes and no container; this
|
|
120
|
+
* is the one to use otherwise, because the reader is already here.
|
|
121
|
+
*
|
|
122
|
+
* @returns {Promise<object|null>}
|
|
123
|
+
*/
|
|
124
|
+
readSubtitlePlan() {
|
|
125
|
+
return MatroskaContainer.readSubtitlePlan(this.readRange, this.fileSize);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The blocks one track has inside a cluster, with their times.
|
|
130
|
+
*
|
|
131
|
+
* The payload is handed back as BYTES: what those bytes mean is
|
|
132
|
+
* {@link MatroskaContainer.cueTextOf}'s answer, and this method's subject is
|
|
133
|
+
* only where a block sits and how long it lasts.
|
|
134
|
+
*
|
|
135
|
+
* @param {Buffer} bytes - The cluster, from its own element header onward.
|
|
136
|
+
* @param {number} trackNumber
|
|
137
|
+
* @param {number} secondsPerTick
|
|
138
|
+
* @returns {{ startSeconds: number, endSeconds: number | null, payload: Buffer }[]}
|
|
139
|
+
*/
|
|
140
|
+
static blocksInCluster(bytes, trackNumber, secondsPerTick) {
|
|
141
|
+
return harvestCluster(bytes, trackNumber, secondsPerTick);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The blocks one track has inside a cluster whose bounds are already known —
|
|
146
|
+
* RFC 9559 §5.1.3.4 (SimpleBlock) and §5.1.3.5 (BlockGroup).
|
|
147
|
+
*
|
|
148
|
+
* The same reading as {@link MatroskaContainer.blocksInCluster}, entered where
|
|
149
|
+
* the caller has already parsed the cluster's own header.
|
|
150
|
+
*
|
|
151
|
+
* @param {Buffer} buffer
|
|
152
|
+
* @param {{ dataOffset: number, size: number }} cluster
|
|
153
|
+
* @param {number} trackNumber
|
|
154
|
+
* @param {number} secondsPerTick
|
|
155
|
+
* @returns {{ startSeconds: number, durationSeconds: number | null, payload: Buffer }[]}
|
|
156
|
+
*/
|
|
157
|
+
static blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick) {
|
|
158
|
+
return blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Duration and the start of this file's own timeline, per RFC 9559 §5.1.2.
|
|
163
|
+
*
|
|
164
|
+
* Duration is stated in `Info` as a FLOAT in ticks, so it needs the file's
|
|
165
|
+
* `TimestampScale` to become seconds. The start of the timeline is not stated
|
|
166
|
+
* anywhere — Matroska has no such element — so it is the timestamp of the
|
|
167
|
+
* first Cluster, which is what the first frame is placed against.
|
|
168
|
+
*
|
|
169
|
+
* @returns {Promise<import("./Container.js").ContainerMediaInfo>}
|
|
170
|
+
*/
|
|
171
|
+
async readMediaInfo() {
|
|
172
|
+
if (this.mediaInfo) {
|
|
173
|
+
return this.mediaInfo;
|
|
174
|
+
}
|
|
175
|
+
/** @type {import("./Container.js").ContainerMediaInfo} */
|
|
176
|
+
const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: null };
|
|
177
|
+
this.mediaInfo = info;
|
|
178
|
+
const head = await this.readRange(0, Math.min(HEAD_BYTES - 1, this.fileSize - 1));
|
|
179
|
+
if (!head || !isMatroska(head)) {
|
|
180
|
+
return info;
|
|
181
|
+
}
|
|
182
|
+
const segment = findElement(head, ID_SEGMENT, []);
|
|
183
|
+
if (!segment) {
|
|
184
|
+
return info;
|
|
185
|
+
}
|
|
186
|
+
const scale = MatroskaContainer.#timestampScaleOf(head, segment.dataOffset);
|
|
187
|
+
const infoElement = findElement(head, ID_INFO, [], segment.dataOffset);
|
|
188
|
+
if (infoElement) {
|
|
189
|
+
const infoEnd = Math.min(head.length, infoElement.dataOffset + infoElement.size);
|
|
190
|
+
for (const field of iterateElements(head, infoElement.dataOffset, infoEnd)) {
|
|
191
|
+
if (field.id !== ID_DURATION) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const ticks = readFloat(head, field.dataOffset, field.size);
|
|
195
|
+
if (ticks !== null && ticks > 0) {
|
|
196
|
+
info.durationSeconds = (ticks * scale) / 1e9;
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
info.startTimeSeconds = await this.#firstClusterSeconds(head, segment.dataOffset, scale);
|
|
202
|
+
return info;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* `TimestampScale` from Info, or the specification's default.
|
|
207
|
+
*
|
|
208
|
+
* @param {Buffer} head
|
|
209
|
+
* @param {number} segmentDataOffset
|
|
210
|
+
* @returns {number} Nanoseconds per tick.
|
|
211
|
+
*/
|
|
212
|
+
static #timestampScaleOf(head, segmentDataOffset) {
|
|
213
|
+
const infoElement = findElement(head, ID_INFO, [], segmentDataOffset);
|
|
214
|
+
if (!infoElement) {
|
|
215
|
+
return DEFAULT_TIMESTAMP_SCALE;
|
|
216
|
+
}
|
|
217
|
+
const infoEnd = Math.min(head.length, infoElement.dataOffset + infoElement.size);
|
|
218
|
+
for (const field of iterateElements(head, infoElement.dataOffset, infoEnd)) {
|
|
219
|
+
if (field.id === ID_TIMESTAMP_SCALE) {
|
|
220
|
+
const scale = readUint(head, field.dataOffset, field.size);
|
|
221
|
+
return scale > 0 ? scale : DEFAULT_TIMESTAMP_SCALE;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return DEFAULT_TIMESTAMP_SCALE;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The timestamp of the first Cluster, in seconds.
|
|
229
|
+
*
|
|
230
|
+
* Tried in the head window first, because a muxer writes the first cluster
|
|
231
|
+
* straight after Tracks and both usually fit; a file whose Tracks element is
|
|
232
|
+
* large enough to push it out is answered from the SeekHead instead, with one
|
|
233
|
+
* short read at the position it names.
|
|
234
|
+
*
|
|
235
|
+
* @param {Buffer} head
|
|
236
|
+
* @param {number} segmentDataOffset
|
|
237
|
+
* @param {number} scale - Nanoseconds per tick.
|
|
238
|
+
* @returns {Promise<number | null>} Null when no cluster could be read.
|
|
239
|
+
*/
|
|
240
|
+
async #firstClusterSeconds(head, segmentDataOffset, scale) {
|
|
241
|
+
/**
|
|
242
|
+
* @param {Buffer} buffer
|
|
243
|
+
* @param {number} dataOffset
|
|
244
|
+
* @param {number} end
|
|
245
|
+
* @returns {number | null}
|
|
246
|
+
*/
|
|
247
|
+
const timestampIn = (buffer, dataOffset, end) => {
|
|
248
|
+
for (const field of iterateElements(buffer, dataOffset, end)) {
|
|
249
|
+
if (field.id === ID_TIMESTAMP) {
|
|
250
|
+
const ticks = readUint(buffer, field.dataOffset, field.size);
|
|
251
|
+
return Number.isFinite(ticks) ? (ticks * scale) / 1e9 : null;
|
|
252
|
+
}
|
|
253
|
+
// Timestamp is written before any frame. Stopping at the first one keeps
|
|
254
|
+
// this from walking a cluster's whole payload, which is megabytes and
|
|
255
|
+
// usually not in the buffer at all.
|
|
256
|
+
if (field.id === ID_SIMPLE_BLOCK || field.id === ID_BLOCK_GROUP) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
for (const element of iterateElements(head, segmentDataOffset, head.length)) {
|
|
264
|
+
if (element.id !== ID_CLUSTER) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
return timestampIn(head, element.dataOffset, Math.min(head.length, element.dataOffset + element.size));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const position = MatroskaContainer.#seekPositionOf(head, segmentDataOffset, ID_CLUSTER);
|
|
271
|
+
if (position === null) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
const at = segmentDataOffset + position;
|
|
275
|
+
if (at >= this.fileSize) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
const chunk = await this.readRange(at, Math.min(this.fileSize - 1, at + CLUSTER_PROBE_BYTES - 1));
|
|
279
|
+
if (!chunk) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
for (const element of iterateElements(chunk, 0, chunk.length)) {
|
|
283
|
+
if (element.id !== ID_CLUSTER) {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
return timestampIn(chunk, element.dataOffset, Math.min(chunk.length, element.dataOffset + element.size));
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Where the SeekHead says an element lives, relative to the Segment's payload.
|
|
293
|
+
*
|
|
294
|
+
* @param {Buffer} head
|
|
295
|
+
* @param {number} segmentDataOffset
|
|
296
|
+
* @param {number} wantedId
|
|
297
|
+
* @returns {number | null}
|
|
298
|
+
*/
|
|
299
|
+
static #seekPositionOf(head, segmentDataOffset, wantedId) {
|
|
300
|
+
const seekHead = findElement(head, ID_SEEK_HEAD, [], segmentDataOffset);
|
|
301
|
+
if (!seekHead) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
const seekHeadEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
|
|
305
|
+
for (const seek of iterateElements(head, seekHead.dataOffset, seekHeadEnd)) {
|
|
306
|
+
if (seek.id !== ID_SEEK) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const seekEnd = Math.min(seekHeadEnd, seek.dataOffset + seek.size);
|
|
310
|
+
let targetId = null;
|
|
311
|
+
let position = null;
|
|
312
|
+
for (const field of iterateElements(head, seek.dataOffset, seekEnd)) {
|
|
313
|
+
if (field.id === ID_SEEK_ID) {
|
|
314
|
+
targetId = readUint(head, field.dataOffset, field.size);
|
|
315
|
+
} else if (field.id === ID_SEEK_POSITION) {
|
|
316
|
+
position = readUint(head, field.dataOffset, field.size);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (targetId === wantedId && position !== null) {
|
|
320
|
+
return position;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Walk the clusters this file's Cues table names and give up the blocks in
|
|
329
|
+
* every one that is READABLE now, for every track at once.
|
|
330
|
+
*
|
|
331
|
+
* One walk for the whole file, not one per track: a Matroska cluster carries
|
|
332
|
+
* the blocks of every track that has anything to say over its span, so the
|
|
333
|
+
* bytes that answer one track answer them all. Reading them once per track
|
|
334
|
+
* meant the same cluster was fetched and parsed as many times as the film has
|
|
335
|
+
* subtitle tracks — measured 2026-08-20 on a film with five: five requests
|
|
336
|
+
* every fifteen seconds, each costing 0.2-5.2 s, for a few kilobytes of cues.
|
|
337
|
+
*
|
|
338
|
+
* Nothing is fetched. `isHeld` decides whether a cluster can be read at all,
|
|
339
|
+
* and one that is not here yet is left for the next call — turning subtitles
|
|
340
|
+
* on must not pull bytes the viewer is not waiting for.
|
|
341
|
+
*
|
|
342
|
+
* @param {object} plan - From {@link MatroskaContainer.readSubtitlePlan}.
|
|
343
|
+
* @param {Set<number>} walked - Cluster positions already read; added to.
|
|
344
|
+
* @returns {Promise<Map<number, {startSeconds: number, endSeconds: number|null, text: string}[]>>}
|
|
345
|
+
* Track number to the cues found in THIS pass.
|
|
346
|
+
*/
|
|
347
|
+
async readHeldCues(plan, track, progress) {
|
|
348
|
+
const found = await this.walkHeldClusters(plan, progress.walked);
|
|
349
|
+
return {
|
|
350
|
+
found,
|
|
351
|
+
// Every track is filled by the same walk, so this is a fact about the
|
|
352
|
+
// FILE and reads the same whichever track asked.
|
|
353
|
+
covered: progress.walked.size,
|
|
354
|
+
indexed: track?.clusterPositions?.length ?? 0
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async walkHeldClusters(plan, walked) {
|
|
359
|
+
/** @type {Map<number, object[]>} */
|
|
360
|
+
const found = new Map();
|
|
361
|
+
// The union of the tracks' cluster lists: each track's list comes from its
|
|
362
|
+
// own Cues entries, so they overlap but do not coincide.
|
|
363
|
+
const positions = new Set();
|
|
364
|
+
for (const candidate of plan?.tracks ?? []) {
|
|
365
|
+
for (const position of candidate.clusterPositions ?? []) {
|
|
366
|
+
positions.add(position);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
for (const position of [...positions].sort((left, right) => left - right)) {
|
|
370
|
+
if (walked.has(position)) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
// The header first: it says how long the cluster is, and a cluster whose
|
|
374
|
+
// bytes are not all here is left for the next time round.
|
|
375
|
+
const probeEnd = Math.min(this.fileSize - 1, position + CLUSTER_HEADER_PROBE - 1);
|
|
376
|
+
if (!this.isHeld(position, probeEnd)) {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const probe = await this.readHeld(position, probeEnd);
|
|
380
|
+
const header = probe && [...iterateElements(probe, 0, probe.length)][0];
|
|
381
|
+
if (!header || header.size <= 0 || header.size > MAX_CLUSTER_BYTES) {
|
|
382
|
+
walked.add(position); // not a cluster this can read; do not look again
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const last = Math.min(this.fileSize - 1, position + header.dataOffset + header.size - 1);
|
|
386
|
+
if (!this.isHeld(position, last)) {
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const bytes = await this.readHeld(position, last);
|
|
390
|
+
if (!bytes) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
walked.add(position);
|
|
394
|
+
for (const candidate of plan.tracks) {
|
|
395
|
+
const blocks = MatroskaContainer.blocksInCluster(bytes, candidate.trackNumber, plan.secondsPerTick);
|
|
396
|
+
if (blocks.length === 0) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const into = found.get(candidate.trackNumber) ?? [];
|
|
400
|
+
for (const block of blocks) {
|
|
401
|
+
// The block's bytes become text HERE, where the container that framed
|
|
402
|
+
// them is known. A cue kept framed and unframed later cannot be
|
|
403
|
+
// unframed at all: nothing downstream knows which container it came
|
|
404
|
+
// out of, and guessing from the field count is what showed the
|
|
405
|
+
// dialogue row's own fields to the viewer.
|
|
406
|
+
into.push({
|
|
407
|
+
startSeconds: block.startSeconds,
|
|
408
|
+
endSeconds: block.endSeconds,
|
|
409
|
+
text: MatroskaContainer.cueTextOf(block.payload, candidate.codecId)
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
found.set(candidate.trackNumber, into);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return found;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async readTracks() {
|
|
419
|
+
const head = await this.readRange(0, Math.min(HEAD_BYTES - 1, this.fileSize - 1));
|
|
420
|
+
if (!head || !isMatroska(head)) return [];
|
|
421
|
+
|
|
422
|
+
// Delegate to subtitle plan reader for subtitle tracks (already handles Flags spec-correct),
|
|
423
|
+
// but we need video/audio tracks too. Do a dedicated Tracks walk here for all types,
|
|
424
|
+
// then merge subtitle detail (clusterPositions, declaresDefault etc.) from the plan.
|
|
425
|
+
const seg = findElement(head, 0x18538067, []);
|
|
426
|
+
if (!seg) return [];
|
|
427
|
+
const tracksEl = findElement(head, ID_TRACKS, [], seg.dataOffset);
|
|
428
|
+
if (!tracksEl) return [];
|
|
429
|
+
|
|
430
|
+
const tracksEnd = Math.min(head.length, tracksEl.dataOffset + tracksEl.size);
|
|
431
|
+
/** @type {import("../tracks/index.js").ContainerTrack[]} */
|
|
432
|
+
const result = [];
|
|
433
|
+
// Subtitle declaredIndex is position among subtitle tracks, not global — track per-type counters.
|
|
434
|
+
let subtitleDeclaredIndex = -1;
|
|
435
|
+
let audioDeclaredIndex = -1;
|
|
436
|
+
let videoDeclaredIndex = -1;
|
|
437
|
+
|
|
438
|
+
// For subtitle flag enrichment, read the existing plan (it already does Cues walk)
|
|
439
|
+
let subtitlePlan = null;
|
|
440
|
+
try {
|
|
441
|
+
const shim = async (s, e) => this.readRange(s, Math.min(e, this.fileSize - 1));
|
|
442
|
+
subtitlePlan = await readSubtitlePlan(shim, this.fileSize);
|
|
443
|
+
} catch {
|
|
444
|
+
subtitlePlan = null;
|
|
445
|
+
}
|
|
446
|
+
const declaredByNumber = new Map();
|
|
447
|
+
const planTracksByNumber = new Map();
|
|
448
|
+
if (subtitlePlan?.declared) {
|
|
449
|
+
for (const d of subtitlePlan.declared) declaredByNumber.set(d.trackNumber, d);
|
|
450
|
+
}
|
|
451
|
+
if (subtitlePlan?.tracks) {
|
|
452
|
+
for (const t of subtitlePlan.tracks) planTracksByNumber.set(t.trackNumber, t);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
for (const entry of iterateElements(head, tracksEl.dataOffset, tracksEnd)) {
|
|
456
|
+
if (entry.id !== ID_TRACK_ENTRY) continue;
|
|
457
|
+
const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
|
|
458
|
+
let trackNumber = null;
|
|
459
|
+
let typeNum = null;
|
|
460
|
+
let codecId = "";
|
|
461
|
+
let language = "";
|
|
462
|
+
let languageBcp47 = "";
|
|
463
|
+
let name = "";
|
|
464
|
+
let codecPrivateB64 = "";
|
|
465
|
+
let isEnabled = true;
|
|
466
|
+
let isDefault = true;
|
|
467
|
+
let declaresDefault = false;
|
|
468
|
+
let isForced = false;
|
|
469
|
+
let isHearing = false;
|
|
470
|
+
let isVisual = false;
|
|
471
|
+
let isOriginal = false;
|
|
472
|
+
let isCommentary = false;
|
|
473
|
+
let pixelWidth = null;
|
|
474
|
+
let pixelHeight = null;
|
|
475
|
+
let displayWidth = null;
|
|
476
|
+
let displayHeight = null;
|
|
477
|
+
let samplingFreq = null;
|
|
478
|
+
let channels = null;
|
|
479
|
+
|
|
480
|
+
for (const f of iterateElements(head, entry.dataOffset, entryEnd)) {
|
|
481
|
+
switch (f.id) {
|
|
482
|
+
case ID_TRACK_NUMBER: trackNumber = readUint(head, f.dataOffset, f.size); break;
|
|
483
|
+
case ID_TRACK_TYPE: typeNum = readUint(head, f.dataOffset, f.size); break;
|
|
484
|
+
case ID_CODEC_ID: codecId = readString(head, f); break;
|
|
485
|
+
case ID_CODEC_PRIVATE: codecPrivateB64 = head.toString("base64", f.dataOffset, f.dataOffset + f.size); break;
|
|
486
|
+
case ID_LANGUAGE: language = readString(head, f); break;
|
|
487
|
+
case ID_LANGUAGE_BCP47: languageBcp47 = readString(head, f); break;
|
|
488
|
+
case ID_NAME: name = readString(head, f); break;
|
|
489
|
+
case ID_FLAG_ENABLED: isEnabled = f.size === 0 || readUint(head, f.dataOffset, f.size) !== 0; break;
|
|
490
|
+
case ID_FLAG_DEFAULT: isDefault = f.size === 0 || readUint(head, f.dataOffset, f.size) === 1; declaresDefault = true; break;
|
|
491
|
+
case ID_FLAG_FORCED: isForced = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
|
|
492
|
+
case ID_FLAG_HEARING: isHearing = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
|
|
493
|
+
case ID_FLAG_VISUAL: isVisual = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
|
|
494
|
+
case ID_FLAG_TEXT_DESCR: break;
|
|
495
|
+
case ID_FLAG_ORIGINAL: isOriginal = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
|
|
496
|
+
case ID_FLAG_COMMENTARY: isCommentary = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
|
|
497
|
+
default: break;
|
|
498
|
+
}
|
|
499
|
+
// Video/Audio sub-elements are nested, not at entry level — read separately below.
|
|
500
|
+
}
|
|
501
|
+
if (trackNumber === null) continue;
|
|
502
|
+
|
|
503
|
+
// Parse Video/Audio sub-elements if present
|
|
504
|
+
const videoEl = findElement(head, ID_VIDEO, [], entry.dataOffset, entryEnd);
|
|
505
|
+
if (videoEl) {
|
|
506
|
+
for (const vf of iterateElements(head, videoEl.dataOffset, Math.min(entryEnd, videoEl.dataOffset + videoEl.size))) {
|
|
507
|
+
if (vf.id === ID_PIXEL_WIDTH) pixelWidth = readUint(head, vf.dataOffset, vf.size);
|
|
508
|
+
else if (vf.id === ID_PIXEL_HEIGHT) pixelHeight = readUint(head, vf.dataOffset, vf.size);
|
|
509
|
+
else if (vf.id === ID_DISPLAY_WIDTH) displayWidth = readUint(head, vf.dataOffset, vf.size);
|
|
510
|
+
else if (vf.id === ID_DISPLAY_HEIGHT) displayHeight = readUint(head, vf.dataOffset, vf.size);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const audioEl = findElement(head, ID_AUDIO, [], entry.dataOffset, entryEnd);
|
|
514
|
+
if (audioEl) {
|
|
515
|
+
for (const af of iterateElements(head, audioEl.dataOffset, Math.min(entryEnd, audioEl.dataOffset + audioEl.size))) {
|
|
516
|
+
if (af.id === ID_SAMPLING_FREQUENCY) {
|
|
517
|
+
// SamplingFrequency is float64
|
|
518
|
+
if (af.size === 8) samplingFreq = head.readDoubleBE(af.dataOffset);
|
|
519
|
+
else samplingFreq = readUint(head, af.dataOffset, af.size);
|
|
520
|
+
} else if (af.id === ID_CHANNELS) channels = readUint(head, af.dataOffset, af.size);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// RFC 9559 LanguageBCP47 MUST — when present, Language ignored
|
|
525
|
+
const resolvedLang = languageBcp47 || language;
|
|
526
|
+
const bcpTag = languageBcp47;
|
|
527
|
+
|
|
528
|
+
if (typeNum === 1) {
|
|
529
|
+
videoDeclaredIndex += 1;
|
|
530
|
+
result.push(new VideoTrack({
|
|
531
|
+
trackNumber,
|
|
532
|
+
declaredIndex: videoDeclaredIndex,
|
|
533
|
+
codecId,
|
|
534
|
+
language: resolvedLang,
|
|
535
|
+
languageBcp47: bcpTag,
|
|
536
|
+
name,
|
|
537
|
+
isEnabled,
|
|
538
|
+
isDefault,
|
|
539
|
+
declaresDefault,
|
|
540
|
+
codecPrivateB64,
|
|
541
|
+
width: pixelWidth,
|
|
542
|
+
height: pixelHeight,
|
|
543
|
+
displayWidth,
|
|
544
|
+
displayHeight
|
|
545
|
+
}));
|
|
546
|
+
} else if (typeNum === 2) {
|
|
547
|
+
audioDeclaredIndex += 1;
|
|
548
|
+
result.push(new AudioTrack({
|
|
549
|
+
trackNumber,
|
|
550
|
+
declaredIndex: audioDeclaredIndex,
|
|
551
|
+
codecId,
|
|
552
|
+
language: resolvedLang,
|
|
553
|
+
languageBcp47: bcpTag,
|
|
554
|
+
name,
|
|
555
|
+
isEnabled,
|
|
556
|
+
isDefault,
|
|
557
|
+
declaresDefault,
|
|
558
|
+
codecPrivateB64,
|
|
559
|
+
isOriginal,
|
|
560
|
+
isCommentary,
|
|
561
|
+
isVisualImpaired: isVisual,
|
|
562
|
+
channels,
|
|
563
|
+
samplingFrequency: samplingFreq
|
|
564
|
+
}));
|
|
565
|
+
} else if (typeNum === 17) {
|
|
566
|
+
subtitleDeclaredIndex += 1;
|
|
567
|
+
// Only Forced/Hearing belong to subtitles; Original/Commentary must not leak.
|
|
568
|
+
const declared = declaredByNumber.get(trackNumber);
|
|
569
|
+
const planTrack = planTracksByNumber.get(trackNumber);
|
|
570
|
+
// Prefer plan's flags when available (already spec-correct), else use parsed.
|
|
571
|
+
const finalForced = planTrack ? !!planTrack.isForced : isForced;
|
|
572
|
+
const finalHearing = planTrack ? !!planTrack.isHearingImpaired : isHearing;
|
|
573
|
+
const finalEnabled = declared ? declared.isEnabled !== false : isEnabled;
|
|
574
|
+
const finalDefault = declared ? !!declared.isDefault : isDefault;
|
|
575
|
+
const finalDeclares = declared ? !!declared.declaresDefault : declaresDefault;
|
|
576
|
+
const clusterPositions = planTrack ? planTrack.clusterPositions ?? [] : [];
|
|
577
|
+
const isText = TEXT_CODECS_MATROSKA.has(codecId);
|
|
578
|
+
// disabled image/text tracks still counted (declaredIndex above) — offerable flag controls visibility
|
|
579
|
+
if (isText && finalEnabled) {
|
|
580
|
+
result.push(new TextSubtitleTrack({
|
|
581
|
+
trackNumber,
|
|
582
|
+
declaredIndex: subtitleDeclaredIndex,
|
|
583
|
+
codecId,
|
|
584
|
+
language: resolvedLang,
|
|
585
|
+
languageBcp47: bcpTag,
|
|
586
|
+
name,
|
|
587
|
+
isEnabled: finalEnabled,
|
|
588
|
+
isDefault: finalDefault,
|
|
589
|
+
declaresDefault: finalDeclares,
|
|
590
|
+
codecPrivateB64,
|
|
591
|
+
isForced: finalForced,
|
|
592
|
+
isHearingImpaired: finalHearing,
|
|
593
|
+
clusterPositions
|
|
594
|
+
}));
|
|
595
|
+
} else {
|
|
596
|
+
// Image or disabled — keep declaredIndex, not offerable if disabled
|
|
597
|
+
const Target = isText ? TextSubtitleTrack : ImageSubtitleTrack;
|
|
598
|
+
result.push(new Target({
|
|
599
|
+
trackNumber,
|
|
600
|
+
declaredIndex: subtitleDeclaredIndex,
|
|
601
|
+
codecId,
|
|
602
|
+
language: resolvedLang,
|
|
603
|
+
languageBcp47: bcpTag,
|
|
604
|
+
name,
|
|
605
|
+
isEnabled: finalEnabled,
|
|
606
|
+
isDefault: finalDefault,
|
|
607
|
+
declaresDefault: finalDeclares,
|
|
608
|
+
codecPrivateB64,
|
|
609
|
+
isForced: finalForced,
|
|
610
|
+
isHearingImpaired: finalHearing,
|
|
611
|
+
clusterPositions: isText ? clusterPositions : []
|
|
612
|
+
}));
|
|
613
|
+
}
|
|
614
|
+
} else {
|
|
615
|
+
// Other TrackType (complex, logo, buttons, control) — keep as generic, not video
|
|
616
|
+
result.push(new ContainerTrack({
|
|
617
|
+
trackNumber,
|
|
618
|
+
declaredIndex: -1,
|
|
619
|
+
type: "other",
|
|
620
|
+
codecId,
|
|
621
|
+
language: resolvedLang,
|
|
622
|
+
languageBcp47: bcpTag,
|
|
623
|
+
name,
|
|
624
|
+
isEnabled,
|
|
625
|
+
isDefault,
|
|
626
|
+
declaresDefault,
|
|
627
|
+
codecPrivateB64
|
|
628
|
+
}));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return result;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* The text field of one cue as Matroska frames it.
|
|
636
|
+
*
|
|
637
|
+
* Two rules, both from `matroska.org/technical/subtitles.html`, "Now, how are
|
|
638
|
+
* they stored in Matroska?":
|
|
639
|
+
*
|
|
640
|
+
* 1. "All text is converted to UTF-8", so the block is decoded as UTF-8 and
|
|
641
|
+
* no other encoding is guessed at. A subtitle FILE is a different matter —
|
|
642
|
+
* there the bytes may be Windows-1251 and `decodeSubtitleBytes` sniffs for
|
|
643
|
+
* it — but a muxer had to convert before writing the block.
|
|
644
|
+
* 2. "Events are stored in the Block in this order: ReadOrder, Layer, Style,
|
|
645
|
+
* Name, MarginL, MarginR, MarginV, Effect, Text", and "Start & End field
|
|
646
|
+
* are used to set TimeStamp and the BlockDuration element". So eight fields
|
|
647
|
+
* stand before the text, the two timing fields of the file's own row are
|
|
648
|
+
* NOT among them, and a read order takes their place at the front. The text
|
|
649
|
+
* itself may hold commas, so everything from the ninth field on is joined
|
|
650
|
+
* back together.
|
|
651
|
+
*
|
|
652
|
+
* `S_TEXT/UTF8` and `S_TEXT/WEBVTT` have no such framing: the block holds the
|
|
653
|
+
* cue text and nothing else. (A WebVTT cue's settings, identifier and
|
|
654
|
+
* preceding comments live in a BlockAddition, which this proxy does not read;
|
|
655
|
+
* losing them costs positioning, not words.)
|
|
656
|
+
*
|
|
657
|
+
* @param {Buffer} payload - The block's own bytes.
|
|
658
|
+
* @param {string} codecId - Matroska CodecID of the track the block belongs to.
|
|
659
|
+
* @returns {string}
|
|
660
|
+
*/
|
|
661
|
+
static cueTextOf(payload, codecId) {
|
|
662
|
+
const text = Buffer.isBuffer(payload) ? payload.toString("utf8") : String(payload ?? "");
|
|
663
|
+
if (codecId !== "S_TEXT/ASS" && codecId !== "S_TEXT/SSA") {
|
|
664
|
+
return text;
|
|
665
|
+
}
|
|
666
|
+
const fields = text.split(",");
|
|
667
|
+
return fields.length > ASS_FIELDS_BEFORE_TEXT ? fields.slice(ASS_FIELDS_BEFORE_TEXT).join(",") : "";
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async readKeyframeIndex() {
|
|
671
|
+
const times = await readMatroskaKeyframeTimes(this.readRange, this.fileSize);
|
|
672
|
+
if (!times) return null;
|
|
673
|
+
if (Array.isArray(times)) return { times, tolerance: 0 };
|
|
674
|
+
return times;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
// Matroska's own reading of its subtitle tracks and its clusters. It lives in
|
|
680
|
+
// this module because every line of it is a statement of RFC 9559 about how
|
|
681
|
+
// this container stores a subtitle, and the class is the only way in.
|
|
682
|
+
// ---------------------------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
const ID_BLOCK = 0xa1;
|
|
685
|
+
const ID_BLOCK_DURATION = 0x9b;
|
|
686
|
+
|
|
687
|
+
/** Bits 1-2 of the flags byte say how a block is laced, or that it is not. */
|
|
688
|
+
const LACING_MASK = 0x06;
|
|
689
|
+
const LACING_NONE = 0x00;
|
|
690
|
+
const LACING_XIPH = 0x02;
|
|
691
|
+
const LACING_FIXED = 0x04;
|
|
692
|
+
const LACING_EBML = 0x06;
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* @typedef {object} SubtitleBlock
|
|
696
|
+
* @property {number} startSeconds - When the cue appears.
|
|
697
|
+
* @property {number | null} durationSeconds - How long it stays, or null when
|
|
698
|
+
* the block carried no duration (a SimpleBlock; the caller decides).
|
|
699
|
+
* @property {Buffer} payload - The block's own bytes, still in the codec's form.
|
|
700
|
+
*/
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Read one block's header.
|
|
704
|
+
*
|
|
705
|
+
* @param {Buffer} buffer
|
|
706
|
+
* @param {number} start - First byte of the block's payload.
|
|
707
|
+
* @param {number} end - One past its last byte.
|
|
708
|
+
* @returns {{ trackNumber: number, relativeTicks: number, flags: number, dataOffset: number } | null}
|
|
709
|
+
*/
|
|
710
|
+
function readBlockHeader(buffer, start, end) {
|
|
711
|
+
const track = readVint(buffer, start, false);
|
|
712
|
+
if (!track || track.value === null) {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
const timestampAt = start + track.length;
|
|
716
|
+
// Signed, and it can be negative: a block may belong slightly before the
|
|
717
|
+
// cluster it is stored in.
|
|
718
|
+
if (timestampAt + 3 > end) {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
trackNumber: Number(track.value),
|
|
723
|
+
relativeTicks: buffer.readInt16BE(timestampAt),
|
|
724
|
+
flags: buffer[timestampAt + 2],
|
|
725
|
+
dataOffset: timestampAt + 3
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Where a laced block's first frame begins.
|
|
731
|
+
*
|
|
732
|
+
* Subtitles are rarely laced, but a block that IS laced starts with a frame
|
|
733
|
+
* count and a table of sizes, and reading the payload without stepping over
|
|
734
|
+
* them yields the table as though it were text.
|
|
735
|
+
*
|
|
736
|
+
* @param {Buffer} buffer
|
|
737
|
+
* @param {number} dataOffset - First byte after the block header.
|
|
738
|
+
* @param {number} end
|
|
739
|
+
* @param {number} flags
|
|
740
|
+
* @returns {number | null} The offset of the first frame, or null when the
|
|
741
|
+
* lacing cannot be read.
|
|
742
|
+
*/
|
|
743
|
+
function firstFrameOffset(buffer, dataOffset, end, flags) {
|
|
744
|
+
const lacing = flags & LACING_MASK;
|
|
745
|
+
if (lacing === LACING_NONE) {
|
|
746
|
+
return dataOffset;
|
|
747
|
+
}
|
|
748
|
+
if (dataOffset >= end) {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
const frames = buffer[dataOffset] + 1;
|
|
752
|
+
let at = dataOffset + 1;
|
|
753
|
+
if (lacing === LACING_FIXED) {
|
|
754
|
+
return at;
|
|
755
|
+
}
|
|
756
|
+
if (lacing === LACING_XIPH) {
|
|
757
|
+
// Each size but the last is a run of 0xFF bytes ending in a smaller one.
|
|
758
|
+
for (let frame = 0; frame < frames - 1; frame += 1) {
|
|
759
|
+
while (at < end && buffer[at] === 0xff) {
|
|
760
|
+
at += 1;
|
|
761
|
+
}
|
|
762
|
+
at += 1;
|
|
763
|
+
}
|
|
764
|
+
return at <= end ? at : null;
|
|
765
|
+
}
|
|
766
|
+
if (lacing === LACING_EBML) {
|
|
767
|
+
// The first size is a plain variable-length integer, the rest are signed
|
|
768
|
+
// differences from it; either way each is one such integer to step over.
|
|
769
|
+
for (let frame = 0; frame < frames - 1; frame += 1) {
|
|
770
|
+
const size = readVint(buffer, at, false);
|
|
771
|
+
if (!size) {
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
at += size.length;
|
|
775
|
+
}
|
|
776
|
+
return at <= end ? at : null;
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Every block of one track inside one cluster.
|
|
783
|
+
*
|
|
784
|
+
* @param {Buffer} buffer - Bytes holding the cluster's payload.
|
|
785
|
+
* @param {{ dataOffset: number, size: number }} cluster - Where that payload is.
|
|
786
|
+
* @param {number} trackNumber - The track to keep.
|
|
787
|
+
* @param {number} secondsPerTick - From the segment's timestamp scale.
|
|
788
|
+
* @returns {SubtitleBlock[]}
|
|
789
|
+
*/
|
|
790
|
+
function blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick) {
|
|
791
|
+
const end = Math.min(buffer.length, cluster.dataOffset + cluster.size);
|
|
792
|
+
/** @type {SubtitleBlock[]} */
|
|
793
|
+
const blocks = [];
|
|
794
|
+
let clusterTicks = null;
|
|
795
|
+
|
|
796
|
+
const take = (blockStart, blockEnd, durationTicks) => {
|
|
797
|
+
const header = readBlockHeader(buffer, blockStart, blockEnd);
|
|
798
|
+
if (!header || header.trackNumber !== trackNumber || clusterTicks === null) {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
const payloadAt = firstFrameOffset(buffer, header.dataOffset, blockEnd, header.flags);
|
|
802
|
+
if (payloadAt === null || payloadAt >= blockEnd) {
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
blocks.push({
|
|
806
|
+
startSeconds: (clusterTicks + header.relativeTicks) * secondsPerTick,
|
|
807
|
+
durationSeconds: durationTicks === null ? null : durationTicks * secondsPerTick,
|
|
808
|
+
payload: buffer.subarray(payloadAt, blockEnd)
|
|
809
|
+
});
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
for (const element of iterateElements(buffer, cluster.dataOffset, end)) {
|
|
813
|
+
const elementEnd = Math.min(end, element.dataOffset + element.size);
|
|
814
|
+
if (element.id === ID_TIMESTAMP) {
|
|
815
|
+
clusterTicks = readUint(buffer, element.dataOffset, element.size);
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (element.id === ID_SIMPLE_BLOCK) {
|
|
819
|
+
take(element.dataOffset, elementEnd, null);
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (element.id !== ID_BLOCK_GROUP) {
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
// A group holds the block and, for a subtitle, the duration that says when
|
|
826
|
+
// the cue leaves the screen. Both are read before either is used, because
|
|
827
|
+
// the duration may be written after the block.
|
|
828
|
+
let blockStart = null;
|
|
829
|
+
let blockEnd = null;
|
|
830
|
+
let durationTicks = null;
|
|
831
|
+
for (const field of iterateElements(buffer, element.dataOffset, elementEnd)) {
|
|
832
|
+
const fieldEnd = Math.min(elementEnd, field.dataOffset + field.size);
|
|
833
|
+
if (field.id === ID_BLOCK) {
|
|
834
|
+
blockStart = field.dataOffset;
|
|
835
|
+
blockEnd = fieldEnd;
|
|
836
|
+
} else if (field.id === ID_BLOCK_DURATION) {
|
|
837
|
+
durationTicks = readUint(buffer, field.dataOffset, field.size);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (blockStart !== null) {
|
|
841
|
+
take(blockStart, blockEnd, durationTicks);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return blocks;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* The rest of what a TrackEntry says about itself, RFC 9559 §5.1.4.1. Read
|
|
850
|
+
* because the file states them and a releaser's own wording in `Name` is the
|
|
851
|
+
* only thing we had before: "fors" and "SDH" in a menu were whatever text
|
|
852
|
+
* someone happened to type.
|
|
853
|
+
*
|
|
854
|
+
* `FlagEnabled` defaults to 1 and means "the track is usable"; a track that
|
|
855
|
+
* says 0 is counted but not offered. `FlagForced` applies only to subtitles and
|
|
856
|
+
* defaults to 0. `FlagHearingImpaired` is set "if and only if the track is
|
|
857
|
+
* suitable for users with hearing impairments". `FlagVisualImpaired`,
|
|
858
|
+
* `FlagOriginal` and `FlagCommentary` bear on the AUDIO choice and are read
|
|
859
|
+
* with that work, not here — see roadmap item 55.
|
|
860
|
+
*/
|
|
861
|
+
const ID_FLAG_HEARING_IMPAIRED = 0x55ab;
|
|
862
|
+
/**
|
|
863
|
+
* The language as RFC 5646 writes it. The specification is a MUST: "If this
|
|
864
|
+
* element is used, then any Language elements used in the same TrackEntry MUST
|
|
865
|
+
* be ignored" — so where both are present, this one is the answer and the
|
|
866
|
+
* three-letter code is not.
|
|
867
|
+
*/
|
|
868
|
+
const ID_CUES = 0x1c53bb6b;
|
|
869
|
+
const ID_CUE_POINT = 0xbb;
|
|
870
|
+
const ID_CUE_TRACK_POSITIONS = 0xb7;
|
|
871
|
+
const ID_CUE_TRACK = 0xf7;
|
|
872
|
+
const ID_CUE_CLUSTER_POSITION = 0xf1;
|
|
873
|
+
|
|
874
|
+
/** TrackType 17 is subtitles; 1 is video and 2 audio. */
|
|
875
|
+
const TRACK_TYPE_SUBTITLE = 17;
|
|
876
|
+
/** How much of the file start to read: the same window the keyframe reader uses. */
|
|
877
|
+
/** Cap on the Cues read; a long film indexes to tens of KB. */
|
|
878
|
+
const MAX_CUES_BYTES = 8 * 1024 * 1024;
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* The codecs whose blocks are text this proxy can turn into WebVTT.
|
|
882
|
+
*
|
|
883
|
+
* `S_TEXT/UTF8` is a plain line of text and needs nothing. `S_TEXT/ASS` and
|
|
884
|
+
* `S_TEXT/SSA` carry a dialogue row whose fields have to be stripped, and their
|
|
885
|
+
* header lives in CodecPrivate — supported, with the stripping done where the
|
|
886
|
+
* cue is turned into WebVTT. `S_HDMV/PGS` and `S_VOBSUB` are pictures, not
|
|
887
|
+
* text, and are deliberately absent: offering them would promise something this
|
|
888
|
+
* path cannot deliver.
|
|
889
|
+
*/
|
|
890
|
+
const TEXT_CODECS = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA"]);
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* @typedef {object} SubtitleTrackPlan
|
|
894
|
+
* @property {number} trackNumber - As the blocks name it.
|
|
895
|
+
* @property {number} declaredIndex - Its position among ALL of the file's
|
|
896
|
+
* subtitle tracks, picture-based ones included — which is the number ffmpeg
|
|
897
|
+
* gives the same stream in `0:s:N`, and therefore the only number the browser
|
|
898
|
+
* ever names. Text tracks alone are not a numbering: a file whose PGS track
|
|
899
|
+
* comes first would have every text track one lower here than in the browser.
|
|
900
|
+
* @property {string} codecId
|
|
901
|
+
* @property {string} language - The language the file declares: its RFC 5646
|
|
902
|
+
* tag where it writes one, and the three-letter code otherwise. The
|
|
903
|
+
* specification requires that order — where `LanguageBCP47` is present, the
|
|
904
|
+
* `Language` element MUST be ignored.
|
|
905
|
+
* @property {string} languageBcp47 - The RFC 5646 tag alone, or "".
|
|
906
|
+
* @property {string} name - What the file calls the track, if anything.
|
|
907
|
+
* @property {boolean} isDefault
|
|
908
|
+
* @property {boolean} isForced - `FlagForced`: the track carries what a viewer
|
|
909
|
+
* needs even when they asked for no subtitles — signs, and dialogue in
|
|
910
|
+
* another language. It does NOT carry the film's own dialogue.
|
|
911
|
+
* @property {boolean} isHearingImpaired - `FlagHearingImpaired`: suitable for
|
|
912
|
+
* viewers who cannot hear, so it carries non-speech sound as well as speech.
|
|
913
|
+
* @property {string} codecPrivate - The ASS/SSA header, base64, or "".
|
|
914
|
+
* @property {number[]} clusterPositions - File offsets of clusters whose cue
|
|
915
|
+
* points name this track, ascending. Empty when the file indexes only its
|
|
916
|
+
* picture, and then the caller has to walk clusters as they arrive instead.
|
|
917
|
+
*/
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Everything about a file's text subtitle tracks that can be learned without
|
|
922
|
+
* reading the film.
|
|
923
|
+
*
|
|
924
|
+
* @param {(start: number, end: number) => Promise<Buffer | null>} readRange
|
|
925
|
+
* @param {number} fileSize
|
|
926
|
+
* @returns {Promise<{ tracks: SubtitleTrackPlan[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
|
|
927
|
+
*/
|
|
928
|
+
async function readSubtitlePlan(readRange, fileSize) {
|
|
929
|
+
const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
|
|
930
|
+
if (!head || head.length < 4 || head.readUInt32BE(0) !== 0x1a45dfa3) {
|
|
931
|
+
return null;
|
|
932
|
+
}
|
|
933
|
+
const segment = findElement(head, ID_SEGMENT, []);
|
|
934
|
+
if (!segment) {
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
const base = segment.dataOffset;
|
|
938
|
+
|
|
939
|
+
const info = findElement(head, ID_INFO, [], base);
|
|
940
|
+
let scale = DEFAULT_TIMESTAMP_SCALE;
|
|
941
|
+
if (info) {
|
|
942
|
+
const declared = findElement(head, ID_TIMESTAMP_SCALE, [], info.dataOffset, info.dataOffset + info.size);
|
|
943
|
+
if (declared) {
|
|
944
|
+
const value = readUint(head, declared.dataOffset, declared.size);
|
|
945
|
+
if (value > 0) {
|
|
946
|
+
scale = value;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const tracksElement = findElement(head, ID_TRACKS, [], base);
|
|
952
|
+
if (!tracksElement) {
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
const tracksEnd = Math.min(head.length, tracksElement.dataOffset + tracksElement.size);
|
|
956
|
+
/** @type {SubtitleTrackPlan[]} */
|
|
957
|
+
const tracks = [];
|
|
958
|
+
/**
|
|
959
|
+
* Every subtitle track the file declares, in the order the Tracks element
|
|
960
|
+
* names them, text or picture. This is not for extraction — `tracks` is —
|
|
961
|
+
* but for lining ffmpeg's `0:s:N` numbering up against the container, which
|
|
962
|
+
* only holds while nothing is missing from the middle of the list.
|
|
963
|
+
*
|
|
964
|
+
* @type {Array<{ trackNumber: number, codecId: string, language: string, name: string, isDefault: boolean, declaresDefault: boolean }>}
|
|
965
|
+
*/
|
|
966
|
+
const declared = [];
|
|
967
|
+
for (const entry of iterateElements(head, tracksElement.dataOffset, tracksEnd)) {
|
|
968
|
+
if (entry.id !== ID_TRACK_ENTRY) {
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
|
|
972
|
+
let trackNumber = null;
|
|
973
|
+
let type = null;
|
|
974
|
+
let codecId = "";
|
|
975
|
+
let language = "";
|
|
976
|
+
let name = "";
|
|
977
|
+
let codecPrivate = "";
|
|
978
|
+
// Matroska's `FlagDefault` DEFAULTS TO 1, so a file whose muxer wrote it on
|
|
979
|
+
// no track is indistinguishable, once the default has been applied, from
|
|
980
|
+
// one that wrote it on every track — which is how ffmpeg's banner prints it
|
|
981
|
+
// and why the banner cannot answer this. Both are kept: what the flag
|
|
982
|
+
// amounts to, and whether the file said anything at all.
|
|
983
|
+
let isDefault = true;
|
|
984
|
+
let declaresDefault = false;
|
|
985
|
+
// Defaults straight from RFC 9559: a track is usable and not forced unless
|
|
986
|
+
// the file says otherwise, and the impaired flags are absent until claimed.
|
|
987
|
+
let isEnabled = true;
|
|
988
|
+
let isForced = false;
|
|
989
|
+
let isHearingImpaired = false;
|
|
990
|
+
let languageBcp47 = "";
|
|
991
|
+
for (const field of iterateElements(head, entry.dataOffset, entryEnd)) {
|
|
992
|
+
if (field.id === ID_TRACK_NUMBER) {
|
|
993
|
+
trackNumber = readUint(head, field.dataOffset, field.size);
|
|
994
|
+
} else if (field.id === ID_TRACK_TYPE) {
|
|
995
|
+
type = readUint(head, field.dataOffset, field.size);
|
|
996
|
+
} else if (field.id === ID_CODEC_ID) {
|
|
997
|
+
codecId = readString(head, field);
|
|
998
|
+
} else if (field.id === ID_LANGUAGE) {
|
|
999
|
+
language = readString(head, field);
|
|
1000
|
+
} else if (field.id === ID_LANGUAGE_BCP47) {
|
|
1001
|
+
languageBcp47 = readString(head, field);
|
|
1002
|
+
} else if (field.id === ID_NAME) {
|
|
1003
|
+
name = readString(head, field);
|
|
1004
|
+
} else if (field.id === ID_FLAG_DEFAULT) {
|
|
1005
|
+
isDefault = readUint(head, field.dataOffset, field.size) === 1;
|
|
1006
|
+
declaresDefault = true;
|
|
1007
|
+
} else if (field.id === ID_FLAG_ENABLED) {
|
|
1008
|
+
// An element written with zero length carries its default, which for
|
|
1009
|
+
// this one is 1 — so an empty element must not read as "unusable", and
|
|
1010
|
+
// neither must a value outside the declared 0-1 range. Only an explicit
|
|
1011
|
+
// zero takes a track away.
|
|
1012
|
+
isEnabled = field.size === 0 || readUint(head, field.dataOffset, field.size) !== 0;
|
|
1013
|
+
} else if (field.id === ID_FLAG_FORCED) {
|
|
1014
|
+
isForced = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
|
|
1015
|
+
} else if (field.id === ID_FLAG_HEARING_IMPAIRED) {
|
|
1016
|
+
isHearingImpaired = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
|
|
1017
|
+
} else if (field.id === ID_CODEC_PRIVATE) {
|
|
1018
|
+
codecPrivate = head.toString("base64", field.dataOffset, field.dataOffset + field.size);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
if (type !== TRACK_TYPE_SUBTITLE || trackNumber === null) {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
// A track the file marks unusable is still COUNTED. FlagEnabled says "the
|
|
1025
|
+
// track is usable", and a player should not offer it — but ffmpeg does not
|
|
1026
|
+
// drop it: `matroskadec.c` parses `MATROSKA_ID_TRACKFLAGENABLED` as
|
|
1027
|
+
// `EBML_NONE`, reading the element and keeping nothing, so the stream is
|
|
1028
|
+
// created and numbered like any other. Leaving it out of this list would
|
|
1029
|
+
// therefore shift `declaredIndex` off ffmpeg's `0:s:N` for every track
|
|
1030
|
+
// after it, which is the numbering defect this file was fixed for a day
|
|
1031
|
+
// earlier. It is counted here and refused where it is offered instead.
|
|
1032
|
+
//
|
|
1033
|
+
// `language` here stays the three-letter code, because this list exists to
|
|
1034
|
+
// be lined up against ffmpeg's banner, which prints that code. The RFC 5646
|
|
1035
|
+
// tag rides beside it for whoever displays the track.
|
|
1036
|
+
declared.push({
|
|
1037
|
+
trackNumber,
|
|
1038
|
+
codecId,
|
|
1039
|
+
language,
|
|
1040
|
+
languageBcp47,
|
|
1041
|
+
name,
|
|
1042
|
+
isDefault,
|
|
1043
|
+
declaresDefault,
|
|
1044
|
+
isEnabled,
|
|
1045
|
+
isForced,
|
|
1046
|
+
isHearingImpaired
|
|
1047
|
+
});
|
|
1048
|
+
if (!TEXT_CODECS.has(codecId) || !isEnabled) {
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
tracks.push({
|
|
1052
|
+
trackNumber,
|
|
1053
|
+
declaredIndex: declared.length - 1,
|
|
1054
|
+
codecId,
|
|
1055
|
+
// This list is ours and is not compared with ffmpeg's, so it carries the
|
|
1056
|
+
// language the file states most precisely: where RFC 5646 is written, the
|
|
1057
|
+
// three-letter code MUST be ignored.
|
|
1058
|
+
language: languageBcp47 || language,
|
|
1059
|
+
languageBcp47,
|
|
1060
|
+
name,
|
|
1061
|
+
isDefault,
|
|
1062
|
+
isForced,
|
|
1063
|
+
isHearingImpaired,
|
|
1064
|
+
codecPrivate,
|
|
1065
|
+
clusterPositions: []
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
if (tracks.length === 0) {
|
|
1069
|
+
return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Where the clusters holding those tracks are. A file that indexes only its
|
|
1073
|
+
// picture leaves these empty, which is not a failure: the caller then reads
|
|
1074
|
+
// the clusters the viewer's own playback brings in.
|
|
1075
|
+
const seekHead = findElement(head, ID_SEEK_HEAD, [], base);
|
|
1076
|
+
let cuesRelative;
|
|
1077
|
+
if (seekHead) {
|
|
1078
|
+
const seekEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
|
|
1079
|
+
for (const seek of iterateElements(head, seekHead.dataOffset, seekEnd)) {
|
|
1080
|
+
if (seek.id !== ID_SEEK) {
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
let target = null;
|
|
1084
|
+
let position = null;
|
|
1085
|
+
for (const field of iterateElements(head, seek.dataOffset, Math.min(seekEnd, seek.dataOffset + seek.size))) {
|
|
1086
|
+
if (field.id === ID_SEEK_ID) {
|
|
1087
|
+
target = readUint(head, field.dataOffset, field.size);
|
|
1088
|
+
} else if (field.id === ID_SEEK_POSITION) {
|
|
1089
|
+
position = readUint(head, field.dataOffset, field.size);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
if (target === ID_CUES && position !== null) {
|
|
1093
|
+
cuesRelative = position;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if (cuesRelative !== undefined) {
|
|
1098
|
+
const cuesAt = base + cuesRelative;
|
|
1099
|
+
if (cuesAt > 0 && cuesAt < fileSize) {
|
|
1100
|
+
const chunk = await readRange(cuesAt, Math.min(fileSize - 1, cuesAt + MAX_CUES_BYTES));
|
|
1101
|
+
const element = chunk && [...iterateElements(chunk, 0, chunk.length)][0];
|
|
1102
|
+
if (element && element.id === ID_CUES) {
|
|
1103
|
+
const body = chunk.subarray(element.dataOffset, Math.min(chunk.length, element.dataOffset + element.size));
|
|
1104
|
+
const byTrack = new Map(tracks.map((track) => [track.trackNumber, new Set()]));
|
|
1105
|
+
for (const point of iterateElements(body, 0, body.length)) {
|
|
1106
|
+
if (point.id !== ID_CUE_POINT) {
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
const pointEnd = Math.min(body.length, point.dataOffset + point.size);
|
|
1110
|
+
for (const field of iterateElements(body, point.dataOffset, pointEnd)) {
|
|
1111
|
+
if (field.id !== ID_CUE_TRACK_POSITIONS) {
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
let cueTrack = null;
|
|
1115
|
+
let position = null;
|
|
1116
|
+
for (const inner of iterateElements(body, field.dataOffset, Math.min(pointEnd, field.dataOffset + field.size))) {
|
|
1117
|
+
if (inner.id === ID_CUE_TRACK) {
|
|
1118
|
+
cueTrack = readUint(body, inner.dataOffset, inner.size);
|
|
1119
|
+
} else if (inner.id === ID_CUE_CLUSTER_POSITION) {
|
|
1120
|
+
position = readUint(body, inner.dataOffset, inner.size);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
if (position !== null && byTrack.has(cueTrack)) {
|
|
1124
|
+
byTrack.get(cueTrack).add(base + position);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
for (const track of tracks) {
|
|
1129
|
+
track.clusterPositions = [...byTrack.get(track.trackNumber)].sort((left, right) => left - right);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* The cues of one track inside one cluster.
|
|
1139
|
+
*
|
|
1140
|
+
* @param {Buffer} bytes - The cluster, from its own element header onward.
|
|
1141
|
+
* @param {number} trackNumber
|
|
1142
|
+
* @param {number} secondsPerTick
|
|
1143
|
+
* @returns {{ startSeconds: number, endSeconds: number | null, text: string }[]}
|
|
1144
|
+
*/
|
|
1145
|
+
function harvestCluster(bytes, trackNumber, secondsPerTick) {
|
|
1146
|
+
const header = [...iterateElements(bytes, 0, bytes.length)][0];
|
|
1147
|
+
if (!header) {
|
|
1148
|
+
return [];
|
|
1149
|
+
}
|
|
1150
|
+
const blocks = blocksOfTrack(
|
|
1151
|
+
bytes,
|
|
1152
|
+
{ dataOffset: header.dataOffset, size: header.size },
|
|
1153
|
+
trackNumber,
|
|
1154
|
+
secondsPerTick
|
|
1155
|
+
);
|
|
1156
|
+
// The payload is handed on as BYTES. What those bytes mean — which of them
|
|
1157
|
+
// are the text and which are the eight fields Matroska puts before it — is
|
|
1158
|
+
// stated by the container's specification and answered by
|
|
1159
|
+
// `MatroskaContainer.cueTextOf`, not here: this function's subject is where a
|
|
1160
|
+
// block sits and how long it lasts.
|
|
1161
|
+
return blocks.map((block) => ({
|
|
1162
|
+
startSeconds: block.startSeconds,
|
|
1163
|
+
endSeconds: block.durationSeconds === null ? null : block.startSeconds + block.durationSeconds,
|
|
1164
|
+
payload: block.payload
|
|
1165
|
+
}));
|
|
1166
|
+
}
|