@torrent-tv/proxy 2.88.4 → 2.88.5

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