@torrent-tv/proxy 2.89.7 → 2.89.9

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 (498) hide show
  1. package/.claude/commands/opsx/apply.md +155 -155
  2. package/.claude/commands/opsx/archive.md +160 -160
  3. package/.claude/commands/opsx/explore.md +174 -174
  4. package/.claude/commands/opsx/propose.md +109 -109
  5. package/.claude/commands/opsx/sync.md +143 -143
  6. package/.claude/skills/openspec-apply-change/SKILL.md +159 -159
  7. package/.claude/skills/openspec-archive-change/SKILL.md +117 -117
  8. package/.claude/skills/openspec-explore/SKILL.md +289 -289
  9. package/.claude/skills/openspec-propose/SKILL.md +113 -113
  10. package/.claude/skills/openspec-sync-specs/SKILL.md +147 -147
  11. package/.dockerignore +5 -5
  12. package/AGENTS.md +363 -338
  13. package/CHANGELOG.md +2345 -2335
  14. package/Dockerfile +30 -30
  15. package/LICENSE +16 -16
  16. package/README.md +369 -369
  17. package/assets/calibration/NOTICE.md +100 -100
  18. package/assets/calibration/audio/NOTICE.md +19 -19
  19. package/assets/calibration/audio/manifest.json +98 -98
  20. package/assets/diagnostics/sctpstate.gdb +31 -31
  21. package/bin/cli.js +684 -684
  22. package/bin/thread-pool.js +30 -30
  23. package/biome.json +412 -412
  24. package/docs/browser-proxy-contract.md +155 -155
  25. package/docs/container-architecture.md +273 -273
  26. package/docs/download-architecture.md +261 -261
  27. package/docs/encode-architecture.md +765 -765
  28. package/docs/encode-run-state.md +96 -96
  29. package/docs/logs.md +97 -97
  30. package/docs/playback-start-forecast.md +113 -113
  31. package/docs/socks5-egress-research.md +114 -114
  32. package/docs/storage-architecture.md +273 -273
  33. package/knip.json +14 -14
  34. package/openspec/changes/adaptive-bitrate/design.md +92 -92
  35. package/openspec/changes/adaptive-bitrate/proposal.md +56 -56
  36. package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +51 -51
  37. package/openspec/changes/adaptive-bitrate/tasks.md +40 -40
  38. package/openspec/changes/binary-distribution/.openspec.yaml +2 -2
  39. package/openspec/changes/binary-distribution/proposal.md +53 -53
  40. package/openspec/changes/chunked-request-bodies/design.md +81 -81
  41. package/openspec/changes/chunked-request-bodies/proposal.md +53 -53
  42. package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +30 -30
  43. package/openspec/changes/chunked-request-bodies/tasks.md +40 -40
  44. package/openspec/changes/cold-start/design.md +98 -98
  45. package/openspec/changes/cold-start/proposal.md +62 -62
  46. package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -47
  47. package/openspec/changes/cold-start/tasks.md +48 -48
  48. package/openspec/changes/disk-cap/.openspec.yaml +2 -2
  49. package/openspec/changes/disk-cap/proposal.md +37 -37
  50. package/openspec/changes/disk-cap/specs/disk-cap/spec.md +25 -25
  51. package/openspec/changes/disk-cap/tasks.md +19 -19
  52. package/openspec/changes/proxy-observability/.openspec.yaml +2 -2
  53. package/openspec/changes/proxy-observability/design.md +38 -38
  54. package/openspec/changes/proxy-observability/proposal.md +49 -49
  55. package/openspec/changes/proxy-observability/specs/observability/spec.md +33 -33
  56. package/openspec/changes/proxy-observability/tasks.md +21 -21
  57. package/openspec/changes/seek-debounce/design.md +118 -118
  58. package/openspec/changes/seek-debounce/proposal.md +59 -59
  59. package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +41 -41
  60. package/openspec/changes/seek-debounce/tasks.md +33 -33
  61. package/openspec/changes/subtitle-language/.openspec.yaml +2 -2
  62. package/openspec/changes/subtitle-language/proposal.md +50 -50
  63. package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +34 -34
  64. package/openspec/changes/subtitle-language/tasks.md +26 -26
  65. package/openspec/changes/track-selection/.openspec.yaml +2 -2
  66. package/openspec/changes/track-selection/design.md +47 -47
  67. package/openspec/changes/track-selection/proposal.md +45 -45
  68. package/openspec/changes/track-selection/specs/track-selection/spec.md +41 -41
  69. package/openspec/changes/track-selection/tasks.md +20 -20
  70. package/openspec/changes/transcode-quality/.openspec.yaml +2 -2
  71. package/openspec/changes/transcode-quality/proposal.md +51 -51
  72. package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +139 -139
  73. package/openspec/changes/transcode-quality/tasks.md +63 -63
  74. package/openspec/config.yaml +38 -38
  75. package/package.json +51 -50
  76. package/public/.well-known/appspecific/com.chrome.devtools.json +1 -1
  77. package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -329
  78. package/research/double-spawn-2026-09-10.md +171 -171
  79. package/research/handover-2026-09-13.md +209 -209
  80. package/research/handover-reader-claims-removal-2026-09-12.md +166 -166
  81. package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +175 -175
  82. package/research/preset-benchmark-size-scaling-2026-08-22.md +55 -55
  83. package/research/priority-map-is-the-truth-2026-09-12.md +225 -225
  84. package/routes/api/client-logs/post.js +86 -86
  85. package/routes/api/delivery-sink/get.js +72 -72
  86. package/routes/api/link-probe/get.js +51 -51
  87. package/routes/api/playback-plan/audio-tracks/post.js +29 -29
  88. package/routes/api/playback-plan/post.js +53 -53
  89. package/routes/api/sources/files/get.js +106 -106
  90. package/routes/api/sources/post.js +36 -36
  91. package/routes/api/sources/stats/get.js +132 -132
  92. package/routes/api/sources/warm/post.js +217 -217
  93. package/routes/api/subtitles/get.js +230 -230
  94. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  95. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  96. package/routes/api/transcode-sessions/post.js +178 -178
  97. package/routes/api/transcode-sessions/progress/get.js +27 -27
  98. package/routes/api/transcode-sessions/release/post.js +41 -41
  99. package/routes/api/transcode-sessions/seek/post.js +63 -63
  100. package/routes/health/get.js +13 -13
  101. package/routes/healthz/get.js +13 -13
  102. package/routes/stream/get.js +416 -416
  103. package/routes/transcode/audio-file/get.js +64 -64
  104. package/routes/transcode/audio-warm/get.js +95 -95
  105. package/routes/transcode/session-file/get.js +52 -52
  106. package/routes/transcode/variant-file/get.js +95 -95
  107. package/routes/transcode/variant-warm/get.js +84 -84
  108. package/scripts/build-audio-samples.mjs +31 -31
  109. package/scripts/render-run-graph.js +173 -173
  110. package/server.js +748 -748
  111. package/services/encode/CoverageMap.js +428 -428
  112. package/services/encode/CushionReport.js +345 -345
  113. package/services/encode/EncodeAdmission.js +376 -376
  114. package/services/encode/EncodeOrchestrator.js +1192 -1192
  115. package/services/encode/EncodePlan.js +1368 -1368
  116. package/services/encode/EncodeRun.js +36 -12
  117. package/services/encode/Encoder.js +142 -142
  118. package/services/encode/EncoderSelection.js +38 -38
  119. package/services/encode/LocalObservations.js +359 -359
  120. package/services/encode/NvencEncoder.js +50 -50
  121. package/services/encode/OutputOpening.js +784 -784
  122. package/services/encode/OutputTimes.js +579 -579
  123. package/services/encode/QsvEncoder.js +58 -58
  124. package/services/encode/Renditions.js +2283 -2283
  125. package/services/encode/RunClock.js +87 -87
  126. package/services/encode/RunProgress.js +79 -79
  127. package/services/encode/SegmentDemand.js +95 -95
  128. package/services/encode/SoftwareEncoder.js +121 -121
  129. package/services/encode/V4l2m2mEncoder.js +53 -53
  130. package/services/encode/VaapiEncoder.js +66 -66
  131. package/services/encode/args.js +418 -418
  132. package/services/encode/audio-calibration.js +67 -67
  133. package/services/encode/audio-work-rate.js +15 -15
  134. package/services/encode/available-share.js +89 -89
  135. package/services/encode/calibration.js +361 -361
  136. package/services/encode/contention.js +128 -128
  137. package/services/encode/decode-cost-fit.js +363 -363
  138. package/services/encode/encode-exit.js +116 -116
  139. package/services/encode/encode-run-state.js +551 -551
  140. package/services/encode/encoder-readings.js +43 -43
  141. package/services/encode/fingerprint.js +322 -322
  142. package/services/encode/host-load.js +174 -174
  143. package/services/encode/hwaccel.js +1894 -1894
  144. package/services/encode/index.js +9 -9
  145. package/services/encode/learned-median.js +90 -90
  146. package/services/encode/output/EncodedOutput.js +57 -57
  147. package/services/encode/output/Output.js +131 -131
  148. package/services/encode/output/OutputCatalog.js +414 -414
  149. package/services/encode/output/OutputSpec.js +390 -390
  150. package/services/encode/output/Timeline.js +308 -308
  151. package/services/encode/output/cut-grid.js +107 -107
  152. package/services/encode/output/index.js +24 -24
  153. package/services/encode/output/ladder.js +32 -32
  154. package/services/encode/output/playlists.js +284 -284
  155. package/services/encode/output/rates.js +125 -125
  156. package/services/encode/output-key-format.js +21 -21
  157. package/services/encode/quality/EncodeCost.js +1043 -1043
  158. package/services/encode/quality/HostLoad.js +525 -525
  159. package/services/encode/quality/HostTimings.js +249 -249
  160. package/services/encode/quality/OutputQualityState.js +24 -24
  161. package/services/encode/quality/QualityController.js +758 -758
  162. package/services/encode/quality/QualityOffer.js +416 -416
  163. package/services/encode/quality/drain-threat.js +47 -47
  164. package/services/encode/quality/link-budget.js +337 -337
  165. package/services/encode/quality/output-format.js +319 -319
  166. package/services/encode/quality/serving-output.js +216 -216
  167. package/services/encode/quality/visible-rung.js +57 -57
  168. package/services/encode/run-budget.js +97 -97
  169. package/services/encode/run-command.js +757 -757
  170. package/services/encode/run-costs.js +155 -155
  171. package/services/encode/run-inputs.js +53 -53
  172. package/services/encode/segment-formats/fmp4.js +476 -476
  173. package/services/encode/segment-formats/init-compat.js +324 -324
  174. package/services/encode/segment-formats/mpegts.js +134 -134
  175. package/services/encode/start-stop-cost.js +178 -178
  176. package/services/encode/throughput.js +97 -97
  177. package/services/encode/torrent-cost.js +88 -88
  178. package/services/identity/installation-id.js +38 -38
  179. package/services/media/ContainerOrchestrator.js +202 -202
  180. package/services/media/KeyframeTables.js +356 -356
  181. package/services/media/SourceFile.js +298 -298
  182. package/services/media/SubtitleCues.js +592 -592
  183. package/services/media/SubtitleOrchestrator.js +167 -167
  184. package/services/media/audio-inventory.js +299 -299
  185. package/services/media/container/AviContainer.js +268 -268
  186. package/services/media/container/Container.js +632 -632
  187. package/services/media/container/ContainerFactory.js +38 -38
  188. package/services/media/container/KeyframeTable.js +272 -272
  189. package/services/media/container/MatroskaContainer.js +1031 -1031
  190. package/services/media/container/Mp4Container.js +1354 -1354
  191. package/services/media/container/SubtitleFileContainer.js +339 -339
  192. package/services/media/container/ebml-reader.js +159 -159
  193. package/services/media/container/ebml-stream.js +174 -174
  194. package/services/media/container/matroska-clusters.js +1165 -1165
  195. package/services/media/container/unavailable.js +77 -77
  196. package/services/media/ffmpeg-banner.js +260 -260
  197. package/services/media/keyframe-probe.js +157 -157
  198. package/services/media/media-info-probe.js +93 -93
  199. package/services/media/playback-planner.js +931 -931
  200. package/services/media/tracks/AudioTrack.js +143 -143
  201. package/services/media/tracks/ContainerTrack.js +84 -84
  202. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  203. package/services/media/tracks/SubtitleTrack.js +38 -38
  204. package/services/media/tracks/TextSubtitleTrack.js +295 -295
  205. package/services/media/tracks/VideoTrack.js +33 -33
  206. package/services/media/tracks/audio-bound.js +233 -233
  207. package/services/media/tracks/index.js +15 -15
  208. package/services/media/tracks/language-detect.js +174 -174
  209. package/services/server/OutputLifecycle.js +418 -418
  210. package/services/server/SegmentServing.js +1152 -1152
  211. package/services/server/ViewerRequests.js +785 -785
  212. package/services/server/WaitLedger.js +142 -142
  213. package/services/server/controllers/PlaybackController.js +40 -40
  214. package/services/server/controllers/SubtitleController.js +109 -109
  215. package/services/server/http-responses.js +60 -60
  216. package/services/server/transcode-session-files.js +325 -325
  217. package/services/server/wire-outputs.js +1029 -1029
  218. package/services/storage/Diagnostics.js +202 -202
  219. package/services/storage/MachineBudget.js +235 -235
  220. package/services/storage/allowance.js +107 -107
  221. package/services/storage/core-dumps.js +106 -106
  222. package/services/storage/files/CompletedFiles.js +391 -391
  223. package/services/storage/files/piece-from-whole-file.js +118 -118
  224. package/services/storage/free.js +71 -71
  225. package/services/storage/keep.js +48 -48
  226. package/services/storage/machine-memory.js +56 -56
  227. package/services/storage/memory-report.js +575 -575
  228. package/services/storage/piece-store/piece-disk-store.js +539 -539
  229. package/services/storage/piece-store/piece-lru.js +495 -495
  230. package/services/storage/piece-store/shared-piece-store.js +1937 -1937
  231. package/services/storage/returns.js +103 -103
  232. package/services/storage/segment-store/SegmentStore.js +1294 -1294
  233. package/services/storage/wire.js +175 -175
  234. package/services/torrent/Contents.js +382 -382
  235. package/services/torrent/demand/DemandRegister.js +202 -202
  236. package/services/torrent/demand/Urgency.js +137 -137
  237. package/services/torrent/demand/Window.js +118 -118
  238. package/services/torrent/demand/index.js +8 -8
  239. package/services/torrent/demand/pieces.js +140 -140
  240. package/services/torrent/download/SwarmSelection.js +386 -386
  241. package/services/torrent/download/registry.js +102 -102
  242. package/services/torrent/download/withdraw-claim.js +80 -80
  243. package/services/torrent/episode-naming.js +182 -182
  244. package/services/torrent/files.js +455 -455
  245. package/services/torrent/naming.js +619 -619
  246. package/services/torrent/piece-store-of.js +29 -29
  247. package/services/torrent/supply-margin.js +264 -264
  248. package/services/torrent/torrent-pool.js +2688 -2688
  249. package/services/torrent/worker/background-fill.js +213 -213
  250. package/services/torrent/worker/channel.js +271 -271
  251. package/services/torrent/worker/client.js +836 -836
  252. package/services/torrent/worker/fastest-wires.js +342 -342
  253. package/services/torrent/worker/handle-state.js +23 -23
  254. package/services/torrent/worker/held-bytes.js +231 -231
  255. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  256. package/services/torrent/worker/piece-reader.js +1078 -1078
  257. package/services/torrent/worker/pool-adapter.js +371 -371
  258. package/services/torrent/worker/protocol.js +184 -184
  259. package/services/torrent/worker/resume-warm.js +107 -107
  260. package/services/torrent/worker/webrtc-shim.js +219 -219
  261. package/services/torrent/worker/worker.js +1137 -1137
  262. package/services/transport/data-channel-handler.js +1406 -1373
  263. package/services/transport/delivery-probe.js +798 -842
  264. package/services/transport/health-collector.js +53 -53
  265. package/services/transport/nat-classifier.js +231 -231
  266. package/services/transport/packet-witness.js +808 -808
  267. package/services/transport/port-mapper.js +235 -235
  268. package/services/transport/registry-api.js +81 -81
  269. package/services/transport/tunnel-client.js +447 -447
  270. package/services/transport/usrsctp-state.js +175 -175
  271. package/services/transport/webrtc-manager.js +484 -484
  272. package/services/viewer/Assignments.js +340 -340
  273. package/services/viewer/PriorityMap.js +421 -421
  274. package/services/viewer/PriorityOrchestrator.js +325 -325
  275. package/services/viewer/Viewer.js +691 -691
  276. package/services/viewer/Viewers.js +368 -368
  277. package/services/viewer/active-output.js +37 -37
  278. package/services/viewer/audio-start.js +46 -46
  279. package/services/viewer/buffer-trend.js +81 -81
  280. package/services/viewer/choices.js +647 -647
  281. package/services/viewer/media-time.js +355 -355
  282. package/services/viewer/playback-readiness.js +635 -635
  283. package/services/viewer/positions.js +126 -126
  284. package/services/viewer/report-intake.js +46 -46
  285. package/store/source-registry.js +71 -71
  286. package/test/asset-paths.test.js +28 -28
  287. package/test/audio-buffer-continuity.test.js +77 -77
  288. package/test/audio-calibration.test.js +23 -23
  289. package/test/audio-inventory.test.js +212 -212
  290. package/test/audio-track-bitrate.test.js +40 -40
  291. package/test/auto-quality-step.test.js +762 -762
  292. package/test/available-share.test.js +76 -76
  293. package/test/background-fill.test.js +190 -190
  294. package/test/client-log-file.test.js +110 -110
  295. package/test/coalesce.test.js +39 -39
  296. package/test/completed-files.test.js +115 -115
  297. package/test/concurrent-cost.test.js +148 -148
  298. package/test/container-media-info.test.js +228 -228
  299. package/test/contention.test.js +76 -76
  300. package/test/core-dumps.test.js +57 -57
  301. package/test/coverage-follows-the-disk.test.js +233 -228
  302. package/test/coverage-map.test.js +195 -195
  303. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  304. package/test/cut-times-timeline.test.js +65 -65
  305. package/test/cuts-follow-published-grid.test.js +109 -109
  306. package/test/data-channel-frame.test.js +63 -63
  307. package/test/data-channel-viewer-lifecycle.test.js +162 -92
  308. package/test/dead-torrent-handle.test.js +52 -52
  309. package/test/declared-rates.test.js +116 -116
  310. package/test/declared-tracks.test.js +43 -43
  311. package/test/decode-cost-fit.test.js +202 -202
  312. package/test/decode-cost.test.js +475 -475
  313. package/test/delivery-probe-field.test.js +173 -0
  314. package/test/delivery-probe.test.js +382 -393
  315. package/test/demand-register.test.js +206 -206
  316. package/test/dht-bootstrap.test.js +72 -72
  317. package/test/drain-threat.test.js +65 -65
  318. package/test/duplicate-add.test.js +48 -48
  319. package/test/encode-admission.test.js +146 -146
  320. package/test/encode-cost.test.js +275 -275
  321. package/test/encode-exit.test.js +111 -111
  322. package/test/encode-orchestrator.test.js +426 -426
  323. package/test/encode-plan-viewers.test.js +725 -725
  324. package/test/encode-plan.test.js +545 -545
  325. package/test/encode-run-state.test.js +314 -314
  326. package/test/encode-run.test.js +20 -0
  327. package/test/encode-slope.test.js +46 -46
  328. package/test/encoded-output.test.js +39 -39
  329. package/test/encoder-kinds.test.js +122 -122
  330. package/test/encoder-readings.test.js +68 -68
  331. package/test/episode-naming.test.js +131 -131
  332. package/test/every-claimant-is-told-its-share.test.js +152 -152
  333. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  334. package/test/failed-start-breaker.test.js +202 -202
  335. package/test/fastest-wires.test.js +143 -143
  336. package/test/ffmpeg-stream-counts.test.js +78 -78
  337. package/test/file-edges.test.js +191 -191
  338. package/test/fixtures/audio-frame-boundary.json +1 -1
  339. package/test/fmp4-self-contained.test.js +80 -80
  340. package/test/fmp4-timestamps.test.js +140 -140
  341. package/test/fmp4-track-completeness.test.js +41 -41
  342. package/test/format-keyed-outputs.test.js +78 -78
  343. package/test/health-metrics.test.js +36 -38
  344. package/test/held-read-ownership.test.js +77 -77
  345. package/test/held-request-width.test.js +157 -157
  346. package/test/helpers/encode-run.js +248 -240
  347. package/test/helpers/held-file.js +77 -77
  348. package/test/helpers/manager.js +62 -62
  349. package/test/helpers/matroska-file.js +296 -296
  350. package/test/helpers/output-spec.js +63 -63
  351. package/test/host-load.test.js +68 -68
  352. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  353. package/test/init-compat.test.js +219 -219
  354. package/test/input-lost-quiets-the-plan.test.js +261 -261
  355. package/test/input-unavailable.test.js +48 -48
  356. package/test/installation-id.test.js +39 -39
  357. package/test/keeping-period.test.js +83 -83
  358. package/test/keyframe-index-accuracy.test.js +188 -188
  359. package/test/keyframe-table-budget.test.js +123 -123
  360. package/test/keyframes-belong-to-the-file.test.js +249 -249
  361. package/test/learned-median.test.js +37 -37
  362. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  363. package/test/limit-row-by-area.test.js +60 -60
  364. package/test/link-budget.test.js +111 -111
  365. package/test/link-probe.test.js +56 -56
  366. package/test/local-encoding-data.test.js +273 -273
  367. package/test/logger-repeats.test.js +134 -134
  368. package/test/look-ahead-coverage.test.js +56 -56
  369. package/test/manager-surface.test.js +291 -291
  370. package/test/matroska-cluster-discovery.test.js +323 -323
  371. package/test/matroska-cues-track.test.js +192 -192
  372. package/test/media-time.test.js +62 -62
  373. package/test/memory-budget.test.js +390 -390
  374. package/test/move-cost.test.js +216 -216
  375. package/test/mp4-subtitles.test.js +173 -173
  376. package/test/one-authority.test.js +285 -285
  377. package/test/one-budget.test.js +149 -149
  378. package/test/one-output-one-session.test.js +273 -273
  379. package/test/one-resource-divided.test.js +185 -185
  380. package/test/one-serving-output-per-height.test.js +458 -458
  381. package/test/orchestrator-wired.test.js +207 -207
  382. package/test/output-catalog.test.js +39 -39
  383. package/test/output-shape.test.js +21 -21
  384. package/test/output-spec.test.js +251 -251
  385. package/test/output-speed.test.js +92 -87
  386. package/test/outputs-being-prepared.test.js +40 -40
  387. package/test/packet-witness-ring.test.js +236 -236
  388. package/test/packet-witness.test.js +148 -148
  389. package/test/piece-disk-store.test.js +401 -401
  390. package/test/piece-from-whole-file.test.js +129 -129
  391. package/test/piece-lru.test.js +319 -319
  392. package/test/piece-reader.test.js +162 -162
  393. package/test/piece-selection-offset.test.js +85 -85
  394. package/test/piece-store-eviction.test.js +595 -595
  395. package/test/piece-store-never-refuses.test.js +153 -153
  396. package/test/piece-store-reservations.test.js +403 -403
  397. package/test/piece-store-slow-disk.test.js +159 -159
  398. package/test/piece-tail.test.js +97 -97
  399. package/test/plan-host-timings.test.js +68 -68
  400. package/test/plan-is-steady.test.js +225 -225
  401. package/test/playback-readiness.test.js +518 -518
  402. package/test/playback-start-regressions.test.js +101 -101
  403. package/test/priority-map-download.test.js +174 -174
  404. package/test/priority-map-emptied.test.js +204 -204
  405. package/test/priority-map-per-output.test.js +215 -215
  406. package/test/priority-map.test.js +163 -163
  407. package/test/probe-wedge-certainty.test.js +60 -60
  408. package/test/produced-copy-choice.test.js +213 -213
  409. package/test/published-timeline.test.js +83 -83
  410. package/test/quality-offer.test.js +160 -160
  411. package/test/quality-variants.test.js +1274 -1270
  412. package/test/rate-control-in-the-output.test.js +181 -180
  413. package/test/read-survives-withdrawal.test.js +164 -164
  414. package/test/read-window.test.js +365 -365
  415. package/test/reads-not-yet-arrived.test.js +218 -218
  416. package/test/resume-warm.test.js +39 -39
  417. package/test/run-graph-drift.test.js +37 -37
  418. package/test/run-inputs.test.js +80 -80
  419. package/test/run-intervals.test.js +100 -100
  420. package/test/run-position-follows-published-grid.test.js +89 -89
  421. package/test/run-progress.test.js +64 -64
  422. package/test/same-height-switch.test.js +663 -663
  423. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  424. package/test/seek-landing.test.js +109 -109
  425. package/test/seek-target-not-superseded.test.js +195 -195
  426. package/test/segment-closed-on-both-branches.test.js +114 -114
  427. package/test/segment-cut-times.test.js +63 -63
  428. package/test/segment-demand.test.js +80 -80
  429. package/test/segment-hold-supersede.test.js +91 -91
  430. package/test/segment-judged-by-its-own-header.test.js +222 -222
  431. package/test/segment-serve-wiring.test.js +481 -481
  432. package/test/segment-store-eviction.test.js +247 -247
  433. package/test/segment-store.test.js +352 -352
  434. package/test/segment-true-start.test.js +86 -86
  435. package/test/segments-are-shared.test.js +176 -176
  436. package/test/send-chunk-bytes.test.js +86 -86
  437. package/test/serving-existing-output.test.js +111 -111
  438. package/test/serving-output.test.js +102 -102
  439. package/test/shared-piece-store.test.js +310 -310
  440. package/test/sidecar-naming.test.js +142 -142
  441. package/test/soundtrack-rate-bound.test.js +289 -289
  442. package/test/source-file.test.js +133 -133
  443. package/test/source-files-route.test.js +113 -113
  444. package/test/source-registry.test.js +18 -18
  445. package/test/split-a-stretch.test.js +139 -139
  446. package/test/stale-request-after-seek.test.js +158 -158
  447. package/test/startup-readings.test.js +124 -124
  448. package/test/stream-route.test.js +237 -237
  449. package/test/subtitle-cue-framing.test.js +200 -200
  450. package/test/subtitle-cue-source.test.js +102 -102
  451. package/test/subtitle-cue-walk.test.js +385 -385
  452. package/test/subtitle-cursor.test.js +63 -63
  453. package/test/subtitle-defaults.test.js +118 -118
  454. package/test/subtitle-language.test.js +252 -252
  455. package/test/subtitle-metadata.test.js +15 -15
  456. package/test/subtitle-plan-waits.test.js +125 -125
  457. package/test/subtitle-track-numbering.test.js +391 -391
  458. package/test/supply-margin.test.js +190 -190
  459. package/test/swarm-follows-readers.test.js +174 -174
  460. package/test/swarm-reach.test.js +130 -130
  461. package/test/swarm-selection.test.js +220 -220
  462. package/test/tail-duplication.test.js +167 -167
  463. package/test/the-channel-has-both-ends.test.js +91 -91
  464. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  465. package/test/thread-pool.test.js +53 -53
  466. package/test/timeline.test.js +84 -84
  467. package/test/torrent-contents.test.js +235 -235
  468. package/test/torrent-cost.test.js +71 -71
  469. package/test/torrent-files.test.js +221 -221
  470. package/test/torrent-naming.test.js +255 -255
  471. package/test/tracks-begin-together.test.js +209 -209
  472. package/test/tunnel-renewal.test.js +167 -167
  473. package/test/two-viewers-one-picture.test.js +508 -508
  474. package/test/upload-hurry.test.js +186 -186
  475. package/test/usrsctp-state.test.js +131 -131
  476. package/test/video-facts.test.js +102 -102
  477. package/test/viewer-assignments.test.js +143 -143
  478. package/test/viewer-outputs.test.js +285 -285
  479. package/test/viewer-presence.test.js +116 -116
  480. package/test/viewer-requests-admission.test.js +92 -92
  481. package/test/viewer-starts-on-map.test.js +27 -27
  482. package/test/viewer-states.test.js +192 -189
  483. package/test/viewer-subtitle-subscription.test.js +96 -96
  484. package/test/viewer.test.js +139 -139
  485. package/test/viewing-generations.test.js +390 -390
  486. package/test/visible-rung.test.js +37 -37
  487. package/test/wait-ledger.test.js +100 -100
  488. package/test/webrtc-shim.test.js +149 -149
  489. package/test/wedge-certainty.test.js +131 -131
  490. package/test/whole-file-held-read.test.js +30 -30
  491. package/test/withdraw-piece-claim.test.js +212 -212
  492. package/test/worker-channel.test.js +158 -158
  493. package/utils/client-log-file.js +151 -151
  494. package/utils/coalesce.js +41 -41
  495. package/utils/logger.js +312 -312
  496. package/utils/parse-range.js +25 -25
  497. package/utils/perf.js +59 -59
  498. package/utils/torrent-source-key.js +38 -38
@@ -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
+ }