@torrent-tv/proxy 2.91.0 → 2.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/bin/cli.js +3 -2
  2. package/package.json +18 -2
  3. package/routes/api/playback-plan/post.js +21 -4
  4. package/routes/api/sources/files/get.js +20 -28
  5. package/routes/api/sources/post.js +4 -2
  6. package/routes/api/sources/stats/get.js +4 -11
  7. package/routes/api/sources/viewer/post.js +16 -0
  8. package/routes/api/sources/warm/post.js +6 -215
  9. package/routes/api/subtitles/get.js +22 -125
  10. package/routes/api/subtitles/post.js +26 -0
  11. package/routes/api/transcode-sessions/post.js +24 -2
  12. package/routes/media/get.js +80 -0
  13. package/routes/stream/get.js +26 -94
  14. package/routes/transcode/audio-warm/get.js +9 -21
  15. package/routes/transcode/variant-warm/get.js +8 -14
  16. package/server.js +410 -46
  17. package/services/encode/AdmittedInput.js +136 -0
  18. package/services/encode/CushionReport.js +1 -76
  19. package/services/encode/EncodeInputs.js +142 -0
  20. package/services/encode/EncodeOrchestrator.js +4 -1
  21. package/services/encode/EncodeRun.js +13 -4
  22. package/services/encode/EncodeRuns.js +132 -52
  23. package/services/encode/InputFailures.js +37 -0
  24. package/services/encode/MatroskaInput.js +158 -0
  25. package/services/encode/OutputOpening.js +16 -64
  26. package/services/encode/Renditions.js +1 -17
  27. package/services/encode/admitted-command.js +60 -0
  28. package/services/encode/annex-b-input.js +37 -0
  29. package/services/encode/bit-payload.js +29 -0
  30. package/services/encode/output/OutputCatalog.js +1 -1
  31. package/services/encode/output/Timeline.js +11 -0
  32. package/services/encode/pcm-normalization.js +30 -0
  33. package/services/encode/piece-completeness.js +54 -1
  34. package/services/encode/quality/HostLoad.js +19 -73
  35. package/services/encode/quality/QualityOffer.js +1 -1
  36. package/services/encode/run-command.js +1 -589
  37. package/services/encode/segment-formats/mp4-boxes.js +3 -1
  38. package/services/encode/segment-formats/presentation-segment.js +114 -0
  39. package/services/media/ContainerOrchestrator.js +118 -48
  40. package/services/media/KeyframeTables.js +16 -115
  41. package/services/media/MediaReadRequests.js +120 -0
  42. package/services/media/ProbeRequests.js +135 -0
  43. package/services/media/SegmentInputs.js +48 -0
  44. package/services/media/SourceFile.js +6 -0
  45. package/services/media/SubtitleOrchestrator.js +190 -4
  46. package/services/media/await-plan.js +47 -0
  47. package/services/media/container/AsfContainer.js +217 -0
  48. package/services/media/container/AviContainer.js +343 -34
  49. package/services/media/container/CadencedPictureOrder.js +45 -0
  50. package/services/media/container/Container.js +7 -3
  51. package/services/media/container/ContainerFactory.js +10 -3
  52. package/services/media/container/FfprobeContainer.js +222 -0
  53. package/services/media/container/MatroskaContainer.js +72 -46
  54. package/services/media/container/Mp4Container.js +472 -139
  55. package/services/media/container/MpegPsContainer.js +281 -0
  56. package/services/media/container/MpegTsContainer.js +277 -0
  57. package/services/media/container/PacketIndex.js +270 -0
  58. package/services/media/container/PacketRecords.js +237 -0
  59. package/services/media/container/RetainedBytes.js +39 -0
  60. package/services/media/container/RetainedReads.js +51 -0
  61. package/services/media/container/ac3-frame.js +46 -0
  62. package/services/media/container/asf-packets.js +153 -0
  63. package/services/media/container/avc-picture-order.js +50 -0
  64. package/services/media/container/avi-open-dml.js +47 -0
  65. package/services/media/container/dts-frame.js +51 -0
  66. package/services/media/container/ebml-stream.js +12 -1
  67. package/services/media/container/ffprobe-record.js +45 -0
  68. package/services/media/container/flv-packet-ranges.js +27 -0
  69. package/services/media/container/following-packet-time.js +37 -0
  70. package/services/media/container/h264-configuration.js +183 -0
  71. package/services/media/container/h264-elementary-index.js +245 -0
  72. package/services/media/container/hevc-configuration.js +165 -0
  73. package/services/media/container/hevc-elementary-index.js +132 -0
  74. package/services/media/container/hevc-picture-order.js +66 -0
  75. package/services/media/container/latm-frame.js +99 -0
  76. package/services/media/container/matroska-packets.js +207 -0
  77. package/services/media/container/memory-unavailable.js +8 -0
  78. package/services/media/container/mp3-packet-dependencies.js +41 -0
  79. package/services/media/container/mp4-fragments.js +148 -0
  80. package/services/media/container/mpeg-audio-frame.js +19 -0
  81. package/services/media/container/mpeg-audio-reservoir.js +27 -0
  82. package/services/media/container/mpeg-elementary-index.js +196 -0
  83. package/services/media/container/mpeg-sequence-reader.js +69 -0
  84. package/services/media/container/mpeg-ts-packets.js +93 -0
  85. package/services/media/container/mpeg-video-configuration.js +50 -0
  86. package/services/media/container/mpeg4-picture-timing.js +108 -0
  87. package/services/media/container/pes-header.js +34 -0
  88. package/services/media/playback-declarations.js +57 -0
  89. package/services/media/playback-planner.js +72 -230
  90. package/services/media/probe-packets.js +61 -0
  91. package/services/media/read-playback-declarations.js +18 -0
  92. package/services/media/tracks/AudioTrack.js +9 -0
  93. package/services/media/tracks/TextSubtitleTrack.js +4 -1
  94. package/services/server/OutputLifecycle.js +2 -0
  95. package/services/server/SegmentServing.js +40 -33
  96. package/services/server/ViewerRequests.js +22 -41
  97. package/services/server/controllers/PlaybackController.js +4 -0
  98. package/services/server/controllers/SubtitleController.js +8 -29
  99. package/services/server/transcode-session-files.js +58 -99
  100. package/services/server/wire-outputs.js +58 -15
  101. package/services/storage/IndexMemory.js +85 -0
  102. package/services/storage/MachineBudget.js +27 -3
  103. package/services/storage/allowance.js +8 -1
  104. package/services/storage/piece-store/piece-disk-store.js +18 -3
  105. package/services/storage/piece-store/piece-lru.js +8 -4
  106. package/services/storage/piece-store/shared-piece-store.js +26 -11
  107. package/services/storage/segment-store/SegmentStore.js +49 -18
  108. package/services/storage/wire.js +7 -0
  109. package/services/torrent/Contents.js +52 -20
  110. package/services/torrent/demand/DemandRegister.js +13 -4
  111. package/services/torrent/demand/Window.js +13 -2
  112. package/services/torrent/demand-progress.js +25 -0
  113. package/services/torrent/download/DeadlineScheduler.js +167 -0
  114. package/services/torrent/download/FutureDownload.js +166 -0
  115. package/services/torrent/download/SwarmSelection.js +30 -59
  116. package/services/torrent/download/registry.js +45 -5
  117. package/services/torrent/download/withdraw-claim.js +2 -0
  118. package/services/torrent/torrent-pool.js +39 -600
  119. package/services/torrent/worker/channel.js +8 -1
  120. package/services/torrent/worker/client.js +74 -72
  121. package/services/torrent/worker/files-in-use.js +9 -0
  122. package/services/torrent/worker/handle-state.js +12 -0
  123. package/services/torrent/worker/held-bytes.js +82 -99
  124. package/services/torrent/worker/held-file-bytes.js +17 -0
  125. package/services/torrent/worker/piece-reader.js +67 -683
  126. package/services/torrent/worker/pool-adapter.js +37 -123
  127. package/services/torrent/worker/protocol.js +6 -17
  128. package/services/torrent/worker/whole-file-stats.js +2 -0
  129. package/services/torrent/worker/worker.js +86 -156
  130. package/services/transport/data-channel-handler.js +56 -13
  131. package/services/viewer/DownloadMaps.js +168 -0
  132. package/services/viewer/NativeSourceMap.js +33 -0
  133. package/services/viewer/PriorityMap.js +49 -40
  134. package/services/viewer/PriorityOrchestrator.js +42 -13
  135. package/services/viewer/SourcePreparation.js +278 -0
  136. package/services/viewer/Viewer.js +11 -0
  137. package/services/viewer/Viewers.js +109 -2
  138. package/services/viewer/playback-readiness.js +50 -63
  139. package/services/viewer/segment-demands.js +37 -0
  140. package/services/viewer/urgent-output-ready.js +26 -0
  141. package/services/encode/run-inputs.js +0 -53
  142. package/services/media/keyframe-probe.js +0 -157
  143. package/services/media/media-info-probe.js +0 -93
  144. package/services/torrent/worker/background-fill.js +0 -213
  145. package/services/torrent/worker/resume-warm.js +0 -107
