@torrent-tv/proxy 2.89.6 → 2.89.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/AGENTS.md +32 -12
  2. package/CHANGELOG.md +2340 -2330
  3. package/assets/calibration/audio/NOTICE.md +19 -19
  4. package/assets/calibration/audio/manifest.json +98 -98
  5. package/biome.json +412 -412
  6. package/docs/browser-proxy-contract.md +155 -155
  7. package/docs/container-architecture.md +273 -273
  8. package/docs/encode-run-state.md +1 -1
  9. package/docs/playback-start-forecast.md +113 -98
  10. package/package.json +51 -50
  11. package/routes/api/sources/files/get.js +106 -106
  12. package/scripts/build-audio-samples.mjs +31 -31
  13. package/services/encode/EncodeRun.js +67 -15
  14. package/services/encode/EncodeRuns.js +19 -7
  15. package/services/encode/OutputOpening.js +784 -784
  16. package/services/encode/audio-calibration.js +67 -67
  17. package/services/encode/audio-work-rate.js +15 -15
  18. package/services/encode/encode-exit.js +2 -0
  19. package/services/encode/hwaccel.js +1894 -1894
  20. package/services/encode/output/index.js +24 -24
  21. package/services/encode/piece-completeness.js +60 -2
  22. package/services/encode/run-command.js +757 -755
  23. package/services/encode/segment-formats/index.js +1 -0
  24. package/services/encode/segment-formats/mp4-boxes.js +8 -1
  25. package/services/media/ContainerOrchestrator.js +202 -202
  26. package/services/media/KeyframeTables.js +356 -356
  27. package/services/media/SubtitleCues.js +592 -592
  28. package/services/media/audio-inventory.js +299 -299
  29. package/services/media/container/AviContainer.js +268 -268
  30. package/services/media/container/Container.js +632 -632
  31. package/services/media/container/ContainerFactory.js +38 -38
  32. package/services/media/container/MatroskaContainer.js +1031 -1031
  33. package/services/media/container/Mp4Container.js +1354 -1354
  34. package/services/media/container/SubtitleFileContainer.js +339 -339
  35. package/services/media/container/ebml-stream.js +174 -174
  36. package/services/media/container/matroska-clusters.js +1165 -1165
  37. package/services/media/container/unavailable.js +77 -77
  38. package/services/media/tracks/ContainerTrack.js +84 -84
  39. package/services/media/tracks/TextSubtitleTrack.js +295 -295
  40. package/services/storage/piece-store/shared-piece-store.js +1937 -1937
  41. package/services/storage/segment-store/SegmentStore.js +1294 -1294
  42. package/services/torrent/Contents.js +382 -382
  43. package/services/torrent/episode-naming.js +182 -182
  44. package/services/torrent/torrent-pool.js +2688 -2688
  45. package/services/torrent/worker/client.js +836 -836
  46. package/services/torrent/worker/held-bytes.js +231 -231
  47. package/services/torrent/worker/worker.js +1137 -1137
  48. package/services/transport/data-channel-handler.js +1373 -1373
  49. package/services/viewer/PriorityMap.js +421 -421
  50. package/test/audio-calibration.test.js +23 -23
  51. package/test/auto-quality-step.test.js +762 -762
  52. package/test/coalesce.test.js +39 -39
  53. package/test/coverage-follows-the-disk.test.js +5 -0
  54. package/test/decode-cost.test.js +475 -475
  55. package/test/encode-run.test.js +58 -0
  56. package/test/episode-naming.test.js +131 -131
  57. package/test/failed-encoder-still-serves-what-exists.test.js +1 -1
  58. package/test/fixtures/audio-frame-boundary.json +1 -1
  59. package/test/fixtures/refused-audio-20261002.json +10 -0
  60. package/test/fmp4-presentation-clock.test.js +4 -2
  61. package/test/format-keyed-outputs.test.js +78 -78
  62. package/test/health-metrics.test.js +11 -13
  63. package/test/held-read-ownership.test.js +77 -77
  64. package/test/helpers/encode-run.js +14 -6
  65. package/test/helpers/held-file.js +77 -77
  66. package/test/helpers/manager.js +62 -62
  67. package/test/helpers/matroska-file.js +296 -296
  68. package/test/keyframe-table-budget.test.js +123 -123
  69. package/test/matroska-cluster-discovery.test.js +323 -323
  70. package/test/one-output-one-session.test.js +273 -273
  71. package/test/output-speed.test.js +6 -1
  72. package/test/piece-completeness.test.js +37 -1
  73. package/test/quality-variants.test.js +1274 -1270
  74. package/test/rate-control-in-the-output.test.js +4 -3
  75. package/test/reads-not-yet-arrived.test.js +218 -218
  76. package/test/seek-frees-the-viewers-requests.test.js +1 -1
  77. package/test/segment-closed-on-both-branches.test.js +114 -114
  78. package/test/segment-judged-by-its-own-header.test.js +222 -222
  79. package/test/segment-serve-wiring.test.js +481 -481
  80. package/test/serving-existing-output.test.js +111 -111
  81. package/test/source-files-route.test.js +113 -113
  82. package/test/subtitle-cue-walk.test.js +385 -385
  83. package/test/subtitle-metadata.test.js +15 -15
  84. package/test/subtitle-plan-waits.test.js +125 -125
  85. package/test/subtitle-track-numbering.test.js +391 -391
  86. package/test/two-viewers-one-picture.test.js +508 -508
  87. package/test/viewer-requests-admission.test.js +92 -92
  88. package/test/viewer-starts-on-map.test.js +27 -27
  89. package/test/viewer-states.test.js +5 -2
  90. package/test/viewing-generations.test.js +390 -390
  91. package/test/whole-file-held-read.test.js +30 -30
  92. package/utils/coalesce.js +41 -41
