@torrent-tv/proxy 2.88.3 → 2.88.4

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