@@ -16,11 +16,17 @@
16
16
  */
17
17
 
18
18
  import { Container } from "./Container.js";
19
+ import { RetainedBytes } from "./RetainedBytes.js";
19
20
  import { VideoTrack } from "../tracks/VideoTrack.js";
20
21
  import { AudioTrack } from "../tracks/AudioTrack.js";
21
22
  import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
22
23
  import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
23
24
  import { isUnavailable } from "./unavailable.js";
25
+ import { PacketIndex } from "./PacketIndex.js";
26
+ import { h264Configuration } from "./h264-configuration.js";
27
+ import { hevcConfiguration } from "./hevc-configuration.js";
28
+ import { mpegAudioFrame } from "./mpeg-audio-frame.js";
29
+ import { readMp4Fragments } from "./mp4-fragments.js";
24
30
 
25
31
  /** A box header is eight bytes, or sixteen when the size field says 1 (§4.2). */
26
32
  const HEADER_BYTES = 8;
@@ -111,20 +117,84 @@ function readAudioSampleEntry(buf, entry) {
111
117
  return null;
112
118
  }
113
119
  const version = buf.readUInt16BE(base + 8);
114
- const channels = buf.readUInt16BE(base + 16);
115
- const sampleRate = buf.readUInt32BE(base + 24) / 65536;
120
+ let channels = buf.readUInt16BE(base + 16);
121
+ let sampleRate = buf.readUInt32BE(base + 24) / 65536;
122
+ let bitDepth = buf.readUInt16BE(base + 18);
123
+ let codecId = entry.type;
124
+ if (entry.type === ".mp3") codecId = "mp3";
125
+ let codecDelaySeconds = 0;
126
+ let seekPrerollSeconds = 0;
116
127
  const childrenStart = base + 28 + (version === 1 ? 16 : version === 2 ? 36 : 0);
128
+ if (version > 2 || childrenStart > entry.end) throw new Error("QuickTime audio sample description is incomplete or unsupported.");
129
+ if (version === 2) {
130
+ sampleRate = buf.readDoubleBE(base + 32);
131
+ channels = buf.readUInt32BE(base + 40);
132
+ bitDepth = buf.readUInt32BE(base + 48);
133
+ if (entry.type === "lpcm") {
134
+ const flags = buf.readUInt32BE(base + 52);
135
+ if ((flags & 32) || !(flags & 8)) throw new Error("QuickTime LPCM needs packed interleaved samples.");
136
+ codecId = `pcm_${flags & 1 ? "f" : flags & 4 ? "s" : "u"}${bitDepth}${bitDepth === 8 ? "" : flags & 2 ? "be" : "le"}`;
137
+ }
138
+ } else {
139
+ codecId = new Map([["sowt", `pcm_s${bitDepth}le`], ["twos", `pcm_s${bitDepth}be`],
140
+ ["in24", "pcm_s24be"], ["in32", "pcm_s32be"], ["fl32", "pcm_f32be"], ["fl64", "pcm_f64be"], ["raw ", "pcm_u8"]]).get(entry.type) ?? entry.type;
141
+ const declaredBits = /^pcm_[suf](\d+)/.exec(codecId);
142
+ if (declaredBits) bitDepth = Number(declaredBits[1]);
143
+ const wave = childOf(buf, childrenStart, entry.end, "wave");
144
+ const endian = childOf(buf, childrenStart, entry.end, "enda") ??
145
+ (wave && childOf(buf, wave.dataOffset, wave.end, "enda"));
146
+ if (endian && /^pcm_[sf]\d+(?:be|le)$/.test(codecId)) {
147
+ if (endian.end - endian.dataOffset !== 2) throw new Error("QuickTime PCM byte-order declaration is invalid.");
148
+ const little = buf.readUInt16BE(endian.dataOffset);
149
+ if (little > 1) throw new Error("QuickTime PCM byte order is unsupported.");
150
+ codecId = codecId.replace(/(?:be|le)$/, little ? "le" : "be");
151
+ }
152
+ }
117
153
  let decoderConfig = null;
118
154
  if (entry.type === "mp4a" && childrenStart < entry.end) {
119
- const esds = childOf(buf, childrenStart, Math.min(entry.end, buf.length), "esds");
155
+ const wave = childOf(buf, childrenStart, Math.min(entry.end, buf.length), "wave");
156
+ const esds = childOf(buf, childrenStart, Math.min(entry.end, buf.length), "esds") ??
157
+ (wave && childOf(buf, wave.dataOffset, wave.end, "esds"));
120
158
  if (esds) {
121
- decoderConfig = decoderSpecificInfoOf(buf, esds.dataOffset + 4, Math.min(esds.end, buf.length));
159
+ const description = decoderSpecificInfoOf(buf, esds.dataOffset + 4, Math.min(esds.end, buf.length));
160
+ decoderConfig = description?.decoderConfig ?? null;
161
+ if ([0x69, 0x6b].includes(description?.objectType)) {
162
+ codecId = "mpeg_audio";
163
+ decoderConfig = null;
164
+ }
122
165
  }
123
166
  }