@@ -1,1354 +1,1354 @@
1
- /**
2
- * @file MP4/MOV container — ISO/IEC 14496-12.
3
- *
4
- * Parses moov for all track types in one walk:
5
- * - tkhd: track_ID, flags track_enabled (0x000001), alternate_group, width/height
6
- * - mdhd: timescale, language (packed 5-bit), version handling
7
- * - hdlr: handler_type (vide/soun/text/sbtl/subt/subp/clcp)
8
- * - elng: extendedLanguage BCP47 (when present, replaces mdhd language per spec)
9
- * - stsd: sample entry format (avc1/hev1/mp4a/tx3g/wvtt/stpp)
10
- * - stbl tables for subtitle cue ranges (stts/stsz/stsc/stco/co64) and video keyframes (stss/stts/ctts/elst)
11
- *
12
- * Keyframe reading is delegated to mp4.js. The subtitle sample table is read in
13
- * this module, because every rule in it is a statement of ISO/IEC 14496-12 about
14
- * this container. Track creation is centralised here so that track_enabled,
15
- * `elng` and alternate_group are handled once for every media type.
16
- */
17
-
18
- import { Container } from "./Container.js";
19
- import { VideoTrack } from "../tracks/VideoTrack.js";
20
- import { AudioTrack } from "../tracks/AudioTrack.js";
21
- import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
22
- import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
23
- import { isUnavailable } from "./unavailable.js";
24
-
25
- /** A box header is eight bytes, or sixteen when the size field says 1 (§4.2). */
26
- const HEADER_BYTES = 8;
27
- const LARGE_SIZE_MARKER = 1;
28
- const LARGE_HEADER_BYTES = 16;
29
-
30
- /**
31
- * One box header, per ISO/IEC 14496-12 §4.2.
32
- *
33
- * @param {Buffer} buf
34
- * @param {number} off
35
- * @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
36
- */
37
- function readBox(buffer, offset) {
38
- if (offset + HEADER_BYTES > buffer.length) {
39
- return null;
40
- }
41
- let size = buffer.readUInt32BE(offset);
42
- const type = buffer.toString("latin1", offset + 4, offset + 8);
43
- let headerBytes = HEADER_BYTES;
44
- // A size of 1 means the real size is the 64-bit value after the type.
45
- if (size === LARGE_SIZE_MARKER) {
46
- if (offset + LARGE_HEADER_BYTES > buffer.length) {
47
- return null;
48
- }
49
- size = Number(buffer.readBigUInt64BE(offset + HEADER_BYTES));
50
- headerBytes = LARGE_HEADER_BYTES;
51
- }
52
- if (size < headerBytes) {
53
- return null;
54
- }
55
- return { type, size, dataOffset: offset + headerBytes, end: offset + size };
56
- }
57
-
58
- /**
59
- * Every direct child box of the given type.
60
- *
61
- * @param {Buffer} buf
62
- * @param {number} start
63
- * @param {number} end
64
- * @param {string} type
65
- * @returns {Array<{ type: string, size: number, dataOffset: number, end: number }>}
66
- */
67
- function childrenOf(buf, start, end, type) {
68
- const out = [];
69
- let p = start;
70
- while (p + 8 <= end) {
71
- const b = readBox(buf, p);
72
- if (!b) break;
73
- if (b.type === type) out.push(b);
74
- p = b.end;
75
- }
76
- return out;
77
- }
78
-
79
- /**
80
- * The first direct child box of the given type, or null.
81
- *
82
- * @param {Buffer} buf
83
- * @param {number} s
84
- * @param {number} e
85
- * @param {string} t
86
- * @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
87
- */
88
- function childOf(buf, s, e, t) {
89
- return childrenOf(buf, s, e, t)[0] ?? null;
90
- }
91
-
92
- /**
93
- * The channel count, sampling frequency and decoder configuration one audio
94
- * sample entry states (ISO/IEC 14496-12 §12.2.3, AudioSampleEntry).
95
- *
96
- * After the 8 bytes every SampleEntry begins with come 8 reserved bytes —
97
- * whose first two QuickTime uses as a version that lengthens the entry by 16
98
- * (version 1) or 36 (version 2) bytes — then `channelcount`, `samplesize`, two
99
- * reserved fields and `samplerate` as 16.16 fixed point. Child boxes follow.
100
- * For `mp4a` the child `esds` holds the AudioSpecificConfig (ISO/IEC 14496-1
101
- * §7.2.6: ES_Descriptor, then DecoderConfigDescriptor, then
102
- * DecoderSpecificInfo).
103
- *
104
- * @param {Buffer} buf
105
- * @param {{ type: string, dataOffset: number, end: number }} entry
106
- * @returns {{ channels: number | null, sampleRate: number | null, decoderConfig: Buffer | null } | null}
107
- */
108
- function readAudioSampleEntry(buf, entry) {
109
- const base = entry.dataOffset;
110
- if (base + 28 > entry.end || base + 28 > buf.length) {
111
- return null;
112
- }
113
- const version = buf.readUInt16BE(base + 8);
114
- const channels = buf.readUInt16BE(base + 16);
115
- const sampleRate = buf.readUInt32BE(base + 24) / 65536;
116
- const childrenStart = base + 28 + (version === 1 ? 16 : version === 2 ? 36 : 0);
117
- let decoderConfig = null;
118
- if (entry.type === "mp4a" && childrenStart < entry.end) {
119
- const esds = childOf(buf, childrenStart, Math.min(entry.end, buf.length), "esds");
120
- if (esds) {
121
- decoderConfig = decoderSpecificInfoOf(buf, esds.dataOffset + 4, Math.min(esds.end, buf.length));
122
- }
123
- }
124
- return {
125
- channels: channels > 0 ? channels : null,
126
- sampleRate: sampleRate > 0 ? sampleRate : null,
127
- decoderConfig
128
- };
129
- }
130
-
131
- /**
132
- * The DecoderSpecificInfo inside an `esds` payload, or null.
133
- *
134
- * Descriptors are a tag byte and a size of one to four bytes, seven bits each,
135
- * the high bit saying another follows (ISO/IEC 14496-1 §8.3.3).
136
- *
137
- * @param {Buffer} buf
138
- * @param {number} start
139
- * @param {number} end
140
- * @returns {Buffer | null}
141
- */
142
- function decoderSpecificInfoOf(buf, start, end) {
143
- const descriptorAt = (offset) => {
144
- if (offset >= end) return null;
145
- const tag = buf[offset];
146
- let size = 0;
147
- let cursor = offset + 1;
148
- for (let i = 0; i < 4 && cursor < end; i += 1) {
149
- const byte = buf[cursor];
150
- cursor += 1;
151
- size = (size * 128) + (byte & 0x7f);
152
- if ((byte & 0x80) === 0) break;
153
- }
154
- return { tag, dataOffset: cursor, end: Math.min(end, cursor + size) };
155
- };
156
- const es = descriptorAt(start);
157
- if (!es || es.tag !== 0x03 || es.dataOffset + 3 > es.end) return null;
158
- const flags = buf[es.dataOffset + 2];
159
- let cursor = es.dataOffset + 3;
160
- if (flags & 0x80) cursor += 2;
161
- if (flags & 0x40) cursor += 1 + (buf[cursor] ?? 0);
162
- if (flags & 0x20) cursor += 2;
163
- while (cursor < es.end) {
164
- const descriptor = descriptorAt(cursor);
165
- if (!descriptor) return null;
166
- if (descriptor.tag === 0x04) {
167
- // objectTypeIndication, streamType, bufferSizeDB, maxBitrate, avgBitrate.
168
- let inner = descriptor.dataOffset + 13;
169
- while (inner < descriptor.end) {
170
- const child = descriptorAt(inner);
171
- if (!child) return null;
172
- if (child.tag === 0x05) {
173
- return child.end > child.dataOffset ? Buffer.from(buf.subarray(child.dataOffset, child.end)) : null;
174
- }
175
- inner = child.end;
176
- }
177
- return null;
178
- }
179
- cursor = descriptor.end;
180
- }
181
- return null;
182
- }
183
-
184
- export class Mp4Container extends Container {
185
- get formatName() {
186
- return "mp4";
187
- }
188
-
189
- static detect(head) {
190
- return isMp4(head);
191
- }
192
-
193
- /**
194
- * The keyframe times this container's own index states, in ascending seconds.
195
- *
196
- * Static so a caller that has bytes and no container can ask; the instance
197
- * form is {@link Container#readKeyframeIndex}.
198
- *
199
- * @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
200
- * @param {number} fileSize
201
- * @returns {Promise<number[]|null>} Null where the container has no index.
202
- */
203
- static async readKeyframeTimes(readRange, fileSize) {
204
- const index = await new Mp4Container({ readRange, fileSize }).parseKeyframeIndex();
205
- return index ? index.times : null;
206
- }
207
-
208
- /**
209
- * This container's text subtitle tracks, with every cue's time and byte
210
- * range — ISO/IEC 14496-12 §8.5 and §8.7.
211
- *
212
- * @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
213
- * @param {number} fileSize
214
- * @returns {Promise<{ tracks: object[] } | null>}
215
- */
216
- static async readSubtitlePlan(readRange, fileSize) {
217
- const held = await new Mp4Container({ readRange, fileSize }).#moovBuffer();
218
- return held ? subtitlePlanFromMoov(held.moov) : null;
219
- }
220
-
221
- /**
222
- * The same reading, over the file this container was built on.
223
- *
224
- * The static form exists for a caller that has bytes and no container; this
225
- * is the one to use otherwise, because the reader is already here.
226
- *
227
- * @returns {Promise<object|null>}
228
- */
229
- async readSubtitlePlan() {
230
- const held = await this.#moovBuffer();
231
- const plan = held ? subtitlePlanFromMoov(held.moov) : null;
232
- if (!plan) {
233
- return null;
234
- }
235
- // An MP4 states every sample's byte range in its own table, so a cue costs
236
- // its own few dozen bytes rather than the cluster around it — which is why
237
- // there are `samples` here and no `clusterPositions`.
238
- //
239
- // An MP4 has no element meaning "show this subtitle track by default", so
240
- // `declared` is empty: the container states nothing, and nothing is shown
241
- // unasked.
242
- return {
243
- tracks: plan.tracks.map((track, order) => ({
244
- trackNumber: track.trackId,
245
- declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
246
- codecId: track.format,
247
- language: track.language,
248
- languageSource: "language",
249
- name: "",
250
- isDefault: order === 0,
251
- codecPrivate: "",
252
- clusterPositions: [],
253
- samples: track.samples
254
- })),
255
- declared: [],
256
- secondsPerTick: 0.001,
257
- segmentDataOffset: 0
258
- };
259
- }
260
-
261
-
262
- /**
263
- * Read the samples of one subtitle track that are HELD now.
264
- *
265
- * An MP4 states every sample's byte range in its own table, so a cue costs its
266
- * own few dozen bytes rather than the cluster around it — which is why this
267
- * reads per sample where Matroska reads per cluster.
268
- *
269
- * Nothing is fetched: a sample whose bytes are not downloaded is left for the
270
- * next call, and so is one whose read did not complete — a sample is marked
271
- * read only once its bytes are in hand.
272
- *
273
- * @param {object} _plan
274
- * @param {{ trackNumber: number, codecId: string, samples: {offset: number, size: number, startSeconds: number, endSeconds: number}[] }} track
275
- * @param {object} progress
276
- * @param {import("./Container.js").HeldReader} held
277
- * @returns {Promise<{ found: Map<number, object[]>, covered: number, indexed: number, withdrawn: number[] }>}
278
- */
279
- async readHeldCues(_plan, track, progress, held) {
280
- if (!progress.mp4) {
281
- progress.mp4 = { harvested: new Map() };
282
- }
283
- let harvested = progress.mp4.harvested.get(track.trackNumber);
284
- if (!harvested) {
285
- harvested = new Set();
286
- progress.mp4.harvested.set(track.trackNumber, harvested);
287
- }
288
- const cues = await this.readHeldSamples(track, harvested, held);
289
- return {
290
- found: cues.length > 0 ? new Map([[track.trackNumber, cues]]) : new Map(),
291
- covered: harvested.size,
292
- indexed: track?.samples?.length ?? 0,
293
- withdrawn: []
294
- };
295
- }
296
-
297
- /**
298
- * @param {object} track
299
- * @param {Set<number>} harvested
300
- * @param {import("./Container.js").HeldReader} held
301
- * @returns {Promise<{startSeconds: number, endSeconds: number, text: string, source: number}[]>}
302
- */
303
- async readHeldSamples(track, harvested, held) {
304
- const found = [];
305
- for (const sample of track?.samples ?? []) {
306
- if (harvested.has(sample.offset)) {
307
- continue;
308
- }
309
- const last = Math.min(this.fileSize - 1, sample.offset + sample.size - 1);
310
- if (!held.isHeld(sample.offset, last)) {
311
- continue;
312
- }
313
- let bytes;
314
- try {
315
- bytes = await held.read(sample.offset, last);
316
- } catch (error) {
317
- if (isUnavailable(error)) {
318
- continue;
319
- }
320
- throw error;
321
- }
322
- harvested.add(sample.offset);
323
- // The MP4 has framed this cue and is the one that unframes it.
324
- const text = Mp4Container.cueTextOf(bytes, track.codecId);
325
- if (text) {
326
- found.push({ startSeconds: sample.startSeconds, endSeconds: sample.endSeconds, text, source: sample.offset });
327
- }
328
- }
329
- return found;
330
- }
331
-
332
- async readTracks() {
333
- const head = await this.readRange(0, Math.min(64 - 1, this.fileSize - 1));
334
- if (!isMp4(head)) return [];
335
-
336
- // Subtitle tracks from the subtitle reading of the one `moov`, video and
337
- // audio from a walk of the same buffer — the box is read once per file.
338
- // A read that has not arrived is not swallowed here: it is not an answer
339
- // about the file, and the caller keeps nothing for it.
340
- const held = await this.#moovBuffer();
341
- const subtitlePlan = held ? subtitlePlanFromMoov(held.moov) : null;
342
- const subtitleByDecl = new Map();
343
- if (subtitlePlan?.tracks) {
344
- for (const t of subtitlePlan.tracks) subtitleByDecl.set(t.declaredIndex, t);
345
- }
346
-
347
- // Minimal moov walk for video/audio: reuse isMp4 + findMoov logic by reading via existing helper
348
- // Instead of duplicating, parse tracks via a second full moov read that collects vide/soun.
349
- // We read moov box directly to extract video/audio tracks.
350
- const tracks = await this.#readVideoAudioTracks();
351
- // Append subtitle tracks from plan, converting to domain objects
352
- if (subtitlePlan?.tracks) {
353
- for (const s of subtitlePlan.tracks) {
354
- const isText = TEXT_FORMATS_MP4.has(s.format);
355
- const Cls = isText ? TextSubtitleTrack : ImageSubtitleTrack;
356
- tracks.push(new Cls({
357
- trackNumber: s.trackId,
358
- declaredIndex: s.declaredIndex,
359
- codecId: s.format,
360
- language: s.language,
361
- languageBcp47: "",
362
- languageSource: "language",
363
- name: "",
364
- isEnabled: true,
365
- isDefault: s.declaredIndex === 0,
366
- declaresDefault: false,
367
- codecPrivateB64: "",
368
- // The sample entry's own words, read below. Either
369
- // bit is enough: a file that sets only "all samples are forced" is
370
- // saying what a well-formed one says twice.
371
- isForced: s.someSamplesForced === true || s.allSamplesForced === true,
372
- isHearingImpaired: false,
373
- clusterPositions: [],
374
- samples: s.samples
375
- }));
376
- }
377
- // Count non-text subtitle handlers (subp/clcp/stpp) for declaredIndex correctness — they are already
378
- // accounted for in subtitlePlan's declaredIndex via SUBTITLE_HANDLERS, but we didn't create objects for
379
- // them above when they were stpp (non-text not in plan's tracks). The plan already excludes stpp from tracks
380
- // but increments declaredIndex, so alignment holds: we don't need extra placeholders.
381
- }
382
- return tracks;
383
- }
384
-
385
- /**
386
- * The `moov` box, read whole — the ONE reading of it every question here is
387
- * answered from.
388
- *
389
- * Held on the instance because every question this class answers is inside
390
- * it, and the box can be tens of megabytes off a torrent. Until 2026-10-01 it
391
- * was read by three functions besides this one, each with its own rule for a
392
- * read that failed.
393
- *
394
- * Three outcomes are kept: the box; null where the file has no `moov`; and
395
- * null with `moovRefused` set where the box is larger than this program reads
396
- * whole — a limit of ours, said as one, not an absence. A read whose bytes
397
- * have not arrived throws `BytesUnavailable` and keeps nothing.
398
- *
399
- * @returns {Promise<{ moov: Buffer, header: number } | null>}
400
- */
401
- async #moovBuffer() {
402
- if (this.moovHeld !== undefined) {
403
- return this.moovHeld;
404
- }
405
- const found = await findMoov(this.readRange, this.fileSize);
406
- if (!found) {
407
- this.moovHeld = null;
408
- return null;
409
- }
410
- if (found.size > MAX_MOOV_BYTES) {
411
- this.moovRefused = { size: found.size, limit: MAX_MOOV_BYTES };
412
- this.moovHeld = null;
413
- return null;
414
- }
415
- const moov = await this.readRange(found.offset, found.offset + found.size - 1);
416
- this.moovHeld = { moov, header: found.headerBytes };
417
- return this.moovHeld;
418
- }
419
-
420
- /**
421
- * Duration from `mvhd` and the presentation offset from the first track's
422
- * edit list, per ISO/IEC 14496-12 §8.2.2 and §8.6.6.
423
- *
424
- * An edit entry whose `media_time` is -1 is an EMPTY edit: it presents
425
- * nothing for `segment_duration`, which shifts everything after it later by
426
- * that much. That shift is what a player reports as the file's start, and it
427
- * is the only way an MP4 states one — a file without such an edit begins at
428
- * zero, which is a declaration, not an absence.
429
- *
430
- * @returns {Promise<import("./Container.js").ContainerMediaInfo>}
431
- */
432
- async readMediaInfo() {
433
- if (this.mediaInfo) {
434
- return this.mediaInfo;
435
- }
436
- /** @type {import("./Container.js").ContainerMediaInfo} */
437
- const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: null };
438
- const held = await this.#moovBuffer();
439
- // Kept only once the `moov` reading has an outcome: a read that had not
440
- // arrived used to leave an empty answer here for the life of the container.
441
- this.mediaInfo = info;
442
- if (!held) {
443
- return info;
444
- }
445
- const { moov, header } = held;
446
- const mvhd = childOf(moov, header, moov.length, "mvhd");
447
- let movieTimescale = 0;
448
- if (mvhd) {
449
- const version = moov[mvhd.dataOffset];
450
- // version 0: creation(4) modification(4) timescale(4) duration(4)
451
- // version 1: creation(8) modification(8) timescale(4) duration(8)
452
- const at = version === 1 ? mvhd.dataOffset + 20 : mvhd.dataOffset + 12;
453
- if (at + 8 <= moov.length) {
454
- movieTimescale = moov.readUInt32BE(at);
455
- const duration = version === 1 ? Number(moov.readBigUInt64BE(at + 4)) : moov.readUInt32BE(at + 4);
456
- if (movieTimescale > 0 && duration > 0) {
457
- info.durationSeconds = duration / movieTimescale;
458
- }
459
- }
460
- }
461
- info.startTimeSeconds = movieTimescale > 0
462
- ? Mp4Container.#emptyEditSeconds(moov, header, movieTimescale)
463
- : null;
464
- return info;
465
- }
466
-
467
- /**
468
- * The presentation shift of the first empty edit, in seconds; 0 when no track
469
- * declares one.
470
- *
471
- * @param {Buffer} moov
472
- * @param {number} header
473
- * @param {number} movieTimescale
474
- * @returns {number}
475
- */
476
- static #emptyEditSeconds(moov, header, movieTimescale) {
477
- let shift = 0;
478
- for (const trak of childrenOf(moov, header, moov.length, "trak")) {
479
- const edts = childOf(moov, trak.dataOffset, trak.end, "edts");
480
- const elst = edts && childOf(moov, edts.dataOffset, edts.end, "elst");
481
- if (!elst) {
482
- continue;
483
- }
484
- const version = moov[elst.dataOffset];
485
- const count = moov.readUInt32BE(elst.dataOffset + 4);
486
- if (count < 1) {
487
- continue;
488
- }
489
- const entry = elst.dataOffset + 8;
490
- const segmentDuration = version === 1
491
- ? Number(moov.readBigUInt64BE(entry))
492
- : moov.readUInt32BE(entry);
493
- const mediaTime = version === 1
494
- ? Number(moov.readBigInt64BE(entry + 8))
495
- : moov.readInt32BE(entry + 4);
496
- if (mediaTime === -1 && segmentDuration > 0) {
497
- shift = Math.max(shift, segmentDuration / movieTimescale);
498
- }
499
- }
500
- return shift;
501
- }
502
-
503
- async #readVideoAudioTracks() {
504
- const held = await this.#moovBuffer();
505
- if (!held) return [];
506
- const { moov, header: moovHeader } = held;
507
-
508
- const result = [];
509
- let videoIdx = -1;
510
- let audioIdx = -1;
511
-
512
- const moovContentStart = moovHeader;
513
- const moovEnd = moov.length;
514
- for (const trak of childrenOf(moov, moovContentStart, moovEnd, "trak")) {
515
- const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
516
- if (!mdia) continue;
517
- const hdlr = childOf(moov, mdia.dataOffset, mdia.end, "hdlr");
518
- const handler = hdlr ? moov.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) : "";
519
- const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
520
- const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
521
- let language = "";
522
- if (mdhd) {
523
- const ver = moov[mdhd.dataOffset];
524
- const langAt = ver === 1 ? mdhd.dataOffset + 32 : mdhd.dataOffset + 20;
525
- if (langAt + 2 <= mdhd.end) {
526
- const packed = moov.readUInt16BE(langAt);
527
- language = [10, 5, 0].map((s) => String.fromCharCode(((packed >> s) & 0x1f) + 0x60)).join("").replace(/[^a-z]/g, "");
528
- }
529
- }
530
- // elng overrides mdhd language per spec §8.4.6
531
- let languageBcp47 = "";
532
- const elng = childOf(moov, mdia.dataOffset, mdia.end, "elng");
533
- if (elng && elng.end - elng.dataOffset >= 4) {
534
- languageBcp47 = moov.toString("utf8", elng.dataOffset + 4, elng.end).replace(/\0+$/, "");
535
- }
536
- let trackId = 0;
537
- let isEnabled = true;
538
- let alternateGroup = 0;
539
- let width = null;
540
- let height = null;
541
- if (tkhd) {
542
- const ver = moov[tkhd.dataOffset];
543
- const flags = moov.readUInt32BE(tkhd.dataOffset + 1) & 0xffffff; // 3 bytes after version
544
- isEnabled = (flags & 0x000001) !== 0;
545
- trackId = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12);
546
- alternateGroup = moov.readUInt16BE(ver === 1 ? tkhd.dataOffset + 26 : tkhd.dataOffset + 18);
547
- // width/height are 16.16 fixed point at end of tkhd
548
- if (tkhd.end - tkhd.dataOffset >= 84) {
549
- const w = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 76 : tkhd.dataOffset + 68);
550
- const h = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 80 : tkhd.dataOffset + 72);
551
- width = w / 65536;
552
- height = h / 65536;
553
- }
554
- }
555
- const resolvedLang = languageBcp47 || language;
556
- if (handler === "vide") {
557
- videoIdx += 1;
558
- // stsd format for codecId
559
- let codecId = "";
560
- const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
561
- const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
562
- const stsd = stbl && childOf(moov, stbl.dataOffset, stbl.end, "stsd");
563
- if (stsd) {
564
- const first = readBox(moov, stsd.dataOffset + 8);
565
- if (first) codecId = first.type;
566
- }
567
- result.push(new VideoTrack({
568
- trackNumber: trackId,
569
- declaredIndex: videoIdx,
570
- codecId,
571
- language: resolvedLang,
572
- languageBcp47,
573
- name: "",
574
- isEnabled,
575
- isDefault: true,
576
- declaresDefault: false,
577
- codecPrivateB64: "",
578
- alternateGroup,
579
- width,
580
- height
581
- }));
582
- } else if (handler === "soun") {
583
- audioIdx += 1;
584
- let codecId = "";
585
- let sampleEntry = null;
586
- const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
587
- const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
588
- const stsd = stbl && childOf(moov, stbl.dataOffset, stbl.end, "stsd");
589
- if (stsd) {
590
- const first = readBox(moov, stsd.dataOffset + 8);
591
- if (first) {
592
- codecId = first.type;
593
- sampleEntry = readAudioSampleEntry(moov, first);
594
- }
595
- }
596
- result.push(new AudioTrack({
597
- trackNumber: trackId,
598
- declaredIndex: audioIdx,
599
- codecId,
600
- language: resolvedLang,
601
- languageBcp47,
602
- name: "",
603
- isEnabled,
604
- isDefault: true,
605
- declaresDefault: false,
606
- // The AudioSpecificConfig an `mp4a` entry carries in its `esds`, which
607
- // is what Matroska writes as CodecPrivate for the same codec.
608
- codecPrivateB64: sampleEntry?.decoderConfig ? sampleEntry.decoderConfig.toString("base64") : "",
609
- alternateGroup,
610
- isOriginal: false,
611
- isCommentary: false,
612
- isVisualImpaired: false,
613
- channels: sampleEntry?.channels ?? null,
614
- samplingFrequency: sampleEntry?.sampleRate ?? null
615
- }));
616
- }
617
- }
618
- return result;
619
- }
620
-
621
- /**
622
- * The text field of one cue as MP4 frames it.
623
- *
624
- * A `tx3g`/`text` sample is a 16-bit big-endian length followed by that many
625
- * bytes of UTF-8 (ISO/IEC 14496-12 §12.6.3 and Apple's text sample format); a
626
- * `wvtt` sample is a sequence of boxes whose `vttc`/`payl` holds the cue text
627
- * (§12.6.3.2). Neither carries the subtitle format's own markup, so the
628
- * markup step that follows has nothing to take off — it is applied all the
629
- * same, because which step applies is decided by the codec and not here.
630
- *
631
- * The byte reading itself is this module's,
632
- * alongside the sample-table walk that found the range.
633
- *
634
- * @param {Buffer} payload - The sample's own bytes.
635
- * @param {string} codecId - Sample entry type: `tx3g`, `text` or `wvtt`.
636
- * @returns {string}
637
- */
638
- static cueTextOf(payload, codecId) {
639
- return decodeSubtitleSample(payload, codecId);
640
- }
641
-
642
- async parseKeyframeIndex() {
643
- const held = await this.#moovBuffer();
644
- const r = held ? keyframeTimesFromMoov(held.moov, held.header) : null;
645
- if (!r) return null;
646
- if (Array.isArray(r)) return { times: r, tolerance: 0 };
647
- return r;
648
- }
649
- }
650
-
651
- // ---------------------------------------------------------------------------
652
- // The MP4's own reading of its subtitle sample table. Here because every rule
653
- // in it is ISO/IEC 14496-12 speaking about this container, and the class is
654
- // the only way in.
655
- // ---------------------------------------------------------------------------
656
- /**
657
- * @file The text subtitle tracks of an MP4, and where each cue's bytes are.
658
- *
659
- * The same rule as the Matroska side: nothing is extracted with ffmpeg and
660
- * nothing is fetched for its own sake. Here it is cheaper still. Matroska hides
661
- * its subtitle blocks inside clusters shared with the picture, so a cue costs
662
- * whatever cluster holds it; an MP4 states every sample's offset and length in
663
- * the sample table, so a cue costs its own bytes and nothing more — usually a
664
- * few dozen of them.
665
- *
666
- * The tables, from ISO/IEC 14496-12:
667
- *
668
- * stsd — what the samples are (`tx3g` timed text, `wvtt` WebVTT, `stpp` TTML)
669
- * stts — how long each sample lasts, run-length encoded (§8.6.1.2)
670
- * stsz — how long each sample is, in bytes (§8.7.3)
671
- * stsc — how samples are grouped into chunks (§8.7.4)
672
- * stco / co64 — where each chunk begins in the file (§8.7.5)
673
- *
674
- * Together they give, for sample N: when it starts, how long it stays, and the
675
- * exact byte range holding it. That is everything needed to show a cue without
676
- * reading anything else.
677
- */
678
-
679
- const PROBE_BYTES = 64;
680
- const MAX_MOOV_BYTES = 32 * 1024 * 1024;
681
-
682
- /** Handlers that mean "this track is text on screen". */
683
- const TEXT_HANDLERS = new Set(["text", "sbtl", "subt"]);
684
- /**
685
- * Every handler ffmpeg's mov demuxer turns into a SUBTITLE stream, whether or
686
- * not this file can read it — `subp` is a DVD subpicture and `clcp` closed
687
- * captions, both pictures or caption data rather than text. They are counted
688
- * because `declaredIndex` has to equal ffmpeg's `0:s:N`, and a track left out
689
- * of the count shifts every text track after it, which is the very defect
690
- * `declaredIndex` exists to remove.
691
- */
692
- const SUBTITLE_HANDLERS = new Set([...TEXT_HANDLERS, "subp", "clcp"]);
693
- /** Sample formats this can turn into cues. `stpp` (TTML) is XML and is not one. */
694
- const TEXT_FORMATS = new Set(["tx3g", "text", "wvtt"]);
695
-
696
-
697
-
698
-
699
-
700
- /**
701
- * Sample durations, expanded from the run-length table.
702
- *
703
- * @param {Buffer} moov
704
- * @param {{ dataOffset: number, end: number }} stts
705
- * @param {number} total - How many samples the size table declares.
706
- * @returns {number[]} Ticks each sample lasts.
707
- */
708
- function sampleDurations(moov, stts, total) {
709
- const durations = new Array(total).fill(0);
710
- const entries = moov.readUInt32BE(stts.dataOffset + 4);
711
- let at = stts.dataOffset + 8;
712
- let sample = 0;
713
- for (let entry = 0; entry < entries && at + 8 <= stts.end && sample < total; entry += 1, at += 8) {
714
- const count = moov.readUInt32BE(at);
715
- const delta = moov.readUInt32BE(at + 4);
716
- for (let index = 0; index < count && sample < total; index += 1, sample += 1) {
717
- durations[sample] = delta;
718
- }
719
- }
720
- return durations;
721
- }
722
-
723
- /**
724
- * Sample sizes, whether the table states one for all or one for each.
725
- *
726
- * @param {Buffer} moov
727
- * @param {{ dataOffset: number, end: number }} stsz
728
- * @returns {number[]}
729
- */
730
- function sampleSizes(moov, stsz) {
731
- const uniform = moov.readUInt32BE(stsz.dataOffset + 4);
732
- const count = moov.readUInt32BE(stsz.dataOffset + 8);
733
- if (uniform > 0) {
734
- return new Array(count).fill(uniform);
735
- }
736
- const sizes = new Array(count).fill(0);
737
- let at = stsz.dataOffset + 12;
738
- for (let index = 0; index < count && at + 4 <= stsz.end; index += 1, at += 4) {
739
- sizes[index] = moov.readUInt32BE(at);
740
- }
741
- return sizes;
742
- }
743
-
744
- /**
745
- * Where every sample of a track begins in the file.
746
- *
747
- * The sample-to-chunk table says how many samples each run of chunks holds, and
748
- * the chunk-offset table says where each chunk starts; a sample's own offset is
749
- * its chunk's start plus the sizes of the samples before it in that chunk.
750
- *
751
- * @param {Buffer} moov
752
- * @param {{ dataOffset: number, end: number }} stsc
753
- * @param {number[]} chunkOffsets
754
- * @param {number[]} sizes
755
- * @returns {number[]}
756
- */
757
- function sampleOffsets(moov, stsc, chunkOffsets, sizes) {
758
- const offsets = new Array(sizes.length).fill(0);
759
- const entries = moov.readUInt32BE(stsc.dataOffset + 4);
760
- /** @type {{ firstChunk: number, perChunk: number }[]} */
761
- const runs = [];
762
- let at = stsc.dataOffset + 8;
763
- for (let entry = 0; entry < entries && at + 12 <= stsc.end; entry += 1, at += 12) {
764
- runs.push({ firstChunk: moov.readUInt32BE(at), perChunk: moov.readUInt32BE(at + 4) });
765
- }
766
- let sample = 0;
767
- for (let run = 0; run < runs.length && sample < sizes.length; run += 1) {
768
- const from = runs[run].firstChunk;
769
- const to = run + 1 < runs.length ? runs[run + 1].firstChunk - 1 : chunkOffsets.length;
770
- for (let chunk = from; chunk <= to && sample < sizes.length; chunk += 1) {
771
- let inChunk = chunkOffsets[chunk - 1];
772
- if (inChunk === undefined) {
773
- break;
774
- }
775
- for (let index = 0; index < runs[run].perChunk && sample < sizes.length; index += 1, sample += 1) {
776
- offsets[sample] = inChunk;
777
- inChunk += sizes[sample];
778
- }
779
- }
780
- }
781
- return offsets;
782
- }
783
-
784
- /**
785
- * @typedef {object} Mp4SubtitleSample
786
- * @property {number} startSeconds
787
- * @property {number} endSeconds
788
- * @property {number} offset - Where the sample's bytes are in the file.
789
- * @property {number} size
790
- */
791
-
792
- /**
793
- * @typedef {object} Mp4SubtitleTrack
794
- * @property {number} trackId
795
- * @property {number} declaredIndex - Its position among ALL of the file's
796
- * subtitle tracks, including the ones whose sample format this cannot turn
797
- * into cues (`stpp` TTML). That is the number ffmpeg gives the same stream in
798
- * `0:s:N`, which is the number the browser names; counting only the readable
799
- * ones would shift every track after a TTML one.
800
- * @property {string} format - `tx3g`, `text` or `wvtt`.
801
- * @property {string} language - Three letters, as the file declares them.
802
- * @property {boolean} someSamplesForced - The sample entry says at least one
803
- * cue carries a forced (`frcd`) atom.
804
- * @property {boolean} allSamplesForced - The sample entry says every cue is to
805
- * be treated as forced, whether or not it carries that atom.
806
- * @property {Mp4SubtitleSample[]} samples - In time order.
807
- */
808
-
809
- /**
810
- * The text subtitle tracks of an MP4, with every cue's time and byte range.
811
- *
812
- * @param {Buffer} moov - The whole `moov` box, its header included.
813
- * @returns {{ tracks: Mp4SubtitleTrack[] } | null}
814
- */
815
- function subtitlePlanFromMoov(moov) {
816
- if (!moov || moov.length < HEADER_BYTES) {
817
- return null;
818
- }
819
- const moovBox = readBox(moov, 0);
820
- if (!moovBox) {
821
- return null;
822
- }
823
-
824
- /** @type {Mp4SubtitleTrack[]} */
825
- const tracks = [];
826
- // Counts every subtitle track the file has, whether or not this can read it,
827
- // so the number handed out matches ffmpeg's `0:s:N`. See `declaredIndex`.
828
- let declaredIndex = -1;
829
- for (const trak of childrenOf(moov, moovBox.dataOffset, moov.length, "trak")) {
830
- const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
831
- if (!mdia) {
832
- continue;
833
- }
834
- const hdlr = childOf(moov, mdia.dataOffset, mdia.end, "hdlr");
835
- const handler = hdlr ? moov.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) : "";
836
- if (!SUBTITLE_HANDLERS.has(handler)) {
837
- continue;
838
- }
839
- // Counted before the readability checks below, and before the handler is
840
- // narrowed to the text ones: this number is the track's place in the file,
841
- // not its place among the tracks this code can turn into cues.
842
- declaredIndex += 1;
843
- if (!TEXT_HANDLERS.has(handler)) {
844
- continue;
845
- }
846
- const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
847
- if (!mdhd) {
848
- continue;
849
- }
850
- const version = moov[mdhd.dataOffset];
851
- const timescale = moov.readUInt32BE(version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12);
852
- if (!timescale) {
853
- continue;
854
- }
855
- // The language is five bits per letter, offset from 0x60, packed into two
856
- // bytes after the times (ISO/IEC 14496-12 §8.4.2.3).
857
- const languageAt = version === 1 ? mdhd.dataOffset + 32 : mdhd.dataOffset + 20;
858
- let language = "";
859
- if (languageAt + 2 <= mdhd.end) {
860
- const packed = moov.readUInt16BE(languageAt);
861
- language = [10, 5, 0]
862
- .map((shift) => String.fromCharCode(((packed >> shift) & 0x1f) + 0x60))
863
- .join("")
864
- .replace(/[^a-z]/g, "");
865
- }
866
-
867
- const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
868
- const trackId = tkhd
869
- ? moov.readUInt32BE(moov[tkhd.dataOffset] === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12)
870
- : tracks.length + 1;
871
-
872
- const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
873
- const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
874
- if (!stbl) {
875
- continue;
876
- }
877
- const stsd = childOf(moov, stbl.dataOffset, stbl.end, "stsd");
878
- const first = stsd && readBox(moov, stsd.dataOffset + 8);
879
- const format = first ? first.type : "";
880
- if (!TEXT_FORMATS.has(format)) {
881
- continue;
882
- }
883
- // Whether the file itself says this track is forced — subtitles shown even
884
- // to a viewer who did not ask for subtitles, because the dialogue on screen
885
- // is in a language the soundtrack is not.
886
- //
887
- // Apple's QuickTime File Format, "Display flags" under Subtitle sample
888
- // description, defines the two bits read here: `0x40000000` "Some samples
889
- // are forced" ("at least one sample contains a forced (`frcd`) atom") and
890
- // `0x80000000` "All samples are forced" ("the subtitle media handler treats
891
- // all samples as forced subtitles, regardless of the presence or absence of
892
- // a `frcd` atom"), with the note that setting the second requires the first
893
- // — the pair together being `0xC0000000`. We honour that requirement rather
894
- // than trusting a writer to have met it: either bit alone is enough to call
895
- // the track forced, because a file that sets only `0x80000000` is saying
896
- // exactly what a well-formed one would say twice.
897
- //
898
- // The field sits at a fixed place in the sample entry. `dataOffset` is
899
- // already past the box header, and every sample entry opens with 6 reserved
900
- // bytes and a 2-byte data reference index (ISO/IEC 14496-12 §8.5.2.2), so
901
- // `displayFlags` is the 32 bits eight bytes in.
902
- let someSamplesForced = false;
903
- let allSamplesForced = false;
904
- if (format === "tx3g" && first && first.dataOffset + 8 + 4 <= first.end) {
905
- const displayFlags = moov.readUInt32BE(first.dataOffset + 8);
906
- someSamplesForced = (displayFlags & 0x40000000) !== 0;
907
- allSamplesForced = (displayFlags & 0x80000000) !== 0;
908
- }
909
- const stts = childOf(moov, stbl.dataOffset, stbl.end, "stts");
910
- const stsz = childOf(moov, stbl.dataOffset, stbl.end, "stsz");
911
- const stsc = childOf(moov, stbl.dataOffset, stbl.end, "stsc");
912
- const stco = childOf(moov, stbl.dataOffset, stbl.end, "stco");
913
- const co64 = childOf(moov, stbl.dataOffset, stbl.end, "co64");
914
- if (!stts || !stsz || !stsc || (!stco && !co64)) {
915
- continue;
916
- }
917
-
918
- const sizes = sampleSizes(moov, stsz);
919
- const durations = sampleDurations(moov, stts, sizes.length);
920
- const chunkOffsets = [];
921
- if (stco) {
922
- const count = moov.readUInt32BE(stco.dataOffset + 4);
923
- let at = stco.dataOffset + 8;
924
- for (let index = 0; index < count && at + 4 <= stco.end; index += 1, at += 4) {
925
- chunkOffsets.push(moov.readUInt32BE(at));
926
- }
927
- } else {
928
- const count = moov.readUInt32BE(co64.dataOffset + 4);
929
- let at = co64.dataOffset + 8;
930
- for (let index = 0; index < count && at + 8 <= co64.end; index += 1, at += 8) {
931
- chunkOffsets.push(Number(moov.readBigUInt64BE(at)));
932
- }
933
- }
934
- const offsets = sampleOffsets(moov, stsc, chunkOffsets, sizes);
935
-
936
- /** @type {Mp4SubtitleSample[]} */
937
- const samples = [];
938
- let ticks = 0;
939
- for (let index = 0; index < sizes.length; index += 1) {
940
- const start = ticks / timescale;
941
- ticks += durations[index];
942
- // An empty sample is a gap between cues, which the format uses to say
943
- // "nothing on screen"; it is not a cue and would show as a blank line.
944
- if (sizes[index] > 2) {
945
- samples.push({
946
- startSeconds: start,
947
- endSeconds: ticks / timescale,
948
- offset: offsets[index],
949
- size: sizes[index]
950
- });
951
- }
952
- }
953
- tracks.push({
954
- trackId,
955
- declaredIndex,
956
- format,
957
- language,
958
- someSamplesForced,
959
- allSamplesForced,
960
- samples
961
- });
962
- }
963
- return { tracks };
964
- }
965
-
966
- /**
967
- * The text of one sample.
968
- *
969
- * `tx3g` is a two-byte length followed by UTF-8; anything after that is styling
970
- * boxes, which this deliberately drops. `wvtt` is a sequence of boxes, and the
971
- * text lives in the `payl` inside a `vttc`.
972
- *
973
- * @param {Buffer} bytes
974
- * @param {string} format
975
- * @returns {string}
976
- */
977
- function decodeSubtitleSample(bytes, format) {
978
- if (format === "wvtt") {
979
- let at = 0;
980
- const parts = [];
981
- while (at + HEADER_BYTES <= bytes.length) {
982
- const box = readBox(bytes, at);
983
- if (!box) {
984
- break;
985
- }
986
- if (box.type === "vttc") {
987
- const payl = childOf(bytes, box.dataOffset, Math.min(bytes.length, box.end), "payl");
988
- if (payl) {
989
- parts.push(bytes.toString("utf8", payl.dataOffset, Math.min(bytes.length, payl.end)));
990
- }
991
- }
992
- at = box.end;
993
- }
994
- return parts.join("\n").trim();
995
- }
996
- if (bytes.length < 2) {
997
- return "";
998
- }
999
- const length = bytes.readUInt16BE(0);
1000
- return bytes.toString("utf8", 2, Math.min(bytes.length, 2 + length)).trim();
1001
- }
1002
-
1003
- // ---------------------------------------------------------------------------
1004
- // ISO/IEC 14496-12 speaking about MP4: stss, stts, ctts and the edit list.
1005
- // Here because the class is the only way in.
1006
- // ---------------------------------------------------------------------------
1007
- /**
1008
- * @file Keyframe index for MP4/MOV, read without downloading the file.
1009
- *
1010
- * MP4 keeps its tables in a `moov` box: `stss` lists which samples are sync
1011
- * samples (keyframes) by number, and `stts` gives each sample's duration, so
1012
- * the two together turn "sample #N" into "second T". `moov` sits either at the
1013
- * start (files written for streaming) or at the end (the common case for a
1014
- * plain mux); box headers state their own size, so it is found by stepping over
1015
- * top-level boxes rather than scanning bytes — a couple of 64-byte reads even
1016
- * when `mdat` is gigabytes.
1017
- *
1018
- * Same purpose as the Matroska reader: on the video-COPY path the segment
1019
- * boundaries ARE the source's keyframes, and inventing an even grid instead
1020
- * makes players walk the whole file or present audio with no picture.
1021
- */
1022
-
1023
- // A 64-bit box size is signalled by a 32-bit size of 1, the real size following
1024
- // in the next 8 bytes.
1025
- // Enough to read any box header while walking the top level.
1026
- // Cap on the moov read. A feature-length file indexes to a few hundred KB;
1027
- // beyond this is not a normal index and not worth pulling over a torrent.
1028
-
1029
- /**
1030
- * Whether this looks like MP4/MOV — every real file opens with an `ftyp` box.
1031
- *
1032
- * @param {Buffer} head
1033
- * @returns {boolean}
1034
- */
1035
- function isMp4(head) {
1036
- return head.length >= 12 && head.toString("latin1", 4, 8) === "ftyp";
1037
- }
1038
-
1039
- /**
1040
- * Read a box header at `offset`.
1041
- *
1042
- * @param {Buffer} buffer
1043
- * @param {number} offset
1044
- * @returns {{ type: string, size: number, headerBytes: number } | null}
1045
- */
1046
- function readBoxHeader(buffer, offset) {
1047
- if (offset + HEADER_BYTES > buffer.length) {
1048
- return null;
1049
- }
1050
- const size32 = buffer.readUInt32BE(offset);
1051
- const type = buffer.toString("latin1", offset + 4, offset + 8);
1052
- if (size32 === LARGE_SIZE_MARKER) {
1053
- if (offset + LARGE_HEADER_BYTES > buffer.length) {
1054
- return null;
1055
- }
1056
- // High word is zero for any file we can practically handle.
1057
- const high = buffer.readUInt32BE(offset + 8);
1058
- const low = buffer.readUInt32BE(offset + 12);
1059
- return { type, size: high * 4294967296 + low, headerBytes: LARGE_HEADER_BYTES };
1060
- }
1061
- return { type, size: size32, headerBytes: HEADER_BYTES };
1062
- }
1063
-
1064
- /**
1065
- * Walk the top-level boxes to find `moov`, reading only each box header.
1066
- *
1067
- * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
1068
- * @param {number} fileSize
1069
- * @returns {Promise<{ offset: number, size: number, headerBytes: number } | null>}
1070
- */
1071
- async function findMoov(readRange, fileSize) {
1072
- let offset = 0;
1073
- while (offset < fileSize) {
1074
- const probe = await readRange(offset, Math.min(fileSize - 1, offset + PROBE_BYTES - 1));
1075
- if (!probe || probe.length < HEADER_BYTES) {
1076
- return null;
1077
- }
1078
- const header = readBoxHeader(probe, 0);
1079
- // Size 0 means "extends to end of file" — legal only for the last box, and
1080
- // never for one we would step over.
1081
- if (!header || header.size <= 0) {
1082
- return null;
1083
- }
1084
- if (header.type === "moov") {
1085
- return { offset, size: header.size, headerBytes: header.headerBytes };
1086
- }
1087
- offset += header.size;
1088
- }
1089
- return null;
1090
- }
1091
-
1092
- /**
1093
- * Find the first box of `type` directly inside a range of an already-read buffer.
1094
- *
1095
- * @param {Buffer} buffer
1096
- * @param {number} start
1097
- * @param {number} end
1098
- * @param {string} type
1099
- * @returns {{ dataOffset: number, end: number } | null}
1100
- */
1101
- function findBox(buffer, start, end, type) {
1102
- let offset = start;
1103
- while (offset + HEADER_BYTES <= end) {
1104
- const header = readBoxHeader(buffer, offset);
1105
- if (!header || header.size <= 0) {
1106
- return null;
1107
- }
1108
- if (header.type === type) {
1109
- return { dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) };
1110
- }
1111
- offset += header.size;
1112
- }
1113
- return null;
1114
- }
1115
-
1116
- /**
1117
- * All boxes of `type` directly inside a range.
1118
- *
1119
- * @param {Buffer} buffer
1120
- * @param {number} start
1121
- * @param {number} end
1122
- * @param {string} type
1123
- * @returns {{ dataOffset: number, end: number }[]}
1124
- */
1125
- function findAllBoxes(buffer, start, end, type) {
1126
- const found = [];
1127
- let offset = start;
1128
- while (offset + HEADER_BYTES <= end) {
1129
- const header = readBoxHeader(buffer, offset);
1130
- if (!header || header.size <= 0) {
1131
- break;
1132
- }
1133
- if (header.type === type) {
1134
- found.push({ dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) });
1135
- }
1136
- offset += header.size;
1137
- }
1138
- return found;
1139
- }
1140
-
1141
- /**
1142
- * Turn sample numbers into seconds using the time-to-sample table.
1143
- *
1144
- * `stts` is run-length encoded — pairs of (sample count, per-sample duration) —
1145
- * so one walk yields every sample's start time without expanding the table.
1146
- *
1147
- * @param {Buffer} buffer
1148
- * @param {{ dataOffset: number, end: number }} stts
1149
- * @param {number} timescale - Ticks per second.
1150
- * @param {Set<number>} wanted - Sample numbers (1-based).
1151
- * @returns {number[]} Seconds, ascending.
1152
- */
1153
- function resolveSampleTimes(buffer, stts, timescale, wanted, offsets = null) {
1154
- const entryCount = buffer.readUInt32BE(stts.dataOffset + 4);
1155
- const times = [];
1156
- let sampleNumber = 1;
1157
- let ticks = 0;
1158
- let cursor = stts.dataOffset + 8;
1159
- for (let entry = 0; entry < entryCount && cursor + 8 <= stts.end; entry += 1) {
1160
- const count = buffer.readUInt32BE(cursor);
1161
- const delta = buffer.readUInt32BE(cursor + 4);
1162
- for (let index = 0; index < count; index += 1) {
1163
- if (wanted.has(sampleNumber)) {
1164
- // `CT(n) = DT(n) + CTTS(n)` — ISO/IEC 14496-12 §8.6.1.3. The offset is
1165
- // what turns decode order into the order frames are shown in, and it is
1166
- // the timeline ffmpeg cuts on.
1167
- times.push((ticks + (offsets?.get(sampleNumber) ?? 0)) / timescale);
1168
- }
1169
- ticks += delta;
1170
- sampleNumber += 1;
1171
- }
1172
- cursor += 8;
1173
- }
1174
- return times;
1175
- }
1176
-
1177
- /**
1178
- * Composition offsets for the sample numbers asked for.
1179
- *
1180
- * `ctts` is run-length encoded like `stts`, and version 1 carries SIGNED
1181
- * offsets — which is what the version exists for: a frame may be shown before
1182
- * it is decoded. Reading them as unsigned turns a small negative offset into
1183
- * roughly four billion ticks.
1184
- *
1185
- * @param {Buffer} buffer
1186
- * @param {{ dataOffset: number, end: number }} ctts
1187
- * @param {Set<number>} wanted - Sample numbers (1-based).
1188
- * @returns {Map<number, number>} Sample number to offset in media ticks.
1189
- */
1190
- function readCompositionOffsets(buffer, ctts, wanted) {
1191
- const version = buffer[ctts.dataOffset];
1192
- const entryCount = buffer.readUInt32BE(ctts.dataOffset + 4);
1193
- const offsets = new Map();
1194
- let sampleNumber = 1;
1195
- let cursor = ctts.dataOffset + 8;
1196
- for (let entry = 0; entry < entryCount && cursor + 8 <= ctts.end; entry += 1) {
1197
- const count = buffer.readUInt32BE(cursor);
1198
- const offset = version === 1 ? buffer.readInt32BE(cursor + 4) : buffer.readUInt32BE(cursor + 4);
1199
- for (let index = 0; index < count; index += 1) {
1200
- if (wanted.has(sampleNumber)) {
1201
- offsets.set(sampleNumber, offset);
1202
- }
1203
- sampleNumber += 1;
1204
- }
1205
- cursor += 8;
1206
- }
1207
- return offsets;
1208
- }
1209
-
1210
- /**
1211
- * How far the edit list shifts this track's composition timeline, in media
1212
- * ticks.
1213
- *
1214
- * ISO/IEC 14496-12 §8.6.6.3: `media_time` is the start of the edit within the
1215
- * media, in the MEDIA timescale and in composition time, while
1216
- * `segment_duration` is in the MOVIE timescale — two different units in one
1217
- * structure, which is why only the first is read here. `media_time = -1` is an
1218
- * empty edit: it inserts blank presentation time and starts no media, so the
1219
- * first real edit is the one that matters.
1220
- *
1221
- * Measured 2026-08-19: every LostFilm MP4 that carries a composition offset
1222
- * also carries an edit list cancelling it exactly, which is why decode times
1223
- * have been right on those files. `Firefly.S01E03` has the offset and NO edit
1224
- * list, and its times were 62.1 ms early on all 34 keyframes checked.
1225
- *
1226
- * @param {Buffer} buffer
1227
- * @param {{ dataOffset: number, end: number }} elst
1228
- * @returns {number} Ticks to subtract; zero when nothing is shifted.
1229
- */
1230
- function readEditShift(buffer, elst) {
1231
- const version = buffer[elst.dataOffset];
1232
- const entryCount = buffer.readUInt32BE(elst.dataOffset + 4);
1233
- const wide = version === 1;
1234
- const entryBytes = wide ? 20 : 12;
1235
- let cursor = elst.dataOffset + 8;
1236
- for (let entry = 0; entry < entryCount && cursor + entryBytes <= elst.end; entry += 1) {
1237
- const mediaTime = wide
1238
- ? Number(buffer.readBigInt64BE(cursor + 8))
1239
- : buffer.readInt32BE(cursor + 4);
1240
- if (mediaTime >= 0) {
1241
- return mediaTime;
1242
- }
1243
- cursor += entryBytes;
1244
- }
1245
- return 0;
1246
- }
1247
-
1248
- /**
1249
- * Whether this track's handler says it carries video.
1250
- *
1251
- * The standard identifies a track by its `hdlr`, and nothing else does. Picking
1252
- * "the first track that happens to carry sync samples" worked only because the
1253
- * seven releases measured all put video first; a file whose audio track carries
1254
- * them, or one that leads with a cover-art video track, would be read from the
1255
- * wrong place. That is the same defect that was fixed in the Matroska reader on
1256
- * 2026-08-18, arrived at from the other side.
1257
- *
1258
- * @param {Buffer} buffer
1259
- * @param {{ dataOffset: number, end: number }} mdia
1260
- * @returns {boolean}
1261
- */
1262
- function isVideoTrack(buffer, mdia) {
1263
- const hdlr = findBox(buffer, mdia.dataOffset, mdia.end, "hdlr");
1264
- if (!hdlr || hdlr.dataOffset + 12 > hdlr.end) {
1265
- return false;
1266
- }
1267
- // FullBox header (4) then a reserved pre_defined (4), then the handler type.
1268
- return buffer.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) === "vide";
1269
- }
1270
-
1271
- /**
1272
- * Read the keyframe times of an MP4/MOV file from its `moov` box.
1273
- *
1274
- * @param {Buffer} moov - The whole `moov` box, its header included.
1275
- * @param {number} headerBytes - The box header's length.
1276
- * @returns {number[] | null} Ascending seconds, or null when the file
1277
- * carries no usable index (fragmented MP4, truncated or damaged `moov`).
1278
- */
1279
- function keyframeTimesFromMoov(moov, headerBytes) {
1280
- const moovBox = { headerBytes };
1281
- if (!moov || moov.length < moovBox.headerBytes) {
1282
- return null;
1283
- }
1284
-
1285
- // Examine every track, and take the one whose HANDLER says it is video. A
1286
- // track with no `stss` has every sample a keyframe, so it constrains nothing
1287
- // and is skipped even when it is the video one.
1288
- for (const trak of findAllBoxes(moov, moovBox.headerBytes, moov.length, "trak")) {
1289
- const mdia = findBox(moov, trak.dataOffset, trak.end, "mdia");
1290
- if (!mdia || !isVideoTrack(moov, mdia)) {
1291
- continue;
1292
- }
1293
- const mdhd = findBox(moov, mdia.dataOffset, mdia.end, "mdhd");
1294
- if (!mdhd) {
1295
- continue;
1296
- }
1297
- // mdhd layout: version(1) + flags(3), then creation/modification times —
1298
- // 32-bit each in version 0, 64-bit in version 1 — then the timescale.
1299
- const version = moov[mdhd.dataOffset];
1300
- const timescaleOffset = version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12;
1301
- if (timescaleOffset + 4 > mdhd.end) {
1302
- continue;
1303
- }
1304
- const timescale = moov.readUInt32BE(timescaleOffset);
1305
- if (!timescale) {
1306
- continue;
1307
- }
1308
-
1309
- const minf = findBox(moov, mdia.dataOffset, mdia.end, "minf");
1310
- const stbl = minf && findBox(moov, minf.dataOffset, minf.end, "stbl");
1311
- if (!stbl) {
1312
- continue;
1313
- }
1314
- const stss = findBox(moov, stbl.dataOffset, stbl.end, "stss");
1315
- const stts = findBox(moov, stbl.dataOffset, stbl.end, "stts");
1316
- if (!stss || !stts) {
1317
- continue;
1318
- }
1319
-
1320
- const syncCount = moov.readUInt32BE(stss.dataOffset + 4);
1321
- const wanted = new Set();
1322
- for (let index = 0; index < syncCount; index += 1) {
1323
- const at = stss.dataOffset + 8 + index * 4;
1324
- if (at + 4 > stss.end) {
1325
- break;
1326
- }
1327
- wanted.add(moov.readUInt32BE(at));
1328
- }
1329
- if (wanted.size === 0) {
1330
- continue;
1331
- }
1332
-
1333
- // The two terms that turn decode times into the timeline ffmpeg cuts on.
1334
- // Both are optional: a file without them is one whose decode and
1335
- // composition orders already agree, and then nothing is added or taken.
1336
- const ctts = findBox(moov, stbl.dataOffset, stbl.end, "ctts");
1337
- const offsets = ctts ? readCompositionOffsets(moov, ctts, wanted) : null;
1338
- const edts = findBox(moov, trak.dataOffset, trak.end, "edts");
1339
- const elst = edts && findBox(moov, edts.dataOffset, edts.end, "elst");
1340
- const editShift = elst ? readEditShift(moov, elst) : 0;
1341
-
1342
- const times = resolveSampleTimes(moov, stts, timescale, wanted, offsets);
1343
- if (times.length > 0) {
1344
- // A shift applied after the division would be in the wrong units: the
1345
- // edit's `media_time` is in MEDIA ticks, like everything else here.
1346
- const shifted = editShift === 0 ? times : times.map((time) => time - editShift / timescale);
1347
- // A negative time is not a position in the file. It happens when an edit
1348
- // starts later than a keyframe the table lists, and those frames are not
1349
- // presented at all.
1350
- return shifted.filter((time) => time >= 0);
1351
- }
1352
- }
1353
- return null;
1354
- }
1
+ /**
2
+ * @file MP4/MOV container — ISO/IEC 14496-12.
3
+ *
4
+ * Parses moov for all track types in one walk:
5
+ * - tkhd: track_ID, flags track_enabled (0x000001), alternate_group, width/height
6
+ * - mdhd: timescale, language (packed 5-bit), version handling
7
+ * - hdlr: handler_type (vide/soun/text/sbtl/subt/subp/clcp)
8
+ * - elng: extendedLanguage BCP47 (when present, replaces mdhd language per spec)
9
+ * - stsd: sample entry format (avc1/hev1/mp4a/tx3g/wvtt/stpp)
10
+ * - stbl tables for subtitle cue ranges (stts/stsz/stsc/stco/co64) and video keyframes (stss/stts/ctts/elst)
11
+ *
12
+ * Keyframe reading is delegated to mp4.js. The subtitle sample table is read in
13
+ * this module, because every rule in it is a statement of ISO/IEC 14496-12 about
14
+ * this container. Track creation is centralised here so that track_enabled,
15
+ * `elng` and alternate_group are handled once for every media type.
16
+ */
17
+
18
+ import { Container } from "./Container.js";
19
+ import { VideoTrack } from "../tracks/VideoTrack.js";
20
+ import { AudioTrack } from "../tracks/AudioTrack.js";
21
+ import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
22
+ import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
23
+ import { isUnavailable } from "./unavailable.js";
24
+
25
+ /** A box header is eight bytes, or sixteen when the size field says 1 (§4.2). */
26
+ const HEADER_BYTES = 8;
27
+ const LARGE_SIZE_MARKER = 1;
28
+ const LARGE_HEADER_BYTES = 16;
29
+
30
+ /**
31
+ * One box header, per ISO/IEC 14496-12 §4.2.
32
+ *
33
+ * @param {Buffer} buf
34
+ * @param {number} off
35
+ * @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
36
+ */
37
+ function readBox(buffer, offset) {
38
+ if (offset + HEADER_BYTES > buffer.length) {
39
+ return null;
40
+ }
41
+ let size = buffer.readUInt32BE(offset);
42
+ const type = buffer.toString("latin1", offset + 4, offset + 8);
43
+ let headerBytes = HEADER_BYTES;
44
+ // A size of 1 means the real size is the 64-bit value after the type.
45
+ if (size === LARGE_SIZE_MARKER) {
46
+ if (offset + LARGE_HEADER_BYTES > buffer.length) {
47
+ return null;
48
+ }
49
+ size = Number(buffer.readBigUInt64BE(offset + HEADER_BYTES));
50
+ headerBytes = LARGE_HEADER_BYTES;
51
+ }
52
+ if (size < headerBytes) {
53
+ return null;
54
+ }
55
+ return { type, size, dataOffset: offset + headerBytes, end: offset + size };
56
+ }
57
+
58
+ /**
59
+ * Every direct child box of the given type.
60
+ *
61
+ * @param {Buffer} buf
62
+ * @param {number} start
63
+ * @param {number} end
64
+ * @param {string} type
65
+ * @returns {Array<{ type: string, size: number, dataOffset: number, end: number }>}
66
+ */
67
+ function childrenOf(buf, start, end, type) {
68
+ const out = [];
69
+ let p = start;
70
+ while (p + 8 <= end) {
71
+ const b = readBox(buf, p);
72
+ if (!b) break;
73
+ if (b.type === type) out.push(b);
74
+ p = b.end;
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * The first direct child box of the given type, or null.
81
+ *
82
+ * @param {Buffer} buf
83
+ * @param {number} s
84
+ * @param {number} e
85
+ * @param {string} t
86
+ * @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
87
+ */
88
+ function childOf(buf, s, e, t) {
89
+ return childrenOf(buf, s, e, t)[0] ?? null;
90
+ }
91
+
92
+ /**
93
+ * The channel count, sampling frequency and decoder configuration one audio
94
+ * sample entry states (ISO/IEC 14496-12 §12.2.3, AudioSampleEntry).
95
+ *
96
+ * After the 8 bytes every SampleEntry begins with come 8 reserved bytes —
97
+ * whose first two QuickTime uses as a version that lengthens the entry by 16
98
+ * (version 1) or 36 (version 2) bytes — then `channelcount`, `samplesize`, two
99
+ * reserved fields and `samplerate` as 16.16 fixed point. Child boxes follow.
100
+ * For `mp4a` the child `esds` holds the AudioSpecificConfig (ISO/IEC 14496-1
101
+ * §7.2.6: ES_Descriptor, then DecoderConfigDescriptor, then
102
+ * DecoderSpecificInfo).
103
+ *
104
+ * @param {Buffer} buf
105
+ * @param {{ type: string, dataOffset: number, end: number }} entry
106
+ * @returns {{ channels: number | null, sampleRate: number | null, decoderConfig: Buffer | null } | null}
107
+ */
108
+ function readAudioSampleEntry(buf, entry) {
109
+ const base = entry.dataOffset;
110
+ if (base + 28 > entry.end || base + 28 > buf.length) {
111
+ return null;
112
+ }
113
+ const version = buf.readUInt16BE(base + 8);
114
+ const channels = buf.readUInt16BE(base + 16);
115
+ const sampleRate = buf.readUInt32BE(base + 24) / 65536;
116
+ const childrenStart = base + 28 + (version === 1 ? 16 : version === 2 ? 36 : 0);
117
+ let decoderConfig = null;
118
+ if (entry.type === "mp4a" && childrenStart < entry.end) {
119
+ const esds = childOf(buf, childrenStart, Math.min(entry.end, buf.length), "esds");
120
+ if (esds) {
121
+ decoderConfig = decoderSpecificInfoOf(buf, esds.dataOffset + 4, Math.min(esds.end, buf.length));
122
+ }
123
+ }
124
+ return {
125
+ channels: channels > 0 ? channels : null,
126
+ sampleRate: sampleRate > 0 ? sampleRate : null,
127
+ decoderConfig
128
+ };
129
+ }
130
+
131
+ /**
132
+ * The DecoderSpecificInfo inside an `esds` payload, or null.
133
+ *
134
+ * Descriptors are a tag byte and a size of one to four bytes, seven bits each,
135
+ * the high bit saying another follows (ISO/IEC 14496-1 §8.3.3).
136
+ *
137
+ * @param {Buffer} buf
138
+ * @param {number} start
139
+ * @param {number} end
140
+ * @returns {Buffer | null}
141
+ */
142
+ function decoderSpecificInfoOf(buf, start, end) {
143
+ const descriptorAt = (offset) => {
144
+ if (offset >= end) return null;
145
+ const tag = buf[offset];
146
+ let size = 0;
147
+ let cursor = offset + 1;
148
+ for (let i = 0; i < 4 && cursor < end; i += 1) {
149
+ const byte = buf[cursor];
150
+ cursor += 1;
151
+ size = (size * 128) + (byte & 0x7f);
152
+ if ((byte & 0x80) === 0) break;
153
+ }
154
+ return { tag, dataOffset: cursor, end: Math.min(end, cursor + size) };
155
+ };
156
+ const es = descriptorAt(start);
157
+ if (!es || es.tag !== 0x03 || es.dataOffset + 3 > es.end) return null;
158
+ const flags = buf[es.dataOffset + 2];
159
+ let cursor = es.dataOffset + 3;
160
+ if (flags & 0x80) cursor += 2;
161
+ if (flags & 0x40) cursor += 1 + (buf[cursor] ?? 0);
162
+ if (flags & 0x20) cursor += 2;
163
+ while (cursor < es.end) {
164
+ const descriptor = descriptorAt(cursor);
165
+ if (!descriptor) return null;
166
+ if (descriptor.tag === 0x04) {
167
+ // objectTypeIndication, streamType, bufferSizeDB, maxBitrate, avgBitrate.
168
+ let inner = descriptor.dataOffset + 13;
169
+ while (inner < descriptor.end) {
170
+ const child = descriptorAt(inner);
171
+ if (!child) return null;
172
+ if (child.tag === 0x05) {
173
+ return child.end > child.dataOffset ? Buffer.from(buf.subarray(child.dataOffset, child.end)) : null;
174
+ }
175
+ inner = child.end;
176
+ }
177
+ return null;
178
+ }
179
+ cursor = descriptor.end;
180
+ }
181
+ return null;
182
+ }
183
+
184
+ export class Mp4Container extends Container {
185
+ get formatName() {
186
+ return "mp4";
187
+ }
188
+
189
+ static detect(head) {
190
+ return isMp4(head);
191
+ }
192
+
193
+ /**
194
+ * The keyframe times this container's own index states, in ascending seconds.
195
+ *
196
+ * Static so a caller that has bytes and no container can ask; the instance
197
+ * form is {@link Container#readKeyframeIndex}.
198
+ *
199
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
200
+ * @param {number} fileSize
201
+ * @returns {Promise<number[]|null>} Null where the container has no index.
202
+ */
203
+ static async readKeyframeTimes(readRange, fileSize) {
204
+ const index = await new Mp4Container({ readRange, fileSize }).parseKeyframeIndex();
205
+ return index ? index.times : null;
206
+ }
207
+
208
+ /**
209
+ * This container's text subtitle tracks, with every cue's time and byte
210
+ * range — ISO/IEC 14496-12 §8.5 and §8.7.
211
+ *
212
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
213
+ * @param {number} fileSize
214
+ * @returns {Promise<{ tracks: object[] } | null>}
215
+ */
216
+ static async readSubtitlePlan(readRange, fileSize) {
217
+ const held = await new Mp4Container({ readRange, fileSize }).#moovBuffer();
218
+ return held ? subtitlePlanFromMoov(held.moov) : null;
219
+ }
220
+
221
+ /**
222
+ * The same reading, over the file this container was built on.
223
+ *
224
+ * The static form exists for a caller that has bytes and no container; this
225
+ * is the one to use otherwise, because the reader is already here.
226
+ *
227
+ * @returns {Promise<object|null>}
228
+ */
229
+ async readSubtitlePlan() {
230
+ const held = await this.#moovBuffer();
231
+ const plan = held ? subtitlePlanFromMoov(held.moov) : null;
232
+ if (!plan) {
233
+ return null;
234
+ }
235
+ // An MP4 states every sample's byte range in its own table, so a cue costs
236
+ // its own few dozen bytes rather than the cluster around it — which is why
237
+ // there are `samples` here and no `clusterPositions`.
238
+ //
239
+ // An MP4 has no element meaning "show this subtitle track by default", so
240
+ // `declared` is empty: the container states nothing, and nothing is shown
241
+ // unasked.
242
+ return {
243
+ tracks: plan.tracks.map((track, order) => ({
244
+ trackNumber: track.trackId,
245
+ declaredIndex: Number.isInteger(track.declaredIndex) ? track.declaredIndex : order,
246
+ codecId: track.format,
247
+ language: track.language,
248
+ languageSource: "language",
249
+ name: "",
250
+ isDefault: order === 0,
251
+ codecPrivate: "",
252
+ clusterPositions: [],
253
+ samples: track.samples
254
+ })),
255
+ declared: [],
256
+ secondsPerTick: 0.001,
257
+ segmentDataOffset: 0
258
+ };
259
+ }
260
+
261
+
262
+ /**
263
+ * Read the samples of one subtitle track that are HELD now.
264
+ *
265
+ * An MP4 states every sample's byte range in its own table, so a cue costs its
266
+ * own few dozen bytes rather than the cluster around it — which is why this
267
+ * reads per sample where Matroska reads per cluster.
268
+ *
269
+ * Nothing is fetched: a sample whose bytes are not downloaded is left for the
270
+ * next call, and so is one whose read did not complete — a sample is marked
271
+ * read only once its bytes are in hand.
272
+ *
273
+ * @param {object} _plan
274
+ * @param {{ trackNumber: number, codecId: string, samples: {offset: number, size: number, startSeconds: number, endSeconds: number}[] }} track
275
+ * @param {object} progress
276
+ * @param {import("./Container.js").HeldReader} held
277
+ * @returns {Promise<{ found: Map<number, object[]>, covered: number, indexed: number, withdrawn: number[] }>}
278
+ */
279
+ async readHeldCues(_plan, track, progress, held) {
280
+ if (!progress.mp4) {
281
+ progress.mp4 = { harvested: new Map() };
282
+ }
283
+ let harvested = progress.mp4.harvested.get(track.trackNumber);
284
+ if (!harvested) {
285
+ harvested = new Set();
286
+ progress.mp4.harvested.set(track.trackNumber, harvested);
287
+ }
288
+ const cues = await this.readHeldSamples(track, harvested, held);
289
+ return {
290
+ found: cues.length > 0 ? new Map([[track.trackNumber, cues]]) : new Map(),
291
+ covered: harvested.size,
292
+ indexed: track?.samples?.length ?? 0,
293
+ withdrawn: []
294
+ };
295
+ }
296
+
297
+ /**
298
+ * @param {object} track
299
+ * @param {Set<number>} harvested
300
+ * @param {import("./Container.js").HeldReader} held
301
+ * @returns {Promise<{startSeconds: number, endSeconds: number, text: string, source: number}[]>}
302
+ */
303
+ async readHeldSamples(track, harvested, held) {
304
+ const found = [];
305
+ for (const sample of track?.samples ?? []) {
306
+ if (harvested.has(sample.offset)) {
307
+ continue;
308
+ }
309
+ const last = Math.min(this.fileSize - 1, sample.offset + sample.size - 1);
310
+ if (!held.isHeld(sample.offset, last)) {
311
+ continue;
312
+ }
313
+ let bytes;
314
+ try {
315
+ bytes = await held.read(sample.offset, last);
316
+ } catch (error) {
317
+ if (isUnavailable(error)) {
318
+ continue;
319
+ }
320
+ throw error;
321
+ }
322
+ harvested.add(sample.offset);
323
+ // The MP4 has framed this cue and is the one that unframes it.
324
+ const text = Mp4Container.cueTextOf(bytes, track.codecId);
325
+ if (text) {
326
+ found.push({ startSeconds: sample.startSeconds, endSeconds: sample.endSeconds, text, source: sample.offset });
327
+ }
328
+ }
329
+ return found;
330
+ }
331
+
332
+ async readTracks() {
333
+ const head = await this.readRange(0, Math.min(64 - 1, this.fileSize - 1));
334
+ if (!isMp4(head)) return [];
335
+
336
+ // Subtitle tracks from the subtitle reading of the one `moov`, video and
337
+ // audio from a walk of the same buffer — the box is read once per file.
338
+ // A read that has not arrived is not swallowed here: it is not an answer
339
+ // about the file, and the caller keeps nothing for it.
340
+ const held = await this.#moovBuffer();
341
+ const subtitlePlan = held ? subtitlePlanFromMoov(held.moov) : null;
342
+ const subtitleByDecl = new Map();
343
+ if (subtitlePlan?.tracks) {
344
+ for (const t of subtitlePlan.tracks) subtitleByDecl.set(t.declaredIndex, t);
345
+ }
346
+
347
+ // Minimal moov walk for video/audio: reuse isMp4 + findMoov logic by reading via existing helper
348
+ // Instead of duplicating, parse tracks via a second full moov read that collects vide/soun.
349
+ // We read moov box directly to extract video/audio tracks.
350
+ const tracks = await this.#readVideoAudioTracks();
351
+ // Append subtitle tracks from plan, converting to domain objects
352
+ if (subtitlePlan?.tracks) {
353
+ for (const s of subtitlePlan.tracks) {
354
+ const isText = TEXT_FORMATS_MP4.has(s.format);
355
+ const Cls = isText ? TextSubtitleTrack : ImageSubtitleTrack;
356
+ tracks.push(new Cls({
357
+ trackNumber: s.trackId,
358
+ declaredIndex: s.declaredIndex,
359
+ codecId: s.format,
360
+ language: s.language,
361
+ languageBcp47: "",
362
+ languageSource: "language",
363
+ name: "",
364
+ isEnabled: true,
365
+ isDefault: s.declaredIndex === 0,
366
+ declaresDefault: false,
367
+ codecPrivateB64: "",
368
+ // The sample entry's own words, read below. Either
369
+ // bit is enough: a file that sets only "all samples are forced" is
370
+ // saying what a well-formed one says twice.
371
+ isForced: s.someSamplesForced === true || s.allSamplesForced === true,
372
+ isHearingImpaired: false,
373
+ clusterPositions: [],
374
+ samples: s.samples
375
+ }));
376
+ }
377
+ // Count non-text subtitle handlers (subp/clcp/stpp) for declaredIndex correctness — they are already
378
+ // accounted for in subtitlePlan's declaredIndex via SUBTITLE_HANDLERS, but we didn't create objects for
379
+ // them above when they were stpp (non-text not in plan's tracks). The plan already excludes stpp from tracks
380
+ // but increments declaredIndex, so alignment holds: we don't need extra placeholders.
381
+ }
382
+ return tracks;
383
+ }
384
+
385
+ /**
386
+ * The `moov` box, read whole — the ONE reading of it every question here is
387
+ * answered from.
388
+ *
389
+ * Held on the instance because every question this class answers is inside
390
+ * it, and the box can be tens of megabytes off a torrent. Until 2026-10-01 it
391
+ * was read by three functions besides this one, each with its own rule for a
392
+ * read that failed.
393
+ *
394
+ * Three outcomes are kept: the box; null where the file has no `moov`; and
395
+ * null with `moovRefused` set where the box is larger than this program reads
396
+ * whole — a limit of ours, said as one, not an absence. A read whose bytes
397
+ * have not arrived throws `BytesUnavailable` and keeps nothing.
398
+ *
399
+ * @returns {Promise<{ moov: Buffer, header: number } | null>}
400
+ */
401
+ async #moovBuffer() {
402
+ if (this.moovHeld !== undefined) {
403
+ return this.moovHeld;
404
+ }
405
+ const found = await findMoov(this.readRange, this.fileSize);
406
+ if (!found) {
407
+ this.moovHeld = null;
408
+ return null;
409
+ }
410
+ if (found.size > MAX_MOOV_BYTES) {
411
+ this.moovRefused = { size: found.size, limit: MAX_MOOV_BYTES };
412
+ this.moovHeld = null;
413
+ return null;
414
+ }
415
+ const moov = await this.readRange(found.offset, found.offset + found.size - 1);
416
+ this.moovHeld = { moov, header: found.headerBytes };
417
+ return this.moovHeld;
418
+ }
419
+
420
+ /**
421
+ * Duration from `mvhd` and the presentation offset from the first track's
422
+ * edit list, per ISO/IEC 14496-12 §8.2.2 and §8.6.6.
423
+ *
424
+ * An edit entry whose `media_time` is -1 is an EMPTY edit: it presents
425
+ * nothing for `segment_duration`, which shifts everything after it later by
426
+ * that much. That shift is what a player reports as the file's start, and it
427
+ * is the only way an MP4 states one — a file without such an edit begins at
428
+ * zero, which is a declaration, not an absence.
429
+ *
430
+ * @returns {Promise<import("./Container.js").ContainerMediaInfo>}
431
+ */
432
+ async readMediaInfo() {
433
+ if (this.mediaInfo) {
434
+ return this.mediaInfo;
435
+ }
436
+ /** @type {import("./Container.js").ContainerMediaInfo} */
437
+ const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: null };
438
+ const held = await this.#moovBuffer();
439
+ // Kept only once the `moov` reading has an outcome: a read that had not
440
+ // arrived used to leave an empty answer here for the life of the container.
441
+ this.mediaInfo = info;
442
+ if (!held) {
443
+ return info;
444
+ }
445
+ const { moov, header } = held;
446
+ const mvhd = childOf(moov, header, moov.length, "mvhd");
447
+ let movieTimescale = 0;
448
+ if (mvhd) {
449
+ const version = moov[mvhd.dataOffset];
450
+ // version 0: creation(4) modification(4) timescale(4) duration(4)
451
+ // version 1: creation(8) modification(8) timescale(4) duration(8)
452
+ const at = version === 1 ? mvhd.dataOffset + 20 : mvhd.dataOffset + 12;
453
+ if (at + 8 <= moov.length) {
454
+ movieTimescale = moov.readUInt32BE(at);
455
+ const duration = version === 1 ? Number(moov.readBigUInt64BE(at + 4)) : moov.readUInt32BE(at + 4);
456
+ if (movieTimescale > 0 && duration > 0) {
457
+ info.durationSeconds = duration / movieTimescale;
458
+ }
459
+ }
460
+ }
461
+ info.startTimeSeconds = movieTimescale > 0
462
+ ? Mp4Container.#emptyEditSeconds(moov, header, movieTimescale)
463
+ : null;
464
+ return info;
465
+ }
466
+
467
+ /**
468
+ * The presentation shift of the first empty edit, in seconds; 0 when no track
469
+ * declares one.
470
+ *
471
+ * @param {Buffer} moov
472
+ * @param {number} header
473
+ * @param {number} movieTimescale
474
+ * @returns {number}
475
+ */
476
+ static #emptyEditSeconds(moov, header, movieTimescale) {
477
+ let shift = 0;
478
+ for (const trak of childrenOf(moov, header, moov.length, "trak")) {
479
+ const edts = childOf(moov, trak.dataOffset, trak.end, "edts");
480
+ const elst = edts && childOf(moov, edts.dataOffset, edts.end, "elst");
481
+ if (!elst) {
482
+ continue;
483
+ }
484
+ const version = moov[elst.dataOffset];
485
+ const count = moov.readUInt32BE(elst.dataOffset + 4);
486
+ if (count < 1) {
487
+ continue;
488
+ }
489
+ const entry = elst.dataOffset + 8;
490
+ const segmentDuration = version === 1
491
+ ? Number(moov.readBigUInt64BE(entry))
492
+ : moov.readUInt32BE(entry);
493
+ const mediaTime = version === 1
494
+ ? Number(moov.readBigInt64BE(entry + 8))
495
+ : moov.readInt32BE(entry + 4);
496
+ if (mediaTime === -1 && segmentDuration > 0) {
497
+ shift = Math.max(shift, segmentDuration / movieTimescale);
498
+ }
499
+ }
500
+ return shift;
501
+ }
502
+
503
+ async #readVideoAudioTracks() {
504
+ const held = await this.#moovBuffer();
505
+ if (!held) return [];
506
+ const { moov, header: moovHeader } = held;
507
+
508
+ const result = [];
509
+ let videoIdx = -1;
510
+ let audioIdx = -1;
511
+
512
+ const moovContentStart = moovHeader;
513
+ const moovEnd = moov.length;
514
+ for (const trak of childrenOf(moov, moovContentStart, moovEnd, "trak")) {
515
+ const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
516
+ if (!mdia) continue;
517
+ const hdlr = childOf(moov, mdia.dataOffset, mdia.end, "hdlr");
518
+ const handler = hdlr ? moov.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) : "";
519
+ const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
520
+ const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
521
+ let language = "";
522
+ if (mdhd) {
523
+ const ver = moov[mdhd.dataOffset];
524
+ const langAt = ver === 1 ? mdhd.dataOffset + 32 : mdhd.dataOffset + 20;
525
+ if (langAt + 2 <= mdhd.end) {
526
+ const packed = moov.readUInt16BE(langAt);
527
+ language = [10, 5, 0].map((s) => String.fromCharCode(((packed >> s) & 0x1f) + 0x60)).join("").replace(/[^a-z]/g, "");
528
+ }
529
+ }
530
+ // elng overrides mdhd language per spec §8.4.6
531
+ let languageBcp47 = "";
532
+ const elng = childOf(moov, mdia.dataOffset, mdia.end, "elng");
533
+ if (elng && elng.end - elng.dataOffset >= 4) {
534
+ languageBcp47 = moov.toString("utf8", elng.dataOffset + 4, elng.end).replace(/\0+$/, "");
535
+ }
536
+ let trackId = 0;
537
+ let isEnabled = true;
538
+ let alternateGroup = 0;
539
+ let width = null;
540
+ let height = null;
541
+ if (tkhd) {
542
+ const ver = moov[tkhd.dataOffset];
543
+ const flags = moov.readUInt32BE(tkhd.dataOffset + 1) & 0xffffff; // 3 bytes after version
544
+ isEnabled = (flags & 0x000001) !== 0;
545
+ trackId = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12);
546
+ alternateGroup = moov.readUInt16BE(ver === 1 ? tkhd.dataOffset + 26 : tkhd.dataOffset + 18);
547
+ // width/height are 16.16 fixed point at end of tkhd
548
+ if (tkhd.end - tkhd.dataOffset >= 84) {
549
+ const w = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 76 : tkhd.dataOffset + 68);
550
+ const h = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 80 : tkhd.dataOffset + 72);
551
+ width = w / 65536;
552
+ height = h / 65536;
553
+ }
554
+ }
555
+ const resolvedLang = languageBcp47 || language;
556
+ if (handler === "vide") {
557
+ videoIdx += 1;
558
+ // stsd format for codecId
559
+ let codecId = "";
560
+ const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
561
+ const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
562
+ const stsd = stbl && childOf(moov, stbl.dataOffset, stbl.end, "stsd");
563
+ if (stsd) {
564
+ const first = readBox(moov, stsd.dataOffset + 8);
565
+ if (first) codecId = first.type;
566
+ }
567
+ result.push(new VideoTrack({
568
+ trackNumber: trackId,
569
+ declaredIndex: videoIdx,
570
+ codecId,
571
+ language: resolvedLang,
572
+ languageBcp47,
573
+ name: "",
574
+ isEnabled,
575
+ isDefault: true,
576
+ declaresDefault: false,
577
+ codecPrivateB64: "",
578
+ alternateGroup,
579
+ width,
580
+ height
581
+ }));
582
+ } else if (handler === "soun") {
583
+ audioIdx += 1;
584
+ let codecId = "";
585
+ let sampleEntry = null;
586
+ const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
587
+ const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
588
+ const stsd = stbl && childOf(moov, stbl.dataOffset, stbl.end, "stsd");
589
+ if (stsd) {
590
+ const first = readBox(moov, stsd.dataOffset + 8);
591
+ if (first) {
592
+ codecId = first.type;
593
+ sampleEntry = readAudioSampleEntry(moov, first);
594
+ }
595
+ }
596
+ result.push(new AudioTrack({
597
+ trackNumber: trackId,
598
+ declaredIndex: audioIdx,
599
+ codecId,
600
+ language: resolvedLang,
601
+ languageBcp47,
602
+ name: "",
603
+ isEnabled,
604
+ isDefault: true,
605
+ declaresDefault: false,
606
+ // The AudioSpecificConfig an `mp4a` entry carries in its `esds`, which
607
+ // is what Matroska writes as CodecPrivate for the same codec.
608
+ codecPrivateB64: sampleEntry?.decoderConfig ? sampleEntry.decoderConfig.toString("base64") : "",
609
+ alternateGroup,
610
+ isOriginal: false,
611
+ isCommentary: false,
612
+ isVisualImpaired: false,
613
+ channels: sampleEntry?.channels ?? null,
614
+ samplingFrequency: sampleEntry?.sampleRate ?? null
615
+ }));
616
+ }
617
+ }
618
+ return result;
619
+ }
620
+
621
+ /**
622
+ * The text field of one cue as MP4 frames it.
623
+ *
624
+ * A `tx3g`/`text` sample is a 16-bit big-endian length followed by that many
625
+ * bytes of UTF-8 (ISO/IEC 14496-12 §12.6.3 and Apple's text sample format); a
626
+ * `wvtt` sample is a sequence of boxes whose `vttc`/`payl` holds the cue text
627
+ * (§12.6.3.2). Neither carries the subtitle format's own markup, so the
628
+ * markup step that follows has nothing to take off — it is applied all the
629
+ * same, because which step applies is decided by the codec and not here.
630
+ *
631
+ * The byte reading itself is this module's,
632
+ * alongside the sample-table walk that found the range.
633
+ *
634
+ * @param {Buffer} payload - The sample's own bytes.
635
+ * @param {string} codecId - Sample entry type: `tx3g`, `text` or `wvtt`.
636
+ * @returns {string}
637
+ */
638
+ static cueTextOf(payload, codecId) {
639
+ return decodeSubtitleSample(payload, codecId);
640
+ }
641
+
642
+ async parseKeyframeIndex() {
643
+ const held = await this.#moovBuffer();
644
+ const r = held ? keyframeTimesFromMoov(held.moov, held.header) : null;
645
+ if (!r) return null;
646
+ if (Array.isArray(r)) return { times: r, tolerance: 0 };
647
+ return r;
648
+ }
649
+ }
650
+
651
+ // ---------------------------------------------------------------------------
652
+ // The MP4's own reading of its subtitle sample table. Here because every rule
653
+ // in it is ISO/IEC 14496-12 speaking about this container, and the class is
654
+ // the only way in.
655
+ // ---------------------------------------------------------------------------
656
+ /**
657
+ * @file The text subtitle tracks of an MP4, and where each cue's bytes are.
658
+ *
659
+ * The same rule as the Matroska side: nothing is extracted with ffmpeg and
660
+ * nothing is fetched for its own sake. Here it is cheaper still. Matroska hides
661
+ * its subtitle blocks inside clusters shared with the picture, so a cue costs
662
+ * whatever cluster holds it; an MP4 states every sample's offset and length in
663
+ * the sample table, so a cue costs its own bytes and nothing more — usually a
664
+ * few dozen of them.
665
+ *
666
+ * The tables, from ISO/IEC 14496-12:
667
+ *
668
+ * stsd — what the samples are (`tx3g` timed text, `wvtt` WebVTT, `stpp` TTML)
669
+ * stts — how long each sample lasts, run-length encoded (§8.6.1.2)
670
+ * stsz — how long each sample is, in bytes (§8.7.3)
671
+ * stsc — how samples are grouped into chunks (§8.7.4)
672
+ * stco / co64 — where each chunk begins in the file (§8.7.5)
673
+ *
674
+ * Together they give, for sample N: when it starts, how long it stays, and the
675
+ * exact byte range holding it. That is everything needed to show a cue without
676
+ * reading anything else.
677
+ */
678
+
679
+ const PROBE_BYTES = 64;
680
+ const MAX_MOOV_BYTES = 32 * 1024 * 1024;
681
+
682
+ /** Handlers that mean "this track is text on screen". */
683
+ const TEXT_HANDLERS = new Set(["text", "sbtl", "subt"]);
684
+ /**
685
+ * Every handler ffmpeg's mov demuxer turns into a SUBTITLE stream, whether or
686
+ * not this file can read it — `subp` is a DVD subpicture and `clcp` closed
687
+ * captions, both pictures or caption data rather than text. They are counted
688
+ * because `declaredIndex` has to equal ffmpeg's `0:s:N`, and a track left out
689
+ * of the count shifts every text track after it, which is the very defect
690
+ * `declaredIndex` exists to remove.
691
+ */
692
+ const SUBTITLE_HANDLERS = new Set([...TEXT_HANDLERS, "subp", "clcp"]);
693
+ /** Sample formats this can turn into cues. `stpp` (TTML) is XML and is not one. */
694
+ const TEXT_FORMATS = new Set(["tx3g", "text", "wvtt"]);
695
+
696
+
697
+
698
+
699
+
700
+ /**
701
+ * Sample durations, expanded from the run-length table.
702
+ *
703
+ * @param {Buffer} moov
704
+ * @param {{ dataOffset: number, end: number }} stts
705
+ * @param {number} total - How many samples the size table declares.
706
+ * @returns {number[]} Ticks each sample lasts.
707
+ */
708
+ function sampleDurations(moov, stts, total) {
709
+ const durations = new Array(total).fill(0);
710
+ const entries = moov.readUInt32BE(stts.dataOffset + 4);
711
+ let at = stts.dataOffset + 8;
712
+ let sample = 0;
713
+ for (let entry = 0; entry < entries && at + 8 <= stts.end && sample < total; entry += 1, at += 8) {
714
+ const count = moov.readUInt32BE(at);
715
+ const delta = moov.readUInt32BE(at + 4);
716
+ for (let index = 0; index < count && sample < total; index += 1, sample += 1) {
717
+ durations[sample] = delta;
718
+ }
719
+ }
720
+ return durations;
721
+ }
722
+
723
+ /**
724
+ * Sample sizes, whether the table states one for all or one for each.
725
+ *
726
+ * @param {Buffer} moov
727
+ * @param {{ dataOffset: number, end: number }} stsz
728
+ * @returns {number[]}
729
+ */
730
+ function sampleSizes(moov, stsz) {
731
+ const uniform = moov.readUInt32BE(stsz.dataOffset + 4);
732
+ const count = moov.readUInt32BE(stsz.dataOffset + 8);
733
+ if (uniform > 0) {
734
+ return new Array(count).fill(uniform);
735
+ }
736
+ const sizes = new Array(count).fill(0);
737
+ let at = stsz.dataOffset + 12;
738
+ for (let index = 0; index < count && at + 4 <= stsz.end; index += 1, at += 4) {
739
+ sizes[index] = moov.readUInt32BE(at);
740
+ }
741
+ return sizes;
742
+ }
743
+
744
+ /**
745
+ * Where every sample of a track begins in the file.
746
+ *
747
+ * The sample-to-chunk table says how many samples each run of chunks holds, and
748
+ * the chunk-offset table says where each chunk starts; a sample's own offset is
749
+ * its chunk's start plus the sizes of the samples before it in that chunk.
750
+ *
751
+ * @param {Buffer} moov
752
+ * @param {{ dataOffset: number, end: number }} stsc
753
+ * @param {number[]} chunkOffsets
754
+ * @param {number[]} sizes
755
+ * @returns {number[]}
756
+ */
757
+ function sampleOffsets(moov, stsc, chunkOffsets, sizes) {
758
+ const offsets = new Array(sizes.length).fill(0);
759
+ const entries = moov.readUInt32BE(stsc.dataOffset + 4);
760
+ /** @type {{ firstChunk: number, perChunk: number }[]} */
761
+ const runs = [];
762
+ let at = stsc.dataOffset + 8;
763
+ for (let entry = 0; entry < entries && at + 12 <= stsc.end; entry += 1, at += 12) {
764
+ runs.push({ firstChunk: moov.readUInt32BE(at), perChunk: moov.readUInt32BE(at + 4) });
765
+ }
766
+ let sample = 0;
767
+ for (let run = 0; run < runs.length && sample < sizes.length; run += 1) {
768
+ const from = runs[run].firstChunk;
769
+ const to = run + 1 < runs.length ? runs[run + 1].firstChunk - 1 : chunkOffsets.length;
770
+ for (let chunk = from; chunk <= to && sample < sizes.length; chunk += 1) {
771
+ let inChunk = chunkOffsets[chunk - 1];
772
+ if (inChunk === undefined) {
773
+ break;
774
+ }
775
+ for (let index = 0; index < runs[run].perChunk && sample < sizes.length; index += 1, sample += 1) {
776
+ offsets[sample] = inChunk;
777
+ inChunk += sizes[sample];
778
+ }
779
+ }
780
+ }
781
+ return offsets;
782
+ }
783
+
784
+ /**
785
+ * @typedef {object} Mp4SubtitleSample
786
+ * @property {number} startSeconds
787
+ * @property {number} endSeconds
788
+ * @property {number} offset - Where the sample's bytes are in the file.
789
+ * @property {number} size
790
+ */
791
+
792
+ /**
793
+ * @typedef {object} Mp4SubtitleTrack
794
+ * @property {number} trackId
795
+ * @property {number} declaredIndex - Its position among ALL of the file's
796
+ * subtitle tracks, including the ones whose sample format this cannot turn
797
+ * into cues (`stpp` TTML). That is the number ffmpeg gives the same stream in
798
+ * `0:s:N`, which is the number the browser names; counting only the readable
799
+ * ones would shift every track after a TTML one.
800
+ * @property {string} format - `tx3g`, `text` or `wvtt`.
801
+ * @property {string} language - Three letters, as the file declares them.
802
+ * @property {boolean} someSamplesForced - The sample entry says at least one
803
+ * cue carries a forced (`frcd`) atom.
804
+ * @property {boolean} allSamplesForced - The sample entry says every cue is to
805
+ * be treated as forced, whether or not it carries that atom.
806
+ * @property {Mp4SubtitleSample[]} samples - In time order.
807
+ */
808
+
809
+ /**
810
+ * The text subtitle tracks of an MP4, with every cue's time and byte range.
811
+ *
812
+ * @param {Buffer} moov - The whole `moov` box, its header included.
813
+ * @returns {{ tracks: Mp4SubtitleTrack[] } | null}
814
+ */
815
+ function subtitlePlanFromMoov(moov) {
816
+ if (!moov || moov.length < HEADER_BYTES) {
817
+ return null;
818
+ }
819
+ const moovBox = readBox(moov, 0);
820
+ if (!moovBox) {
821
+ return null;
822
+ }
823
+
824
+ /** @type {Mp4SubtitleTrack[]} */
825
+ const tracks = [];
826
+ // Counts every subtitle track the file has, whether or not this can read it,
827
+ // so the number handed out matches ffmpeg's `0:s:N`. See `declaredIndex`.
828
+ let declaredIndex = -1;
829
+ for (const trak of childrenOf(moov, moovBox.dataOffset, moov.length, "trak")) {
830
+ const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
831
+ if (!mdia) {
832
+ continue;
833
+ }
834
+ const hdlr = childOf(moov, mdia.dataOffset, mdia.end, "hdlr");
835
+ const handler = hdlr ? moov.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) : "";
836
+ if (!SUBTITLE_HANDLERS.has(handler)) {
837
+ continue;
838
+ }
839
+ // Counted before the readability checks below, and before the handler is
840
+ // narrowed to the text ones: this number is the track's place in the file,
841
+ // not its place among the tracks this code can turn into cues.
842
+ declaredIndex += 1;
843
+ if (!TEXT_HANDLERS.has(handler)) {
844
+ continue;
845
+ }
846
+ const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
847
+ if (!mdhd) {
848
+ continue;
849
+ }
850
+ const version = moov[mdhd.dataOffset];
851
+ const timescale = moov.readUInt32BE(version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12);
852
+ if (!timescale) {
853
+ continue;
854
+ }
855
+ // The language is five bits per letter, offset from 0x60, packed into two
856
+ // bytes after the times (ISO/IEC 14496-12 §8.4.2.3).
857
+ const languageAt = version === 1 ? mdhd.dataOffset + 32 : mdhd.dataOffset + 20;
858
+ let language = "";
859
+ if (languageAt + 2 <= mdhd.end) {
860
+ const packed = moov.readUInt16BE(languageAt);
861
+ language = [10, 5, 0]
862
+ .map((shift) => String.fromCharCode(((packed >> shift) & 0x1f) + 0x60))
863
+ .join("")
864
+ .replace(/[^a-z]/g, "");
865
+ }
866
+
867
+ const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
868
+ const trackId = tkhd
869
+ ? moov.readUInt32BE(moov[tkhd.dataOffset] === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12)
870
+ : tracks.length + 1;
871
+
872
+ const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
873
+ const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
874
+ if (!stbl) {
875
+ continue;
876
+ }
877
+ const stsd = childOf(moov, stbl.dataOffset, stbl.end, "stsd");
878
+ const first = stsd && readBox(moov, stsd.dataOffset + 8);
879
+ const format = first ? first.type : "";
880
+ if (!TEXT_FORMATS.has(format)) {
881
+ continue;
882
+ }
883
+ // Whether the file itself says this track is forced — subtitles shown even
884
+ // to a viewer who did not ask for subtitles, because the dialogue on screen
885
+ // is in a language the soundtrack is not.
886
+ //
887
+ // Apple's QuickTime File Format, "Display flags" under Subtitle sample
888
+ // description, defines the two bits read here: `0x40000000` "Some samples
889
+ // are forced" ("at least one sample contains a forced (`frcd`) atom") and
890
+ // `0x80000000` "All samples are forced" ("the subtitle media handler treats
891
+ // all samples as forced subtitles, regardless of the presence or absence of
892
+ // a `frcd` atom"), with the note that setting the second requires the first
893
+ // — the pair together being `0xC0000000`. We honour that requirement rather
894
+ // than trusting a writer to have met it: either bit alone is enough to call
895
+ // the track forced, because a file that sets only `0x80000000` is saying
896
+ // exactly what a well-formed one would say twice.
897
+ //
898
+ // The field sits at a fixed place in the sample entry. `dataOffset` is
899
+ // already past the box header, and every sample entry opens with 6 reserved
900
+ // bytes and a 2-byte data reference index (ISO/IEC 14496-12 §8.5.2.2), so
901
+ // `displayFlags` is the 32 bits eight bytes in.
902
+ let someSamplesForced = false;
903
+ let allSamplesForced = false;
904
+ if (format === "tx3g" && first && first.dataOffset + 8 + 4 <= first.end) {
905
+ const displayFlags = moov.readUInt32BE(first.dataOffset + 8);
906
+ someSamplesForced = (displayFlags & 0x40000000) !== 0;
907
+ allSamplesForced = (displayFlags & 0x80000000) !== 0;
908
+ }
909
+ const stts = childOf(moov, stbl.dataOffset, stbl.end, "stts");
910
+ const stsz = childOf(moov, stbl.dataOffset, stbl.end, "stsz");
911
+ const stsc = childOf(moov, stbl.dataOffset, stbl.end, "stsc");
912
+ const stco = childOf(moov, stbl.dataOffset, stbl.end, "stco");
913
+ const co64 = childOf(moov, stbl.dataOffset, stbl.end, "co64");
914
+ if (!stts || !stsz || !stsc || (!stco && !co64)) {
915
+ continue;
916
+ }
917
+
918
+ const sizes = sampleSizes(moov, stsz);
919
+ const durations = sampleDurations(moov, stts, sizes.length);
920
+ const chunkOffsets = [];
921
+ if (stco) {
922
+ const count = moov.readUInt32BE(stco.dataOffset + 4);
923
+ let at = stco.dataOffset + 8;
924
+ for (let index = 0; index < count && at + 4 <= stco.end; index += 1, at += 4) {
925
+ chunkOffsets.push(moov.readUInt32BE(at));
926
+ }
927
+ } else {
928
+ const count = moov.readUInt32BE(co64.dataOffset + 4);
929
+ let at = co64.dataOffset + 8;
930
+ for (let index = 0; index < count && at + 8 <= co64.end; index += 1, at += 8) {
931
+ chunkOffsets.push(Number(moov.readBigUInt64BE(at)));
932
+ }
933
+ }
934
+ const offsets = sampleOffsets(moov, stsc, chunkOffsets, sizes);
935
+
936
+ /** @type {Mp4SubtitleSample[]} */
937
+ const samples = [];
938
+ let ticks = 0;
939
+ for (let index = 0; index < sizes.length; index += 1) {
940
+ const start = ticks / timescale;
941
+ ticks += durations[index];
942
+ // An empty sample is a gap between cues, which the format uses to say
943
+ // "nothing on screen"; it is not a cue and would show as a blank line.
944
+ if (sizes[index] > 2) {
945
+ samples.push({
946
+ startSeconds: start,
947
+ endSeconds: ticks / timescale,
948
+ offset: offsets[index],
949
+ size: sizes[index]
950
+ });
951
+ }
952
+ }
953
+ tracks.push({
954
+ trackId,
955
+ declaredIndex,
956
+ format,
957
+ language,
958
+ someSamplesForced,
959
+ allSamplesForced,
960
+ samples
961
+ });
962
+ }
963
+ return { tracks };
964
+ }
965
+
966
+ /**
967
+ * The text of one sample.
968
+ *
969
+ * `tx3g` is a two-byte length followed by UTF-8; anything after that is styling
970
+ * boxes, which this deliberately drops. `wvtt` is a sequence of boxes, and the
971
+ * text lives in the `payl` inside a `vttc`.
972
+ *
973
+ * @param {Buffer} bytes
974
+ * @param {string} format
975
+ * @returns {string}
976
+ */
977
+ function decodeSubtitleSample(bytes, format) {
978
+ if (format === "wvtt") {
979
+ let at = 0;
980
+ const parts = [];
981
+ while (at + HEADER_BYTES <= bytes.length) {
982
+ const box = readBox(bytes, at);
983
+ if (!box) {
984
+ break;
985
+ }
986
+ if (box.type === "vttc") {
987
+ const payl = childOf(bytes, box.dataOffset, Math.min(bytes.length, box.end), "payl");
988
+ if (payl) {
989
+ parts.push(bytes.toString("utf8", payl.dataOffset, Math.min(bytes.length, payl.end)));
990
+ }
991
+ }
992
+ at = box.end;
993
+ }
994
+ return parts.join("\n").trim();
995
+ }
996
+ if (bytes.length < 2) {
997
+ return "";
998
+ }
999
+ const length = bytes.readUInt16BE(0);
1000
+ return bytes.toString("utf8", 2, Math.min(bytes.length, 2 + length)).trim();
1001
+ }
1002
+
1003
+ // ---------------------------------------------------------------------------
1004
+ // ISO/IEC 14496-12 speaking about MP4: stss, stts, ctts and the edit list.
1005
+ // Here because the class is the only way in.
1006
+ // ---------------------------------------------------------------------------
1007
+ /**
1008
+ * @file Keyframe index for MP4/MOV, read without downloading the file.
1009
+ *
1010
+ * MP4 keeps its tables in a `moov` box: `stss` lists which samples are sync
1011
+ * samples (keyframes) by number, and `stts` gives each sample's duration, so
1012
+ * the two together turn "sample #N" into "second T". `moov` sits either at the
1013
+ * start (files written for streaming) or at the end (the common case for a
1014
+ * plain mux); box headers state their own size, so it is found by stepping over
1015
+ * top-level boxes rather than scanning bytes — a couple of 64-byte reads even
1016
+ * when `mdat` is gigabytes.
1017
+ *
1018
+ * Same purpose as the Matroska reader: on the video-COPY path the segment
1019
+ * boundaries ARE the source's keyframes, and inventing an even grid instead
1020
+ * makes players walk the whole file or present audio with no picture.
1021
+ */
1022
+
1023
+ // A 64-bit box size is signalled by a 32-bit size of 1, the real size following
1024
+ // in the next 8 bytes.
1025
+ // Enough to read any box header while walking the top level.
1026
+ // Cap on the moov read. A feature-length file indexes to a few hundred KB;
1027
+ // beyond this is not a normal index and not worth pulling over a torrent.
1028
+
1029
+ /**
1030
+ * Whether this looks like MP4/MOV — every real file opens with an `ftyp` box.
1031
+ *
1032
+ * @param {Buffer} head
1033
+ * @returns {boolean}
1034
+ */
1035
+ function isMp4(head) {
1036
+ return head.length >= 12 && head.toString("latin1", 4, 8) === "ftyp";
1037
+ }
1038
+
1039
+ /**
1040
+ * Read a box header at `offset`.
1041
+ *
1042
+ * @param {Buffer} buffer
1043
+ * @param {number} offset
1044
+ * @returns {{ type: string, size: number, headerBytes: number } | null}
1045
+ */
1046
+ function readBoxHeader(buffer, offset) {
1047
+ if (offset + HEADER_BYTES > buffer.length) {
1048
+ return null;
1049
+ }
1050
+ const size32 = buffer.readUInt32BE(offset);
1051
+ const type = buffer.toString("latin1", offset + 4, offset + 8);
1052
+ if (size32 === LARGE_SIZE_MARKER) {
1053
+ if (offset + LARGE_HEADER_BYTES > buffer.length) {
1054
+ return null;
1055
+ }
1056
+ // High word is zero for any file we can practically handle.
1057
+ const high = buffer.readUInt32BE(offset + 8);
1058
+ const low = buffer.readUInt32BE(offset + 12);
1059
+ return { type, size: high * 4294967296 + low, headerBytes: LARGE_HEADER_BYTES };
1060
+ }
1061
+ return { type, size: size32, headerBytes: HEADER_BYTES };
1062
+ }
1063
+
1064
+ /**
1065
+ * Walk the top-level boxes to find `moov`, reading only each box header.
1066
+ *
1067
+ * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
1068
+ * @param {number} fileSize
1069
+ * @returns {Promise<{ offset: number, size: number, headerBytes: number } | null>}
1070
+ */
1071
+ async function findMoov(readRange, fileSize) {
1072
+ let offset = 0;
1073
+ while (offset < fileSize) {
1074
+ const probe = await readRange(offset, Math.min(fileSize - 1, offset + PROBE_BYTES - 1));
1075
+ if (!probe || probe.length < HEADER_BYTES) {
1076
+ return null;
1077
+ }
1078
+ const header = readBoxHeader(probe, 0);
1079
+ // Size 0 means "extends to end of file" — legal only for the last box, and
1080
+ // never for one we would step over.
1081
+ if (!header || header.size <= 0) {
1082
+ return null;
1083
+ }
1084
+ if (header.type === "moov") {
1085
+ return { offset, size: header.size, headerBytes: header.headerBytes };
1086
+ }
1087
+ offset += header.size;
1088
+ }
1089
+ return null;
1090
+ }
1091
+
1092
+ /**
1093
+ * Find the first box of `type` directly inside a range of an already-read buffer.
1094
+ *
1095
+ * @param {Buffer} buffer
1096
+ * @param {number} start
1097
+ * @param {number} end
1098
+ * @param {string} type
1099
+ * @returns {{ dataOffset: number, end: number } | null}
1100
+ */
1101
+ function findBox(buffer, start, end, type) {
1102
+ let offset = start;
1103
+ while (offset + HEADER_BYTES <= end) {
1104
+ const header = readBoxHeader(buffer, offset);
1105
+ if (!header || header.size <= 0) {
1106
+ return null;
1107
+ }
1108
+ if (header.type === type) {
1109
+ return { dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) };
1110
+ }
1111
+ offset += header.size;
1112
+ }
1113
+ return null;
1114
+ }
1115
+
1116
+ /**
1117
+ * All boxes of `type` directly inside a range.
1118
+ *
1119
+ * @param {Buffer} buffer
1120
+ * @param {number} start
1121
+ * @param {number} end
1122
+ * @param {string} type
1123
+ * @returns {{ dataOffset: number, end: number }[]}
1124
+ */
1125
+ function findAllBoxes(buffer, start, end, type) {
1126
+ const found = [];
1127
+ let offset = start;
1128
+ while (offset + HEADER_BYTES <= end) {
1129
+ const header = readBoxHeader(buffer, offset);
1130
+ if (!header || header.size <= 0) {
1131
+ break;
1132
+ }
1133
+ if (header.type === type) {
1134
+ found.push({ dataOffset: offset + header.headerBytes, end: Math.min(end, offset + header.size) });
1135
+ }
1136
+ offset += header.size;
1137
+ }
1138
+ return found;
1139
+ }
1140
+
1141
+ /**
1142
+ * Turn sample numbers into seconds using the time-to-sample table.
1143
+ *
1144
+ * `stts` is run-length encoded — pairs of (sample count, per-sample duration) —
1145
+ * so one walk yields every sample's start time without expanding the table.
1146
+ *
1147
+ * @param {Buffer} buffer
1148
+ * @param {{ dataOffset: number, end: number }} stts
1149
+ * @param {number} timescale - Ticks per second.
1150
+ * @param {Set<number>} wanted - Sample numbers (1-based).
1151
+ * @returns {number[]} Seconds, ascending.
1152
+ */
1153
+ function resolveSampleTimes(buffer, stts, timescale, wanted, offsets = null) {
1154
+ const entryCount = buffer.readUInt32BE(stts.dataOffset + 4);
1155
+ const times = [];
1156
+ let sampleNumber = 1;
1157
+ let ticks = 0;
1158
+ let cursor = stts.dataOffset + 8;
1159
+ for (let entry = 0; entry < entryCount && cursor + 8 <= stts.end; entry += 1) {
1160
+ const count = buffer.readUInt32BE(cursor);
1161
+ const delta = buffer.readUInt32BE(cursor + 4);
1162
+ for (let index = 0; index < count; index += 1) {
1163
+ if (wanted.has(sampleNumber)) {
1164
+ // `CT(n) = DT(n) + CTTS(n)` — ISO/IEC 14496-12 §8.6.1.3. The offset is
1165
+ // what turns decode order into the order frames are shown in, and it is
1166
+ // the timeline ffmpeg cuts on.
1167
+ times.push((ticks + (offsets?.get(sampleNumber) ?? 0)) / timescale);
1168
+ }
1169
+ ticks += delta;
1170
+ sampleNumber += 1;
1171
+ }
1172
+ cursor += 8;
1173
+ }
1174
+ return times;
1175
+ }
1176
+
1177
+ /**
1178
+ * Composition offsets for the sample numbers asked for.
1179
+ *
1180
+ * `ctts` is run-length encoded like `stts`, and version 1 carries SIGNED
1181
+ * offsets — which is what the version exists for: a frame may be shown before
1182
+ * it is decoded. Reading them as unsigned turns a small negative offset into
1183
+ * roughly four billion ticks.
1184
+ *
1185
+ * @param {Buffer} buffer
1186
+ * @param {{ dataOffset: number, end: number }} ctts
1187
+ * @param {Set<number>} wanted - Sample numbers (1-based).
1188
+ * @returns {Map<number, number>} Sample number to offset in media ticks.
1189
+ */
1190
+ function readCompositionOffsets(buffer, ctts, wanted) {
1191
+ const version = buffer[ctts.dataOffset];
1192
+ const entryCount = buffer.readUInt32BE(ctts.dataOffset + 4);
1193
+ const offsets = new Map();
1194
+ let sampleNumber = 1;
1195
+ let cursor = ctts.dataOffset + 8;
1196
+ for (let entry = 0; entry < entryCount && cursor + 8 <= ctts.end; entry += 1) {
1197
+ const count = buffer.readUInt32BE(cursor);
1198
+ const offset = version === 1 ? buffer.readInt32BE(cursor + 4) : buffer.readUInt32BE(cursor + 4);
1199
+ for (let index = 0; index < count; index += 1) {
1200
+ if (wanted.has(sampleNumber)) {
1201
+ offsets.set(sampleNumber, offset);
1202
+ }
1203
+ sampleNumber += 1;
1204
+ }
1205
+ cursor += 8;
1206
+ }
1207
+ return offsets;
1208
+ }
1209
+
1210
+ /**
1211
+ * How far the edit list shifts this track's composition timeline, in media
1212
+ * ticks.
1213
+ *
1214
+ * ISO/IEC 14496-12 §8.6.6.3: `media_time` is the start of the edit within the
1215
+ * media, in the MEDIA timescale and in composition time, while
1216
+ * `segment_duration` is in the MOVIE timescale — two different units in one
1217
+ * structure, which is why only the first is read here. `media_time = -1` is an
1218
+ * empty edit: it inserts blank presentation time and starts no media, so the
1219
+ * first real edit is the one that matters.
1220
+ *
1221
+ * Measured 2026-08-19: every LostFilm MP4 that carries a composition offset
1222
+ * also carries an edit list cancelling it exactly, which is why decode times
1223
+ * have been right on those files. `Firefly.S01E03` has the offset and NO edit
1224
+ * list, and its times were 62.1 ms early on all 34 keyframes checked.
1225
+ *
1226
+ * @param {Buffer} buffer
1227
+ * @param {{ dataOffset: number, end: number }} elst
1228
+ * @returns {number} Ticks to subtract; zero when nothing is shifted.
1229
+ */
1230
+ function readEditShift(buffer, elst) {
1231
+ const version = buffer[elst.dataOffset];
1232
+ const entryCount = buffer.readUInt32BE(elst.dataOffset + 4);
1233
+ const wide = version === 1;
1234
+ const entryBytes = wide ? 20 : 12;
1235
+ let cursor = elst.dataOffset + 8;
1236
+ for (let entry = 0; entry < entryCount && cursor + entryBytes <= elst.end; entry += 1) {
1237
+ const mediaTime = wide
1238
+ ? Number(buffer.readBigInt64BE(cursor + 8))
1239
+ : buffer.readInt32BE(cursor + 4);
1240
+ if (mediaTime >= 0) {
1241
+ return mediaTime;
1242
+ }
1243
+ cursor += entryBytes;
1244
+ }
1245
+ return 0;
1246
+ }
1247
+
1248
+ /**
1249
+ * Whether this track's handler says it carries video.
1250
+ *
1251
+ * The standard identifies a track by its `hdlr`, and nothing else does. Picking
1252
+ * "the first track that happens to carry sync samples" worked only because the
1253
+ * seven releases measured all put video first; a file whose audio track carries
1254
+ * them, or one that leads with a cover-art video track, would be read from the
1255
+ * wrong place. That is the same defect that was fixed in the Matroska reader on
1256
+ * 2026-08-18, arrived at from the other side.
1257
+ *
1258
+ * @param {Buffer} buffer
1259
+ * @param {{ dataOffset: number, end: number }} mdia
1260
+ * @returns {boolean}
1261
+ */
1262
+ function isVideoTrack(buffer, mdia) {
1263
+ const hdlr = findBox(buffer, mdia.dataOffset, mdia.end, "hdlr");
1264
+ if (!hdlr || hdlr.dataOffset + 12 > hdlr.end) {
1265
+ return false;
1266
+ }
1267
+ // FullBox header (4) then a reserved pre_defined (4), then the handler type.
1268
+ return buffer.toString("latin1", hdlr.dataOffset + 8, hdlr.dataOffset + 12) === "vide";
1269
+ }
1270
+
1271
+ /**
1272
+ * Read the keyframe times of an MP4/MOV file from its `moov` box.
1273
+ *
1274
+ * @param {Buffer} moov - The whole `moov` box, its header included.
1275
+ * @param {number} headerBytes - The box header's length.
1276
+ * @returns {number[] | null} Ascending seconds, or null when the file
1277
+ * carries no usable index (fragmented MP4, truncated or damaged `moov`).
1278
+ */
1279
+ function keyframeTimesFromMoov(moov, headerBytes) {
1280
+ const moovBox = { headerBytes };
1281
+ if (!moov || moov.length < moovBox.headerBytes) {
1282
+ return null;
1283
+ }
1284
+
1285
+ // Examine every track, and take the one whose HANDLER says it is video. A
1286
+ // track with no `stss` has every sample a keyframe, so it constrains nothing
1287
+ // and is skipped even when it is the video one.
1288
+ for (const trak of findAllBoxes(moov, moovBox.headerBytes, moov.length, "trak")) {
1289
+ const mdia = findBox(moov, trak.dataOffset, trak.end, "mdia");
1290
+ if (!mdia || !isVideoTrack(moov, mdia)) {
1291
+ continue;
1292
+ }
1293
+ const mdhd = findBox(moov, mdia.dataOffset, mdia.end, "mdhd");
1294
+ if (!mdhd) {
1295
+ continue;
1296
+ }
1297
+ // mdhd layout: version(1) + flags(3), then creation/modification times —
1298
+ // 32-bit each in version 0, 64-bit in version 1 — then the timescale.
1299
+ const version = moov[mdhd.dataOffset];
1300
+ const timescaleOffset = version === 1 ? mdhd.dataOffset + 20 : mdhd.dataOffset + 12;
1301
+ if (timescaleOffset + 4 > mdhd.end) {
1302
+ continue;
1303
+ }
1304
+ const timescale = moov.readUInt32BE(timescaleOffset);
1305
+ if (!timescale) {
1306
+ continue;
1307
+ }
1308
+
1309
+ const minf = findBox(moov, mdia.dataOffset, mdia.end, "minf");
1310
+ const stbl = minf && findBox(moov, minf.dataOffset, minf.end, "stbl");
1311
+ if (!stbl) {
1312
+ continue;
1313
+ }
1314
+ const stss = findBox(moov, stbl.dataOffset, stbl.end, "stss");
1315
+ const stts = findBox(moov, stbl.dataOffset, stbl.end, "stts");
1316
+ if (!stss || !stts) {
1317
+ continue;
1318
+ }
1319
+
1320
+ const syncCount = moov.readUInt32BE(stss.dataOffset + 4);
1321
+ const wanted = new Set();
1322
+ for (let index = 0; index < syncCount; index += 1) {
1323
+ const at = stss.dataOffset + 8 + index * 4;
1324
+ if (at + 4 > stss.end) {
1325
+ break;
1326
+ }
1327
+ wanted.add(moov.readUInt32BE(at));
1328
+ }
1329
+ if (wanted.size === 0) {
1330
+ continue;
1331
+ }
1332
+
1333
+ // The two terms that turn decode times into the timeline ffmpeg cuts on.
1334
+ // Both are optional: a file without them is one whose decode and
1335
+ // composition orders already agree, and then nothing is added or taken.
1336
+ const ctts = findBox(moov, stbl.dataOffset, stbl.end, "ctts");
1337
+ const offsets = ctts ? readCompositionOffsets(moov, ctts, wanted) : null;
1338
+ const edts = findBox(moov, trak.dataOffset, trak.end, "edts");
1339
+ const elst = edts && findBox(moov, edts.dataOffset, edts.end, "elst");
1340
+ const editShift = elst ? readEditShift(moov, elst) : 0;
1341
+
1342
+ const times = resolveSampleTimes(moov, stts, timescale, wanted, offsets);
1343
+ if (times.length > 0) {
1344
+ // A shift applied after the division would be in the wrong units: the
1345
+ // edit's `media_time` is in MEDIA ticks, like everything else here.
1346
+ const shifted = editShift === 0 ? times : times.map((time) => time - editShift / timescale);
1347
+ // A negative time is not a position in the file. It happens when an edit
1348
+ // starts later than a keyframe the table lists, and those frames are not
1349
+ // presented at all.
1350
+ return shifted.filter((time) => time >= 0);
1351
+ }
1352
+ }
1353
+ return null;
1354
+ }