167
+ if (entry.type === "alac") {
168
+ const config = childOf(buf, childrenStart, entry.end, "alac");
169
+ if (!config || config.end - config.dataOffset < 28) throw new Error("ALAC decoder configuration is absent or truncated.");
170
+ decoderConfig = buf.subarray(config.dataOffset + 4, config.end);
171
+ }
172
+ if (entry.type === "Opus") {
173
+ const config = childOf(buf, childrenStart, entry.end, "dOps");
174
+ if (!config || config.end - config.dataOffset < 11) throw new Error("MP4 Opus decoder declaration is absent or truncated.");
175
+ const data = buf.subarray(config.dataOffset, config.end);
176
+ const family = data[10];
177
+ if (data[0] !== 0 || !data[1] || (!family && data[1] > 2) || (family && data.length < 13 + data[1])) {
178
+ throw new Error("MP4 Opus channel mapping is invalid.");
179
+ }
180
+ const head = Buffer.alloc(19);
181
+ head.write("OpusHead");
182
+ head[8] = 1;
183
+ head[9] = data[1];
184
+ head.writeUInt16LE(data.readUInt16BE(2), 10);
185
+ head.writeUInt32LE(data.readUInt32BE(4), 12);
186
+ head.writeInt16LE(data.readInt16BE(8), 16);
187
+ head[18] = family;
188
+ decoderConfig = family ? Buffer.concat([head, data.subarray(11, 13 + data[1])]) : head;
189
+ codecDelaySeconds = data.readUInt16BE(2) / 48000;
190
+ seekPrerollSeconds = 0.08;
191
+ sampleRate = 48000;
192
+ channels = data[1];
193
+ }
124
194
  return {
125
195
  channels: channels > 0 ? channels : null,
126
196
  sampleRate: sampleRate > 0 ? sampleRate : null,
127
- decoderConfig
197
+ decoderConfig, bitDepth: bitDepth > 0 ? bitDepth : null, codecId, codecDelaySeconds, seekPrerollSeconds
128
198
  };
129
199
  }
130
200
 
@@ -140,41 +210,49 @@ function readAudioSampleEntry(buf, entry) {
140
210
  * @returns {Buffer | null}
141
211
  */
142
212
  function decoderSpecificInfoOf(buf, start, end) {
143
- const descriptorAt = (offset) => {
144
- if (offset >= end) return null;
213
+ const descriptorAt = (offset, limit = end) => {
214
+ if (offset >= limit) return null;
145
215
  const tag = buf[offset];
146
216
  let size = 0;
147
217
  let cursor = offset + 1;
148
- for (let i = 0; i < 4 && cursor < end; i += 1) {
218
+ let complete = false;
219
+ for (let i = 0; i < 4 && cursor < limit; i += 1) {
149
220
  const byte = buf[cursor];
150
221
  cursor += 1;
151
222
  size = (size * 128) + (byte & 0x7f);
152
- if ((byte & 0x80) === 0) break;
223
+ if ((byte & 0x80) === 0) { complete = true; break; }
153
224
  }
154
- return { tag, dataOffset: cursor, end: Math.min(end, cursor + size) };
225
+ if (!complete || cursor + size > limit) throw new Error("MP4 audio descriptor exceeds its declared parent.");
226
+ return { tag, dataOffset: cursor, end: cursor + size };
155
227
  };
156
228
  const es = descriptorAt(start);
157
229
  if (!es || es.tag !== 0x03 || es.dataOffset + 3 > es.end) return null;
158
230
  const flags = buf[es.dataOffset + 2];
159
231
  let cursor = es.dataOffset + 3;
160
232
  if (flags & 0x80) cursor += 2;
161
- if (flags & 0x40) cursor += 1 + (buf[cursor] ?? 0);
233
+ if (flags & 0x40) {
234
+ if (cursor >= es.end) throw new Error("MP4 audio descriptor URL length is absent.");
235
+ cursor += 1 + buf[cursor];
236
+ }
162
237
  if (flags & 0x20) cursor += 2;
238
+ if (cursor > es.end) throw new Error("MP4 audio descriptor flags exceed its declared size.");
163
239
  while (cursor < es.end) {
164
- const descriptor = descriptorAt(cursor);
240
+ const descriptor = descriptorAt(cursor, es.end);
165
241
  if (!descriptor) return null;
166
242
  if (descriptor.tag === 0x04) {
167
243
  // objectTypeIndication, streamType, bufferSizeDB, maxBitrate, avgBitrate.
244
+ if (descriptor.dataOffset + 13 > descriptor.end) throw new Error("MP4 audio decoder descriptor is truncated.");
245
+ const objectType = buf[descriptor.dataOffset];
168
246
  let inner = descriptor.dataOffset + 13;
169
247
  while (inner < descriptor.end) {
170
- const child = descriptorAt(inner);
248
+ const child = descriptorAt(inner, descriptor.end);
171
249
  if (!child) return null;
172
250
  if (child.tag === 0x05) {
173
- return child.end > child.dataOffset ? Buffer.from(buf.subarray(child.dataOffset, child.end)) : null;
251
+ return { objectType, decoderConfig: child.end > child.dataOffset ? Buffer.from(buf.subarray(child.dataOffset, child.end)) : null };
174
252
  }
175
253
  inner = child.end;
176
254
  }
177
- return null;
255
+ return { objectType, decoderConfig: null };
178
256
  }
179
257
  cursor = descriptor.end;
180
258
  }
@@ -182,10 +260,13 @@ function decoderSpecificInfoOf(buf, start, end) {
182
260
  }
183
261
 
184
262
  export class Mp4Container extends Container {
263
+ #tracks = null;
185
264
  get formatName() {
186
265
  return "mp4";
187
266
  }
188
267
 
268
+ packetIndexBytes() { return this.packetIndex?.allocatedBytes() ?? 0; }
269
+
189
270
  static detect(head) {
190
271
  return isMp4(head);
191
272
  }
@@ -330,6 +411,7 @@ export class Mp4Container extends Container {
330
411
  }
331
412
 
332
413
  async readTracks() {
414
+ if (this.#tracks) return this.#tracks;
333
415
  const head = await this.readRange(0, Math.min(64 - 1, this.fileSize - 1));
334
416
  if (!isMp4(head)) return [];
335
417
 
@@ -379,6 +461,18 @@ export class Mp4Container extends Container {
379
461
  // them above when they were stpp (non-text not in plan's tracks). The plan already excludes stpp from tracks
380
462
  // but increments declaredIndex, so alignment holds: we don't need extra placeholders.
381
463
  }
464
+ const pendingAudio = tracks.filter(track => track.codecId === "mpeg_audio");
465
+ if (pendingAudio.length) {
466
+ this.fragmentState ??= {};
467
+ await readMp4Fragments({ readRange: this.readRange, fileSize: this.fileSize,
468
+ tracks: fragmentTracks(held, tracks), memory: this.packetMemory, state: this.fragmentState,
469
+ firstTrackIds: pendingAudio.map(track => track.trackNumber) });
470
+ for (const track of pendingAudio) {
471
+ if (track.codecId !== "mpeg_audio") continue;
472
+ throw new Error("MP4 MPEG audio has no addressed sample declaring its codec.");
473
+ }
474
+ }
475
+ this.#tracks = tracks;
382
476
  return tracks;
383
477
  }
384
478
 
@@ -412,11 +506,34 @@ export class Mp4Container extends Container {
412
506
  this.moovHeld = null;
413
507
  return null;
414
508
  }
415
- const moov = await this.readRange(found.offset, found.offset + found.size - 1);
416
- this.moovHeld = { moov, header: found.headerBytes };
509
+ this.moovMemory ??= new RetainedBytes(this.packetMemory);
510
+ const moov = await this.moovMemory.read(found.size,
511
+ () => this.readRange(found.offset, found.offset + found.size - 1));
512
+ this.moovHeld = { moov, header: found.headerBytes, offset: found.offset };
417
513
  return this.moovHeld;
418
514
  }
419
515
 
516
+ /** Exact decode-order samples from the same moov used for all metadata. */
517
+ async readPacketIndex() {
518
+ if (this.packetIndex) return this.packetIndex;
519
+ const held = await this.#moovBuffer();
520
+ if (!held) throw new Error(this.moovRefused ? "MP4 metadata exceeds the configured read limit." : "MP4 has no sample tables.");
521
+ const tracks = await this.readTracks();
522
+ const fragmented = childOf(held.moov, held.header, held.moov.length, "mvex");
523
+ let index;
524
+ if (fragmented) {
525
+ this.fragmentState ??= {};
526
+ index = await readMp4Fragments({ readRange: this.readRange, fileSize: this.fileSize,
527
+ tracks: fragmentTracks(held, tracks), memory: this.packetMemory, state: this.fragmentState });
528
+ const timeline = tracks.find(track => track.type === "video") ?? tracks.find(track => track.type === "audio");
529
+ const bounds = timeline && index.boundsOf(timeline.trackNumber);
530
+ if (bounds) this.mediaInfo = { ...(await this.readMediaInfo()), startTimeSeconds: bounds.start,
531
+ durationSeconds: bounds.end - bounds.start };
532
+ } else index = packetIndexFromMoov(held, this.fileSize, tracks, this.packetMemory);
533
+ this.packetIndex = index;
534
+ return index;
535
+ }
536
+
420
537
  /**
421
538
  * Duration from `mvhd` and the presentation offset from the first track's
422
539
  * edit list, per ISO/IEC 14496-12 §8.2.2 and §8.6.6.
@@ -540,14 +657,15 @@ export class Mp4Container extends Container {
540
657
  let height = null;
541
658
  if (tkhd) {
542
659
  const ver = moov[tkhd.dataOffset];
543
- const flags = moov.readUInt32BE(tkhd.dataOffset + 1) & 0xffffff; // 3 bytes after version
660
+ const flags = moov.readUIntBE(tkhd.dataOffset + 1, 3);
544
661
  isEnabled = (flags & 0x000001) !== 0;
545
662
  trackId = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 20 : tkhd.dataOffset + 12);
546
- alternateGroup = moov.readUInt16BE(ver === 1 ? tkhd.dataOffset + 26 : tkhd.dataOffset + 18);
663
+ const groupAt = tkhd.dataOffset + (ver === 1 ? 46 : 34);
664
+ if (groupAt + 2 <= tkhd.end) alternateGroup = moov.readUInt16BE(groupAt);
547
665
  // 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);
666
+ if (tkhd.end - tkhd.dataOffset >= (ver === 1 ? 96 : 84)) {
667
+ const w = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 88 : tkhd.dataOffset + 76);
668
+ const h = moov.readUInt32BE(ver === 1 ? tkhd.dataOffset + 92 : tkhd.dataOffset + 80);
551
669
  width = w / 65536;
552
670
  height = h / 65536;
553
671
  }
@@ -557,14 +675,31 @@ export class Mp4Container extends Container {
557
675
  videoIdx += 1;
558
676
  // stsd format for codecId
559
677
  let codecId = "";
678
+ let codecPrivateB64 = "";
679
+ const displayWidth = width;
680
+ const displayHeight = height;
560
681
  const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
561
682
  const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
562
683
  const stsd = stbl && childOf(moov, stbl.dataOffset, stbl.end, "stsd");
563
684
  if (stsd) {
564
685
  const first = readBox(moov, stsd.dataOffset + 8);
565
- if (first) codecId = first.type;
686
+ if (first) {
687
+ codecId = first.type;
688
+ if (first.end - first.dataOffset >= 78) {
689
+ width = moov.readUInt16BE(first.dataOffset + 24);
690
+ height = moov.readUInt16BE(first.dataOffset + 26);
691
+ }
692
+ for (const name of ["avcC", "hvcC", "av1C"]) {
693
+ const configuration = childOf(moov, first.dataOffset + 78, first.end, name);
694
+ if (configuration) codecPrivateB64 = moov.subarray(configuration.dataOffset, configuration.end).toString("base64");
695
+ }
696
+ }
566
697
  }
567
- result.push(new VideoTrack({
698
+ const configuration = codecPrivateB64 && ["avc1", "avc3"].includes(codecId)
699
+ ? h264Configuration(Buffer.from(codecPrivateB64, "base64"))
700
+ : codecPrivateB64 && ["hvc1", "hev1"].includes(codecId)
701
+ ? hevcConfiguration(Buffer.from(codecPrivateB64, "base64")) : null;
702
+ const track = new VideoTrack({
568
703
  trackNumber: trackId,
569
704
  declaredIndex: videoIdx,
570
705
  codecId,
@@ -574,11 +709,20 @@ export class Mp4Container extends Container {
574
709
  isEnabled,
575
710
  isDefault: true,
576
711
  declaresDefault: false,
577
- codecPrivateB64: "",
712
+ codecPrivateB64,
578
713
  alternateGroup,
579
- width,
580
- height
581
- }));
714
+ width: configuration?.width ?? width,
715
+ height: configuration?.height ?? height,
716
+ displayWidth,
717
+ displayHeight,
718
+ fps: configuration?.fps ?? videoSampleRate(moov, mdhd, stbl),
719
+ bitDepth: configuration?.bitDepth ?? null
720
+ });
721
+ if (configuration) {
722
+ track.codecConfiguration = configuration;
723
+ track.reorderDepth = configuration.reorderDepth;
724
+ }
725
+ result.push(track);
582
726
  } else if (handler === "soun") {
583
727
  audioIdx += 1;
584
728
  let codecId = "";
@@ -589,8 +733,22 @@ export class Mp4Container extends Container {
589
733
  if (stsd) {
590
734
  const first = readBox(moov, stsd.dataOffset + 8);
591
735
  if (first) {
592
- codecId = first.type;
593
736
  sampleEntry = readAudioSampleEntry(moov, first);
737
+ codecId = sampleEntry?.codecId ?? first.type;
738
+ if (codecId === "mpeg_audio" && !childOf(moov, held.header, moov.length, "mvex")) {
739
+ const chunks = childOf(moov, stbl.dataOffset, stbl.end, "stco") ?? childOf(moov, stbl.dataOffset, stbl.end, "co64");
740
+ const sizes = childOf(moov, stbl.dataOffset, stbl.end, "stsz");
741
+ if (!chunks || !sizes || !moov.readUInt32BE(chunks.dataOffset + 4) || !moov.readUInt32BE(sizes.dataOffset + 8)) {
742
+ throw new Error("MP4 MPEG audio has no addressed first sample.");
743
+ }
744
+ const offset = chunks.type === "co64" ? Number(moov.readBigUInt64BE(chunks.dataOffset + 8)) : moov.readUInt32BE(chunks.dataOffset + 8);
745
+ const size = moov.readUInt32BE(sizes.dataOffset + 4) || moov.readUInt32BE(sizes.dataOffset + 12);
746
+ if (!Number.isSafeInteger(offset) || size < 4 || offset + size > this.fileSize) throw new Error("MP4 MPEG audio sample address is invalid.");
747
+ const facts = mpegAudioFrame(await this.readRange(offset, offset + 3));
748
+ codecId = facts.codecId;
749
+ sampleEntry.channels = facts.channels;
750
+ sampleEntry.sampleRate = facts.sampleRate;
751
+ }
594
752
  }
595
753
  }
596
754
  result.push(new AudioTrack({
@@ -603,15 +761,17 @@ export class Mp4Container extends Container {
603
761
  isEnabled,
604
762
  isDefault: true,
605
763
  declaresDefault: false,
606
- // The AudioSpecificConfig an `mp4a` entry carries in its `esds`, which
607
- // is what Matroska writes as CodecPrivate for the same codec.
764
+ // The sample entry's decoder configuration in its Matroska declaration form.
608
765
  codecPrivateB64: sampleEntry?.decoderConfig ? sampleEntry.decoderConfig.toString("base64") : "",
609
766
  alternateGroup,
610
767
  isOriginal: false,
611
768
  isCommentary: false,
612
769
  isVisualImpaired: false,
613
770
  channels: sampleEntry?.channels ?? null,
614
- samplingFrequency: sampleEntry?.sampleRate ?? null
771
+ samplingFrequency: sampleEntry?.sampleRate ?? null,
772
+ bitDepth: sampleEntry?.bitDepth ?? null,
773
+ codecDelaySeconds: sampleEntry?.codecDelaySeconds ?? 0,
774
+ seekPrerollSeconds: sampleEntry?.seekPrerollSeconds ?? 0
615
775
  }));
616
776
  }
617
777
  }
@@ -641,6 +801,10 @@ export class Mp4Container extends Container {
641
801
 
642
802
  async parseKeyframeIndex() {
643
803
  const held = await this.#moovBuffer();
804
+ if (held && childOf(held.moov, held.header, held.moov.length, "mvex")) {
805
+ const video = (await this.readTracks()).find(track => track.type === "video");
806
+ return video ? { times: (await this.readPacketIndex()).keyframesOf(video.trackNumber), tolerance: 0 } : null;
807
+ }
644
808
  const r = held ? keyframeTimesFromMoov(held.moov, held.header) : null;
645
809
  if (!r) return null;
646
810
  if (Array.isArray(r)) return { times: r, tolerance: 0 };
@@ -648,6 +812,69 @@ export class Mp4Container extends Container {
648
812
  }
649
813
  }
650
814
 
815
+ /** Average decoded-frame cadence from the declared sample timing table. */
816
+ function videoSampleRate(bytes, mdhd, stbl) {
817
+ if (!mdhd || !stbl) return null;
818
+ const scaleAt = mdhd.dataOffset + (bytes[mdhd.dataOffset] === 1 ? 20 : 12);
819
+ if (scaleAt + 4 > mdhd.end) throw new Error("MP4 video timescale is truncated.");
820
+ const scale = bytes.readUInt32BE(scaleAt);
821
+ const stts = childOf(bytes, stbl.dataOffset, stbl.end, "stts");
822
+ if (!stts) return null;
823
+ if (stts.dataOffset + 8 > stts.end) throw new Error("MP4 video timing table is truncated.");
824
+ const count = bytes.readUInt32BE(stts.dataOffset + 4);
825
+ if (stts.dataOffset + 8 + count * 8 !== stts.end) throw new Error("MP4 video timing count differs from its table size.");
826
+ let samples = 0, ticks = 0;
827
+ for (let at = stts.dataOffset + 8; at < stts.end; at += 8) {
828
+ const n = bytes.readUInt32BE(at), duration = bytes.readUInt32BE(at + 4);
829
+ samples += n;
830
+ ticks += n * duration;
831
+ if (!Number.isSafeInteger(samples) || !Number.isSafeInteger(ticks)) throw new Error("MP4 video timing exceeds its integer range.");
832
+ }
833
+ return scale > 0 && samples > 0 && ticks > 0 ? samples * scale / ticks : null;
834
+ }
835
+
836
+ function fragmentTracks({ moov, header }, tracks) {
837
+ const movie = childOf(moov, header, moov.length, "mvhd");
838
+ const movieScale = movie && moov.readUInt32BE(movie.dataOffset + (moov[movie.dataOffset] === 1 ? 20 : 12));
839
+ const mvex = childOf(moov, header, moov.length, "mvex");
840
+ const defaults = new Map(childrenOf(moov, mvex.dataOffset, mvex.end, "trex").map(trex => {
841
+ if (trex.dataOffset + 24 !== trex.end) throw new Error("MP4 fragment defaults are incomplete.");
842
+ const at = trex.dataOffset;
843
+ return [moov.readUInt32BE(at + 4), { description: moov.readUInt32BE(at + 8),
844
+ duration: moov.readUInt32BE(at + 12), size: moov.readUInt32BE(at + 16), flags: moov.readUInt32BE(at + 20) }];
845
+ }));
846
+ return childrenOf(moov, header, moov.length, "trak").map(trak => {
847
+ const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
848
+ const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
849
+ if (!tkhd || !mdia) throw new Error("MP4 fragment track declaration is absent.");
850
+ const id = moov.readUInt32BE(tkhd.dataOffset + (moov[tkhd.dataOffset] === 1 ? 20 : 12));
851
+ const track = tracks.find(track => track.trackNumber === id);
852
+ const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
853
+ const scale = mdhd && moov.readUInt32BE(mdhd.dataOffset + (moov[mdhd.dataOffset] === 1 ? 20 : 12));
854
+ if (!track || !scale || !defaults.has(id)) throw new Error("MP4 fragment track settings are incomplete.");
855
+ let shift = 0;
856
+ const edts = childOf(moov, trak.dataOffset, trak.end, "edts");
857
+ const elst = edts && childOf(moov, edts.dataOffset, edts.end, "elst");
858
+ if (elst) {
859
+ const wide = moov[elst.dataOffset] === 1, count = moov.readUInt32BE(elst.dataOffset + 4);
860
+ const width = wide ? 20 : 12;
861
+ if (moov[elst.dataOffset] > 1 || elst.dataOffset + 8 + count * width !== elst.end) throw new Error("MP4 fragment edits are incomplete.");
862
+ let mediaEdit = false;
863
+ for (let entry = 0, at = elst.dataOffset + 8; entry < count; entry++, at += width) {
864
+ const duration = wide ? Number(moov.readBigUInt64BE(at)) : moov.readUInt32BE(at);
865
+ const time = wide ? Number(moov.readBigInt64BE(at + 8)) : moov.readInt32BE(at + 4);
866
+ const rate = at + (wide ? 16 : 8);
867
+ if (!Number.isSafeInteger(duration) || !Number.isSafeInteger(time) ||
868
+ moov.readInt16BE(rate) !== 1 || moov.readInt16BE(rate + 2) !== 0 || mediaEdit) throw new Error("MP4 fragment edits require a different presentation mapping.");
869
+ if (time === -1 && movieScale) shift += duration / movieScale;
870
+ else if (time >= 0) { shift -= time / scale; mediaEdit = true; }
871
+ else throw new Error("MP4 fragment edit has no valid presentation time.");
872
+ }
873
+ }
874
+ return { id, track, scale, shift, ...defaults.get(id) };
875
+ });
876
+ }
877
+
651
878
  // ---------------------------------------------------------------------------
652
879
  // The MP4's own reading of its subtitle sample table. Here because every rule
653
880
  // in it is ISO/IEC 14496-12 speaking about this container, and the class is
@@ -705,80 +932,170 @@ const TEXT_FORMATS = new Set(["tx3g", "text", "wvtt"]);
705
932
  * @param {number} total - How many samples the size table declares.
706
933
  * @returns {number[]} Ticks each sample lasts.
707
934
  */
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;
935
+ function packetIndexFromMoov({ moov, header, offset: moovOffset }, fileSize, declaredTracks, packetMemory) {
936
+ const index = new PacketIndex({ packetMemory });
937
+ try {
938
+ const movie = childOf(moov, header, moov.length, "mvhd");
939
+ const movieScaleAt = movie ? movie.dataOffset + (moov[movie.dataOffset] === 1 ? 20 : 12) : 0;
940
+ const movieScale = movie ? moov.readUInt32BE(movieScaleAt) : 0;
941
+ for (const trak of childrenOf(moov, header, moov.length, "trak")) {
942
+ const tkhd = childOf(moov, trak.dataOffset, trak.end, "tkhd");
943
+ const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
944
+ if (!tkhd || !mdia) throw new Error("MP4 track address is absent.");
945
+ const id = moov.readUInt32BE(tkhd.dataOffset + (moov[tkhd.dataOffset] === 1 ? 20 : 12));
946
+ const track = declaredTracks.find(one => one.trackNumber === id);
947
+ if (!track || (!["video", "audio"].includes(track.type) &&
948
+ !(track.type === "subtitle" && track.isTextBased()))) continue;
949
+ const mdhd = childOf(moov, mdia.dataOffset, mdia.end, "mdhd");
950
+ const scale = mdhd && moov.readUInt32BE(mdhd.dataOffset + (moov[mdhd.dataOffset] === 1 ? 20 : 12));
951
+ if (!(scale > 0)) throw new Error("MP4 media timescale is invalid.");
952
+ const minf = childOf(moov, mdia.dataOffset, mdia.end, "minf");
953
+ const stbl = minf && childOf(moov, minf.dataOffset, minf.end, "stbl");
954
+ if (!stbl) throw new Error("MP4 track sample table is absent.");
955
+ const table = name => childOf(moov, stbl.dataOffset, stbl.end, name);
956
+ const stsz = table("stsz"), stts = table("stts"), stsc = table("stsc"), stsd = table("stsd");
957
+ const chunks = table("stco") ?? table("co64");
958
+ if (!stsz || !stts || !stsc || !stsd || !chunks) throw new Error("MP4 track sample tables are incomplete.");
959
+ const count = moov.readUInt32BE(stsz.dataOffset + 8);
960
+ const uniform = moov.readUInt32BE(stsz.dataOffset + 4);
961
+ if ((!uniform && stsz.dataOffset + 12 + count * 4 > stsz.end) ||
962
+ (uniform && count * uniform > fileSize)) throw new Error("MP4 sample sizes exceed the file.");
963
+ const sizeAt = sample => uniform || moov.readUInt32BE(stsz.dataOffset + 12 + sample * 4);
964
+ const validateRuns = (box, width) => {
965
+ const entries = moov.readUInt32BE(box.dataOffset + 4);
966
+ if (box.dataOffset + 8 + entries * width > box.end) throw new Error("MP4 sample table is truncated.");
967
+ return entries;
968
+ };
969
+ const timeEntries = validateRuns(stts, 8);
970
+ let timedSamples = 0;
971
+ for (let entry = 0; entry < timeEntries; entry++) {
972
+ timedSamples += moov.readUInt32BE(stts.dataOffset + 8 + entry * 8);
973
+ }
974
+ if (timedSamples !== count) throw new Error("MP4 sample timing count differs from its size count.");
975
+ const nextDuration = sampleRunReader(moov, stts);
976
+ const ctts = table("ctts");
977
+ if (ctts) {
978
+ const entries = validateRuns(ctts, 8);
979
+ let sample = 0;
980
+ if (moov[ctts.dataOffset] > 1) throw new Error("MP4 composition table version is invalid.");
981
+ for (let entry = 0; entry < entries; entry++) {
982
+ const at = ctts.dataOffset + 8 + entry * 8;
983
+ const run = moov.readUInt32BE(at);
984
+ if (sample + run > count) throw new Error("MP4 composition count exceeds its size count.");
985
+ sample += run;
986
+ }
987
+ if (sample !== count) throw new Error("MP4 composition count differs from its size count.");
988
+ }
989
+ const nextComposition = ctts ? sampleRunReader(moov, ctts, moov[ctts.dataOffset] === 1) : () => 0;
990
+ const chunkWidth = chunks.type === "co64" ? 8 : 4;
991
+ const chunkCount = validateRuns(chunks, chunkWidth);
992
+ const chunkRuns = validateRuns(stsc, 12);
993
+ let declaredCount = 0;
994
+ let previousChunk = 0;
995
+ for (let run = 0; run < chunkRuns; run++) {
996
+ const at = stsc.dataOffset + 8 + run * 12;
997
+ const first = moov.readUInt32BE(at), perChunk = moov.readUInt32BE(at + 4);
998
+ const next = run + 1 < chunkRuns ? moov.readUInt32BE(at + 12) : chunkCount + 1;
999
+ if (first <= previousChunk || (run === 0 && first !== 1) || next <= first || next > chunkCount + 1 || !perChunk) {
1000
+ throw new Error("MP4 sample-to-chunk run is invalid.");
1001
+ }
1002
+ declaredCount += (next - first) * perChunk;
1003
+ previousChunk = first;
718
1004
  }
1005
+ if (declaredCount !== count) throw new Error("MP4 chunk sample count differs from its size count.");
1006
+ const nextAddress = sampleAddressReader(moov, stsc, chunks, chunkWidth, chunkRuns);
1007
+ const stss = table("stss");
1008
+ let syncEntries = 0;
1009
+ if (stss) {
1010
+ syncEntries = validateRuns(stss, 4);
1011
+ let previous = 0;
1012
+ for (let entry = 0; entry < syncEntries; entry++) {
1013
+ const sample = moov.readUInt32BE(stss.dataOffset + 8 + entry * 4);
1014
+ if (sample <= previous || sample > count) throw new Error("MP4 sync sample is outside the sample table or unordered.");
1015
+ previous = sample;
1016
+ }
1017
+ }
1018
+ let emptyShiftSeconds = 0;
1019
+ let mediaShiftTicks = 0;
1020
+ const edts = childOf(moov, trak.dataOffset, trak.end, "edts");
1021
+ const edits = edts && childOf(moov, edts.dataOffset, edts.end, "elst");
1022
+ if (edits) {
1023
+ const version = moov[edits.dataOffset], wide = version === 1;
1024
+ if (version > 1) throw new Error("MP4 edit list version is invalid.");
1025
+ const entries = validateRuns(edits, wide ? 20 : 12);
1026
+ let emptySeconds = 0, mediaEdit = false;
1027
+ for (let entry = 0; entry < entries; entry++) {
1028
+ const at = edits.dataOffset + 8 + entry * (wide ? 20 : 12);
1029
+ const duration = wide ? Number(moov.readBigUInt64BE(at)) : moov.readUInt32BE(at);
1030
+ const mediaTime = wide ? Number(moov.readBigInt64BE(at + 8)) : moov.readInt32BE(at + 4);
1031
+ const rateAt = at + (wide ? 16 : 8);
1032
+ if (moov.readInt16BE(rateAt) !== 1 || moov.readInt16BE(rateAt + 2) !== 0) throw new Error("MP4 non-unit edit rate requires a presentation mapping.");
1033
+ if (mediaTime === -1 && !mediaEdit && movieScale > 0) emptySeconds += duration / movieScale;
1034
+ else if (mediaTime >= 0 && !mediaEdit) { emptyShiftSeconds = emptySeconds; mediaShiftTicks = mediaTime; mediaEdit = true; }
1035
+ else throw new Error("MP4 repeated edits require a presentation mapping.");
1036
+ }
1037
+ }
1038
+ const audioPreroll = track.seekPrerollSeconds || (track.codecId === "Opus" ? 0.08 : 0);
1039
+ index.declareTrack(id, { type: track.type, codecId: track.codecId,
1040
+ codecRanges: track.type === "subtitle" ? [] : [[moovOffset + stsd.dataOffset, moovOffset + stsd.end - 1]],
1041
+ prerollSeconds: audioPreroll, reorderDepth: track.reorderDepth ?? 0 });
1042
+ index.reservePackets(id, count);
1043
+ let decodeTicks = 0, syncAt = 0;
1044
+ for (let sample = 0; sample < count; sample++) {
1045
+ const duration = nextDuration();
1046
+ const pts = (decodeTicks + nextComposition() - mediaShiftTicks) / scale + emptyShiftSeconds;
1047
+ const dts = (decodeTicks - mediaShiftTicks) / scale + emptyShiftSeconds;
1048
+ decodeTicks += duration;
1049
+ const size = sizeAt(sample), start = nextAddress(size);
1050
+ const keyframe = !stss || (syncAt < syncEntries && moov.readUInt32BE(stss.dataOffset + 8 + syncAt * 4) === sample + 1);
1051
+ if (stss && keyframe) syncAt++;
1052
+ if (size === 0) continue;
1053
+ if (!Number.isSafeInteger(start) || start < 0 || start + size > fileSize) throw new Error("MP4 sample address exceeds the file.");
1054
+ const discardPaddingSeconds = track.type === "audio" && !(track.codecDelaySeconds > 0)
1055
+ ? -Math.min(duration / scale, Math.max(0, emptyShiftSeconds - pts)) : 0;
1056
+ index.append(id, { pts, dts, duration: duration / scale, keyframe, ranges: [[start, start + size - 1]],
1057
+ ...(discardPaddingSeconds ? { discardPaddingSeconds } : {}) });
1058
+ }
1059
+ index.complete(id);
1060
+ }
1061
+ return index;
1062
+ } catch (error) {
1063
+ index.dispose();
1064
+ throw error;
719
1065
  }
720
- return durations;
721
1066
  }
722
1067
 
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;
1068
+ /** Read compressed timing runs without expanding a whole-track array. */
1069
+ function sampleRunReader(bytes, table, signed = false) {
1070
+ let at = table.dataOffset + 8, remaining = 0, value = 0;
1071
+ return () => {
1072
+ while (!remaining) {
1073
+ if (at + 8 > table.end) throw new Error("MP4 sample timing run ended early.");
1074
+ remaining = bytes.readUInt32BE(at);
1075
+ value = signed ? bytes.readInt32BE(at + 4) : bytes.readUInt32BE(at + 4);
1076
+ at += 8;
1077
+ }
1078
+ remaining--;
1079
+ return value;
1080
+ };
742
1081
  }
743
1082
 
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
- }
1083
+ /** Sample addresses follow chunk runs directly from the retained tables. */
1084
+ function sampleAddressReader(bytes, stsc, chunks, width, runs) {
1085
+ let chunk = 1, run = 0, remaining = 0, address = 0;
1086
+ return size => {
1087
+ if (!remaining) {
1088
+ while (run + 1 < runs && bytes.readUInt32BE(stsc.dataOffset + 8 + (run + 1) * 12) <= chunk) run++;
1089
+ remaining = bytes.readUInt32BE(stsc.dataOffset + 12 + run * 12);
1090
+ const at = chunks.dataOffset + 8 + (chunk - 1) * width;
1091
+ address = width === 8 ? Number(bytes.readBigUInt64BE(at)) : bytes.readUInt32BE(at);
1092
+ chunk++;
779
1093
  }
780
- }
781
- return offsets;
1094
+ const start = address;
1095
+ address += size;
1096
+ remaining--;
1097
+ return start;
1098
+ };
782
1099
  }
783
1100
 
784
1101
  /**
@@ -812,6 +1129,57 @@ function sampleOffsets(moov, stsc, chunkOffsets, sizes) {
812
1129
  * @param {Buffer} moov - The whole `moov` box, its header included.
813
1130
  * @returns {{ tracks: Mp4SubtitleTrack[] } | null}
814
1131
  */
1132
+ function subtitleSampleView(bytes, { stsz, stts, stsc, chunks, timescale }) {
1133
+ const count = bytes.readUInt32BE(stsz.dataOffset + 8), uniform = bytes.readUInt32BE(stsz.dataOffset + 4);
1134
+ const sizeAt = sample => uniform || bytes.readUInt32BE(stsz.dataOffset + 12 + sample * 4);
1135
+ if (!uniform && stsz.dataOffset + 12 + count * 4 > stsz.end) throw new Error("MP4 subtitle sample sizes are truncated.");
1136
+ const entries = (table, width) => {
1137
+ const value = bytes.readUInt32BE(table.dataOffset + 4);
1138
+ if (table.dataOffset + 8 + value * width > table.end) throw new Error("MP4 subtitle sample table is truncated.");
1139
+ return value;
1140
+ };
1141
+ const timeEntries = entries(stts, 8), runs = entries(stsc, 12), width = chunks.type === "co64" ? 8 : 4;
1142
+ const chunkCount = entries(chunks, width);
1143
+ let timed = 0, addressed = 0, previous = 0;
1144
+ for (let entry = 0; entry < timeEntries; entry++) timed += bytes.readUInt32BE(stts.dataOffset + 8 + entry * 8);
1145
+ for (let run = 0; run < runs; run++) {
1146
+ const at = stsc.dataOffset + 8 + run * 12;
1147
+ const first = bytes.readUInt32BE(at), perChunk = bytes.readUInt32BE(at + 4);
1148
+ const next = run + 1 < runs ? bytes.readUInt32BE(at + 12) : chunkCount + 1;
1149
+ if (first <= previous || (run === 0 && first !== 1) || next <= first || next > chunkCount + 1 || !perChunk) throw new Error("MP4 subtitle chunk run is invalid.");
1150
+ addressed += (next - first) * perChunk;
1151
+ previous = first;
1152
+ }
1153
+ if (timed !== count || addressed !== count) throw new Error("MP4 subtitle sample tables disagree on their count.");
1154
+ let length = uniform ? uniform > 2 ? count : 0 : 0;
1155
+ if (!uniform) for (let sample = 0; sample < count; sample++) if (sizeAt(sample) > 2) length++;
1156
+ const view = {
1157
+ length,
1158
+ *[Symbol.iterator]() {
1159
+ if (!length) return;
1160
+ const nextDuration = sampleRunReader(bytes, stts);
1161
+ const nextAddress = sampleAddressReader(bytes, stsc, chunks, width, runs);
1162
+ let ticks = 0;
1163
+ for (let sample = 0; sample < count; sample++) {
1164
+ const startSeconds = ticks / timescale;
1165
+ ticks += nextDuration();
1166
+ const size = sizeAt(sample), offset = nextAddress(size);
1167
+ if (size > 2) yield { startSeconds, endSeconds: ticks / timescale, offset, size };
1168
+ }
1169
+ },
1170
+ map(callback) { return Array.from(this, callback); },
1171
+ at(index) {
1172
+ if (index < 0) index += length;
1173
+ if (!Number.isSafeInteger(index) || index < 0 || index >= length) return undefined;
1174
+ let current = 0;
1175
+ for (const sample of this) if (current++ === index) return sample;
1176
+ }
1177
+ };
1178
+ return new Proxy(view, { get(target, property, receiver) {
1179
+ return typeof property === "string" && /^(0|[1-9]\d*)$/.test(property) ? target.at(Number(property)) : Reflect.get(target, property, receiver);
1180
+ } });
1181
+ }
1182
+
815
1183
  function subtitlePlanFromMoov(moov) {
816
1184
  if (!moov || moov.length < HEADER_BYTES) {
817
1185
  return null;
@@ -915,41 +1283,7 @@ function subtitlePlanFromMoov(moov) {
915
1283
  continue;
916
1284
  }
917
1285
 
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
- }
1286
+ const samples = subtitleSampleView(moov, { stsz, stts, stsc, chunks: stco ?? co64, timescale });
953
1287
  tracks.push({
954
1288
  trackId,
955
1289
  declaredIndex,
@@ -1150,7 +1484,7 @@ function findAllBoxes(buffer, start, end, type) {
1150
1484
  * @param {Set<number>} wanted - Sample numbers (1-based).
1151
1485
  * @returns {number[]} Seconds, ascending.
1152
1486
  */
1153
- function resolveSampleTimes(buffer, stts, timescale, wanted, offsets = null) {
1487
+ function resolveSampleTimes(buffer, stts, timescale, wanted, offsets = null, editShift = 0) {
1154
1488
  const entryCount = buffer.readUInt32BE(stts.dataOffset + 4);
1155
1489
  const times = [];
1156
1490
  let sampleNumber = 1;
@@ -1164,7 +1498,7 @@ function resolveSampleTimes(buffer, stts, timescale, wanted, offsets = null) {
1164
1498
  // `CT(n) = DT(n) + CTTS(n)` — ISO/IEC 14496-12 §8.6.1.3. The offset is
1165
1499
  // what turns decode order into the order frames are shown in, and it is
1166
1500
  // the timeline ffmpeg cuts on.
1167
- times.push((ticks + (offsets?.get(sampleNumber) ?? 0)) / timescale);
1501
+ times.push((ticks + (offsets?.get(sampleNumber) ?? 0) - editShift) / timescale);
1168
1502
  }
1169
1503
  ticks += delta;
1170
1504
  sampleNumber += 1;
@@ -1339,15 +1673,14 @@ function keyframeTimesFromMoov(moov, headerBytes) {
1339
1673
  const elst = edts && findBox(moov, edts.dataOffset, edts.end, "elst");
1340
1674
  const editShift = elst ? readEditShift(moov, elst) : 0;
1341
1675
 
1342
- const times = resolveSampleTimes(moov, stts, timescale, wanted, offsets);
1676
+ const times = resolveSampleTimes(moov, stts, timescale, wanted, offsets, editShift);
1343
1677
  if (times.length > 0) {
1344
1678
  // A shift applied after the division would be in the wrong units: the
1345
1679
  // 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
1680
  // A negative time is not a position in the file. It happens when an edit
1348
1681
  // starts later than a keyframe the table lists, and those frames are not
1349
1682
  // presented at all.
1350
- return shifted.filter((time) => time >= 0);
1683
+ return times.filter((time) => time >= 0);
1351
1684
  }
1352
1685
  }
1353
1686
  return null;