@torrent-tv/proxy 2.87.20 → 2.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (474) 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 +8 -0
  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 +133 -133
  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 +50 -50
  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/files/get.js +106 -100
  87. package/routes/api/sources/post.js +36 -36
  88. package/routes/api/sources/stats/get.js +132 -132
  89. package/routes/api/sources/warm/post.js +217 -217
  90. package/routes/api/subtitles/get.js +230 -230
  91. package/routes/api/transcode-sessions/fragment-far/post.js +60 -60
  92. package/routes/api/transcode-sessions/net-report/post.js +138 -138
  93. package/routes/api/transcode-sessions/post.js +173 -173
  94. package/routes/api/transcode-sessions/progress/get.js +27 -27
  95. package/routes/api/transcode-sessions/release/post.js +41 -41
  96. package/routes/api/transcode-sessions/seek/post.js +63 -63
  97. package/routes/health/get.js +13 -13
  98. package/routes/healthz/get.js +13 -13
  99. package/routes/stream/get.js +380 -380
  100. package/routes/transcode/audio-file/get.js +64 -64
  101. package/routes/transcode/audio-warm/get.js +84 -84
  102. package/routes/transcode/session-file/get.js +52 -52
  103. package/routes/transcode/variant-file/get.js +95 -95
  104. package/routes/transcode/variant-warm/get.js +84 -84
  105. package/scripts/render-run-graph.js +173 -173
  106. package/server.js +725 -725
  107. package/services/encode/CoverageMap.js +428 -428
  108. package/services/encode/CushionReport.js +345 -345
  109. package/services/encode/EncodeAdmission.js +376 -376
  110. package/services/encode/EncodeOrchestrator.js +1217 -1217
  111. package/services/encode/EncodePlan.js +1366 -1366
  112. package/services/encode/EncodeRun.js +813 -813
  113. package/services/encode/EncodeRuns.js +1235 -1235
  114. package/services/encode/Encoder.js +142 -142
  115. package/services/encode/EncoderSelection.js +38 -38
  116. package/services/encode/LocalObservations.js +359 -359
  117. package/services/encode/NvencEncoder.js +50 -50
  118. package/services/encode/OutputOpening.js +758 -758
  119. package/services/encode/OutputTimes.js +579 -579
  120. package/services/encode/QsvEncoder.js +58 -58
  121. package/services/encode/Renditions.js +2184 -2184
  122. package/services/encode/RunProgress.js +84 -84
  123. package/services/encode/SegmentDemand.js +95 -95
  124. package/services/encode/SoftwareEncoder.js +121 -121
  125. package/services/encode/V4l2m2mEncoder.js +53 -53
  126. package/services/encode/VaapiEncoder.js +66 -66
  127. package/services/encode/args.js +416 -416
  128. package/services/encode/available-share.js +89 -89
  129. package/services/encode/calibration.js +361 -361
  130. package/services/encode/contention.js +128 -128
  131. package/services/encode/decode-cost-fit.js +363 -363
  132. package/services/encode/encode-exit.js +114 -114
  133. package/services/encode/encode-run-state.js +551 -551
  134. package/services/encode/encoder-readings.js +45 -45
  135. package/services/encode/fingerprint.js +322 -322
  136. package/services/encode/host-load.js +174 -174
  137. package/services/encode/hwaccel.js +1894 -1894
  138. package/services/encode/index.js +9 -9
  139. package/services/encode/learned-median.js +90 -90
  140. package/services/encode/output/EncodedOutput.js +57 -57
  141. package/services/encode/output/Output.js +131 -131
  142. package/services/encode/output/OutputCatalog.js +414 -414
  143. package/services/encode/output/OutputSpec.js +390 -390
  144. package/services/encode/output/Timeline.js +308 -308
  145. package/services/encode/output/cut-grid.js +107 -107
  146. package/services/encode/output/index.js +20 -20
  147. package/services/encode/output/ladder.js +32 -32
  148. package/services/encode/output/playlists.js +284 -284
  149. package/services/encode/output/rates.js +125 -125
  150. package/services/encode/output-key-format.js +21 -21
  151. package/services/encode/quality/EncodeCost.js +1141 -1141
  152. package/services/encode/quality/HostLoad.js +525 -525
  153. package/services/encode/quality/HostTimings.js +249 -249
  154. package/services/encode/quality/OutputQualityState.js +28 -28
  155. package/services/encode/quality/QualityController.js +935 -935
  156. package/services/encode/quality/QualityOffer.js +416 -416
  157. package/services/encode/quality/drain-threat.js +47 -47
  158. package/services/encode/quality/link-budget.js +254 -254
  159. package/services/encode/quality/output-format.js +315 -315
  160. package/services/encode/quality/serving-output.js +216 -216
  161. package/services/encode/quality/visible-rung.js +57 -57
  162. package/services/encode/run-budget.js +97 -97
  163. package/services/encode/run-command.js +754 -754
  164. package/services/encode/run-costs.js +155 -155
  165. package/services/encode/run-inputs.js +42 -42
  166. package/services/encode/segment-formats/fmp4.js +456 -456
  167. package/services/encode/segment-formats/index.js +85 -85
  168. package/services/encode/segment-formats/init-compat.js +324 -324
  169. package/services/encode/segment-formats/mp4-boxes.js +515 -515
  170. package/services/encode/segment-formats/mpegts.js +134 -134
  171. package/services/encode/start-stop-cost.js +178 -178
  172. package/services/encode/throughput.js +97 -97
  173. package/services/encode/torrent-cost.js +88 -88
  174. package/services/identity/installation-id.js +38 -38
  175. package/services/media/ContainerOrchestrator.js +149 -149
  176. package/services/media/KeyframeTables.js +316 -316
  177. package/services/media/SourceFile.js +298 -298
  178. package/services/media/SubtitleCues.js +520 -520
  179. package/services/media/SubtitleOrchestrator.js +167 -167
  180. package/services/media/audio-inventory.js +245 -245
  181. package/services/media/container/AviContainer.js +266 -266
  182. package/services/media/container/Container.js +603 -603
  183. package/services/media/container/ContainerFactory.js +56 -56
  184. package/services/media/container/KeyframeTable.js +272 -272
  185. package/services/media/container/MatroskaContainer.js +1494 -1494
  186. package/services/media/container/Mp4Container.js +1244 -1244
  187. package/services/media/container/SubtitleFileContainer.js +322 -322
  188. package/services/media/container/ebml-reader.js +159 -159
  189. package/services/media/ffmpeg-banner.js +260 -260
  190. package/services/media/keyframe-probe.js +157 -157
  191. package/services/media/media-info-probe.js +93 -93
  192. package/services/media/playback-planner.js +931 -931
  193. package/services/media/tracks/AudioTrack.js +131 -131
  194. package/services/media/tracks/ContainerTrack.js +75 -75
  195. package/services/media/tracks/ImageSubtitleTrack.js +19 -19
  196. package/services/media/tracks/SubtitleTrack.js +38 -38
  197. package/services/media/tracks/TextSubtitleTrack.js +287 -287
  198. package/services/media/tracks/VideoTrack.js +33 -33
  199. package/services/media/tracks/index.js +15 -15
  200. package/services/media/tracks/language-detect.js +174 -174
  201. package/services/server/OutputLifecycle.js +418 -418
  202. package/services/server/SegmentServing.js +1150 -1150
  203. package/services/server/ViewerRequests.js +760 -760
  204. package/services/server/WaitLedger.js +142 -142
  205. package/services/server/controllers/PlaybackController.js +40 -40
  206. package/services/server/controllers/SubtitleController.js +109 -109
  207. package/services/server/http-responses.js +60 -60
  208. package/services/server/playback-readiness.js +696 -696
  209. package/services/server/transcode-session-files.js +325 -325
  210. package/services/server/wire-outputs.js +992 -992
  211. package/services/storage/Diagnostics.js +202 -202
  212. package/services/storage/MachineBudget.js +235 -235
  213. package/services/storage/allowance.js +107 -107
  214. package/services/storage/core-dumps.js +106 -106
  215. package/services/storage/files/CompletedFiles.js +391 -391
  216. package/services/storage/files/piece-from-whole-file.js +118 -118
  217. package/services/storage/free.js +71 -71
  218. package/services/storage/keep.js +48 -48
  219. package/services/storage/machine-memory.js +56 -56
  220. package/services/storage/memory-report.js +575 -575
  221. package/services/storage/piece-store/piece-disk-store.js +539 -539
  222. package/services/storage/piece-store/piece-lru.js +495 -495
  223. package/services/storage/piece-store/shared-piece-store.js +1931 -1908
  224. package/services/storage/returns.js +103 -103
  225. package/services/storage/segment-store/SegmentStore.js +1240 -1240
  226. package/services/storage/wire.js +175 -175
  227. package/services/torrent/Contents.js +382 -324
  228. package/services/torrent/demand/DemandRegister.js +202 -202
  229. package/services/torrent/demand/Urgency.js +137 -137
  230. package/services/torrent/demand/Window.js +118 -118
  231. package/services/torrent/demand/index.js +8 -8
  232. package/services/torrent/demand/pieces.js +140 -140
  233. package/services/torrent/download/SwarmSelection.js +386 -386
  234. package/services/torrent/download/registry.js +102 -102
  235. package/services/torrent/download/withdraw-claim.js +80 -80
  236. package/services/torrent/episode-naming.js +182 -0
  237. package/services/torrent/files.js +455 -455
  238. package/services/torrent/naming.js +619 -619
  239. package/services/torrent/piece-store-of.js +29 -29
  240. package/services/torrent/supply-margin.js +262 -262
  241. package/services/torrent/torrent-pool.js +2675 -2675
  242. package/services/torrent/worker/background-fill.js +213 -213
  243. package/services/torrent/worker/channel.js +271 -271
  244. package/services/torrent/worker/client.js +765 -765
  245. package/services/torrent/worker/fastest-wires.js +342 -342
  246. package/services/torrent/worker/handle-state.js +23 -23
  247. package/services/torrent/worker/held-bytes.js +155 -155
  248. package/services/torrent/worker/install-webrtc-shim.js +32 -32
  249. package/services/torrent/worker/piece-reader.js +1078 -1078
  250. package/services/torrent/worker/pool-adapter.js +371 -371
  251. package/services/torrent/worker/protocol.js +184 -184
  252. package/services/torrent/worker/resume-warm.js +107 -107
  253. package/services/torrent/worker/webrtc-shim.js +219 -219
  254. package/services/torrent/worker/worker.js +1129 -1129
  255. package/services/transport/data-channel-handler.js +1369 -1369
  256. package/services/transport/delivery-probe.js +842 -842
  257. package/services/transport/health-collector.js +53 -53
  258. package/services/transport/nat-classifier.js +231 -231
  259. package/services/transport/packet-witness.js +808 -808
  260. package/services/transport/port-mapper.js +235 -235
  261. package/services/transport/registry-api.js +81 -81
  262. package/services/transport/tunnel-client.js +447 -447
  263. package/services/transport/usrsctp-state.js +175 -175
  264. package/services/transport/webrtc-manager.js +484 -484
  265. package/services/viewer/Assignments.js +340 -340
  266. package/services/viewer/PriorityMap.js +390 -390
  267. package/services/viewer/PriorityOrchestrator.js +325 -325
  268. package/services/viewer/Viewer.js +691 -691
  269. package/services/viewer/Viewers.js +368 -368
  270. package/services/viewer/active-output.js +37 -37
  271. package/services/viewer/audio-start.js +46 -46
  272. package/services/viewer/buffer-trend.js +81 -81
  273. package/services/viewer/choices.js +647 -647
  274. package/services/viewer/positions.js +126 -126
  275. package/services/viewer/report-intake.js +46 -46
  276. package/store/source-registry.js +71 -71
  277. package/test/asset-paths.test.js +28 -28
  278. package/test/audio-inventory.test.js +212 -212
  279. package/test/audio-track-bitrate.test.js +40 -40
  280. package/test/auto-quality-step.test.js +707 -707
  281. package/test/available-share.test.js +76 -76
  282. package/test/background-fill.test.js +190 -190
  283. package/test/client-log-file.test.js +110 -110
  284. package/test/completed-files.test.js +115 -115
  285. package/test/concurrent-cost.test.js +148 -148
  286. package/test/container-media-info.test.js +228 -228
  287. package/test/contention.test.js +76 -76
  288. package/test/core-dumps.test.js +57 -57
  289. package/test/coverage-follows-the-disk.test.js +228 -228
  290. package/test/coverage-map.test.js +195 -195
  291. package/test/cue-push-carries-what-the-walk-found.test.js +58 -58
  292. package/test/cut-times-timeline.test.js +65 -65
  293. package/test/cuts-follow-published-grid.test.js +109 -109
  294. package/test/data-channel-frame.test.js +63 -63
  295. package/test/data-channel-viewer-lifecycle.test.js +92 -92
  296. package/test/dead-torrent-handle.test.js +52 -52
  297. package/test/declared-rates.test.js +116 -116
  298. package/test/declared-tracks.test.js +43 -43
  299. package/test/decode-cost-fit.test.js +202 -202
  300. package/test/decode-cost.test.js +462 -462
  301. package/test/delivery-probe.test.js +393 -393
  302. package/test/demand-register.test.js +206 -206
  303. package/test/dht-bootstrap.test.js +72 -72
  304. package/test/drain-threat.test.js +65 -65
  305. package/test/duplicate-add.test.js +48 -48
  306. package/test/encode-admission.test.js +146 -146
  307. package/test/encode-cost.test.js +283 -283
  308. package/test/encode-exit.test.js +111 -111
  309. package/test/encode-orchestrator.test.js +419 -419
  310. package/test/encode-plan-viewers.test.js +718 -718
  311. package/test/encode-plan.test.js +545 -545
  312. package/test/encode-run-state.test.js +314 -314
  313. package/test/encode-run.test.js +162 -162
  314. package/test/encode-slope.test.js +46 -46
  315. package/test/encoded-output.test.js +39 -39
  316. package/test/encoder-kinds.test.js +122 -122
  317. package/test/encoder-readings.test.js +46 -46
  318. package/test/episode-naming.test.js +131 -0
  319. package/test/every-claimant-is-told-its-share.test.js +152 -152
  320. package/test/failed-encoder-still-serves-what-exists.test.js +354 -354
  321. package/test/failed-start-breaker.test.js +202 -202
  322. package/test/fastest-wires.test.js +143 -143
  323. package/test/ffmpeg-stream-counts.test.js +78 -78
  324. package/test/file-edges.test.js +191 -191
  325. package/test/fmp4-presentation-clock.test.js +147 -147
  326. package/test/fmp4-self-contained.test.js +80 -80
  327. package/test/fmp4-timestamps.test.js +140 -140
  328. package/test/fmp4-track-completeness.test.js +41 -41
  329. package/test/format-keyed-outputs.test.js +78 -78
  330. package/test/health-metrics.test.js +38 -38
  331. package/test/held-request-width.test.js +157 -157
  332. package/test/helpers/encode-run.js +136 -136
  333. package/test/helpers/held-file.js +29 -29
  334. package/test/helpers/manager.js +57 -57
  335. package/test/helpers/output-spec.js +63 -63
  336. package/test/host-load.test.js +68 -68
  337. package/test/index-accuracy-is-about-the-file.test.js +128 -128
  338. package/test/init-compat.test.js +219 -219
  339. package/test/input-lost-quiets-the-plan.test.js +261 -261
  340. package/test/input-unavailable.test.js +48 -48
  341. package/test/installation-id.test.js +39 -39
  342. package/test/keeping-period.test.js +83 -83
  343. package/test/keyframe-index-accuracy.test.js +188 -188
  344. package/test/keyframe-table-budget.test.js +102 -102
  345. package/test/keyframes-belong-to-the-file.test.js +249 -249
  346. package/test/learned-median.test.js +37 -37
  347. package/test/letting-go-asks-who-is-watching.test.js +191 -191
  348. package/test/limit-row-by-area.test.js +60 -60
  349. package/test/link-budget.test.js +111 -111
  350. package/test/link-probe.test.js +56 -56
  351. package/test/local-encoding-data.test.js +273 -273
  352. package/test/logger-repeats.test.js +134 -134
  353. package/test/look-ahead-coverage.test.js +56 -56
  354. package/test/manager-surface.test.js +292 -292
  355. package/test/matroska-cues-track.test.js +192 -192
  356. package/test/memory-budget.test.js +390 -390
  357. package/test/move-cost.test.js +216 -216
  358. package/test/mp4-subtitles.test.js +173 -173
  359. package/test/one-authority.test.js +285 -285
  360. package/test/one-budget.test.js +149 -149
  361. package/test/one-output-one-session.test.js +204 -204
  362. package/test/one-resource-divided.test.js +185 -185
  363. package/test/one-serving-output-per-height.test.js +458 -458
  364. package/test/orchestrator-wired.test.js +206 -206
  365. package/test/output-catalog.test.js +39 -39
  366. package/test/output-shape.test.js +21 -21
  367. package/test/output-spec.test.js +251 -251
  368. package/test/output-speed.test.js +88 -88
  369. package/test/outputs-being-prepared.test.js +40 -40
  370. package/test/packet-witness-ring.test.js +236 -236
  371. package/test/packet-witness.test.js +148 -148
  372. package/test/piece-disk-store.test.js +401 -401
  373. package/test/piece-from-whole-file.test.js +129 -129
  374. package/test/piece-lru.test.js +319 -319
  375. package/test/piece-reader.test.js +162 -162
  376. package/test/piece-selection-offset.test.js +85 -85
  377. package/test/piece-store-eviction.test.js +595 -595
  378. package/test/piece-store-never-refuses.test.js +153 -153
  379. package/test/piece-store-reservations.test.js +403 -365
  380. package/test/piece-store-slow-disk.test.js +159 -159
  381. package/test/piece-tail.test.js +97 -97
  382. package/test/plan-host-timings.test.js +68 -68
  383. package/test/plan-is-steady.test.js +225 -225
  384. package/test/playback-readiness.test.js +453 -453
  385. package/test/playback-start-regressions.test.js +99 -99
  386. package/test/priority-map-download.test.js +174 -174
  387. package/test/priority-map-emptied.test.js +204 -204
  388. package/test/priority-map-per-output.test.js +215 -215
  389. package/test/priority-map.test.js +163 -163
  390. package/test/probe-wedge-certainty.test.js +60 -60
  391. package/test/produced-copy-choice.test.js +213 -213
  392. package/test/published-timeline.test.js +83 -83
  393. package/test/quality-offer.test.js +162 -162
  394. package/test/quality-variants.test.js +1229 -1229
  395. package/test/rate-control-in-the-output.test.js +180 -180
  396. package/test/read-survives-withdrawal.test.js +164 -164
  397. package/test/read-window.test.js +365 -365
  398. package/test/resume-warm.test.js +39 -39
  399. package/test/run-graph-drift.test.js +37 -37
  400. package/test/run-inputs.test.js +80 -80
  401. package/test/run-intervals.test.js +100 -100
  402. package/test/run-position-follows-published-grid.test.js +89 -89
  403. package/test/run-progress.test.js +65 -65
  404. package/test/same-height-switch.test.js +664 -664
  405. package/test/seek-frees-the-viewers-requests.test.js +217 -217
  406. package/test/seek-landing.test.js +109 -109
  407. package/test/seek-target-not-superseded.test.js +195 -195
  408. package/test/segment-closed-on-both-branches.test.js +96 -96
  409. package/test/segment-cut-times.test.js +63 -63
  410. package/test/segment-demand.test.js +80 -80
  411. package/test/segment-hold-supersede.test.js +91 -91
  412. package/test/segment-judged-by-its-own-header.test.js +221 -221
  413. package/test/segment-serve-wiring.test.js +475 -475
  414. package/test/segment-store-eviction.test.js +247 -247
  415. package/test/segment-store.test.js +323 -323
  416. package/test/segment-true-start.test.js +86 -86
  417. package/test/segments-are-shared.test.js +176 -176
  418. package/test/send-chunk-bytes.test.js +86 -86
  419. package/test/serving-existing-output.test.js +102 -102
  420. package/test/serving-output.test.js +102 -102
  421. package/test/shared-piece-store.test.js +310 -310
  422. package/test/sidecar-naming.test.js +142 -142
  423. package/test/source-file.test.js +133 -133
  424. package/test/source-files-route.test.js +12 -2
  425. package/test/source-registry.test.js +18 -18
  426. package/test/split-a-stretch.test.js +139 -139
  427. package/test/stale-request-after-seek.test.js +158 -158
  428. package/test/startup-readings.test.js +124 -124
  429. package/test/stream-route.test.js +209 -209
  430. package/test/subtitle-cue-framing.test.js +200 -200
  431. package/test/subtitle-cue-source.test.js +102 -102
  432. package/test/subtitle-cue-walk.test.js +370 -370
  433. package/test/subtitle-cursor.test.js +63 -63
  434. package/test/subtitle-defaults.test.js +118 -118
  435. package/test/subtitle-language.test.js +252 -252
  436. package/test/subtitle-track-numbering.test.js +386 -386
  437. package/test/supply-margin.test.js +177 -177
  438. package/test/swarm-follows-readers.test.js +174 -174
  439. package/test/swarm-reach.test.js +130 -130
  440. package/test/swarm-selection.test.js +220 -220
  441. package/test/tail-duplication.test.js +167 -167
  442. package/test/the-channel-has-both-ends.test.js +91 -91
  443. package/test/the-torrent-thread-serves-bytes.test.js +107 -107
  444. package/test/thread-pool.test.js +53 -53
  445. package/test/timeline.test.js +84 -84
  446. package/test/torrent-contents.test.js +235 -235
  447. package/test/torrent-cost.test.js +71 -71
  448. package/test/torrent-files.test.js +221 -221
  449. package/test/torrent-naming.test.js +255 -255
  450. package/test/tracks-begin-together.test.js +209 -209
  451. package/test/tunnel-renewal.test.js +167 -167
  452. package/test/two-viewers-one-picture.test.js +407 -407
  453. package/test/upload-hurry.test.js +186 -186
  454. package/test/usrsctp-state.test.js +131 -131
  455. package/test/video-facts.test.js +102 -102
  456. package/test/viewer-assignments.test.js +143 -143
  457. package/test/viewer-outputs.test.js +285 -285
  458. package/test/viewer-presence.test.js +116 -116
  459. package/test/viewer-requests-admission.test.js +89 -89
  460. package/test/viewer-states.test.js +189 -189
  461. package/test/viewer-subtitle-subscription.test.js +96 -96
  462. package/test/viewer.test.js +139 -139
  463. package/test/viewing-generations.test.js +390 -390
  464. package/test/visible-rung.test.js +37 -37
  465. package/test/wait-ledger.test.js +100 -100
  466. package/test/webrtc-shim.test.js +149 -149
  467. package/test/wedge-certainty.test.js +131 -131
  468. package/test/withdraw-piece-claim.test.js +212 -212
  469. package/test/worker-channel.test.js +158 -158
  470. package/utils/client-log-file.js +151 -151
  471. package/utils/logger.js +312 -312
  472. package/utils/parse-range.js +25 -25
  473. package/utils/perf.js +59 -59
  474. package/utils/torrent-source-key.js +38 -38
@@ -1,1494 +1,1494 @@
1
- /**
2
- * @file Matroska/WebM container — RFC 9559.
3
- *
4
- * Reads Tracks in one pass for all media types (video, audio, subtitle).
5
- * Implements spec-accurate flag handling:
6
- * - FlagEnabled 0xB9 default 1, zero-length element = default (not disabled)
7
- * - FlagDefault 0x88 default 1, declaresDefault tracks whether element was written
8
- * - FlagForced 0x55AA only for subtitles, FlagHearingImpaired 0x55AB, FlagVisualImpaired 0x55AC,
9
- * FlagTextDescriptions 0x55AD, FlagOriginal 0x55AE, FlagCommentary 0x55AF
10
- * - Language 0x22B59C default "eng", LanguageBCP47 0x22B59D MUST — when present, Language ignored
11
- * - CodecID 0x86, CodecPrivate 0x63A2, Name 0x536E, TrackType 0x83 (1 video, 2 audio, 17 subtitle)
12
- *
13
- * Byte-level EBML element walking is `ebml-reader.js`, which is the format's
14
- * grammar and not this container's own statements. Everything Matroska DOES
15
- * state about itself — the Tracks walk, the Cues, the keyframe times they name,
16
- * the cluster positions, the blocks inside a cluster —
17
- * is read in this module: each of those is RFC 9559 speaking about Matroska,
18
- * and the class is the only way in.
19
- */
20
-
21
- import { Container } from "./Container.js";
22
- import { VideoTrack } from "../tracks/VideoTrack.js";
23
- import { AudioTrack } from "../tracks/AudioTrack.js";
24
- import { TextSubtitleTrack, TEXT_CODECS_MATROSKA } from "../tracks/TextSubtitleTrack.js";
25
- import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
26
- import { ContainerTrack } from "../tracks/ContainerTrack.js";
27
- import { findElement, iterateElements, readFloat, readUint, readVint } from "./ebml-reader.js";
28
-
29
- const HEAD_BYTES = 64 * 1024;
30
- /** Enough to read any cluster's own element header. */
31
- const CLUSTER_HEADER_PROBE = 64;
32
- /**
33
- * The largest cluster this will read whole. Real muxers write clusters of a few
34
- * megabytes; anything past this is not a cluster boundary we recognised and
35
- * reading it would be a large read for nothing.
36
- */
37
- const MAX_CLUSTER_BYTES = 32 * 1024 * 1024;
38
-
39
- const ID_SEGMENT = 0x18538067;
40
- const ID_SEEK_HEAD = 0x114d9b74;
41
- const ID_SEEK = 0x4dbb;
42
- const ID_SEEK_ID = 0x53ab;
43
- const ID_SEEK_POSITION = 0x53ac;
44
- const ID_INFO = 0x1549a966;
45
- const ID_TIMESTAMP_SCALE = 0x2ad7b1;
46
- const ID_DURATION = 0x4489;
47
- const ID_CLUSTER = 0x1f43b675;
48
- const ID_TIMESTAMP = 0xe7;
49
- const ID_SIMPLE_BLOCK = 0xa3;
50
- const ID_BLOCK_GROUP = 0xa0;
51
- /** RFC 9559 §5.1.2.1: nanoseconds per tick when Info omits TimestampScale. */
52
- const DEFAULT_TIMESTAMP_SCALE = 1_000_000;
53
- /**
54
- * How much to read at a cluster whose position came from the SeekHead. A
55
- * cluster's Timestamp is the first child every muxer writes, so this only has
56
- * to cover the element header and that one field.
57
- */
58
- const CLUSTER_PROBE_BYTES = 4 * 1024;
59
- const ID_TRACKS = 0x1654ae6b;
60
- const ID_TRACK_ENTRY = 0xae;
61
- const ID_TRACK_NUMBER = 0xd7;
62
- const ID_TRACK_TYPE = 0x83;
63
- const ID_FLAG_ENABLED = 0xb9;
64
- const ID_FLAG_DEFAULT = 0x88;
65
- const ID_FLAG_FORCED = 0x55aa;
66
- const ID_FLAG_HEARING = 0x55ab;
67
- const ID_FLAG_VISUAL = 0x55ac;
68
- const ID_FLAG_TEXT_DESCR = 0x55ad;
69
- const ID_FLAG_ORIGINAL = 0x55ae;
70
- const ID_FLAG_COMMENTARY = 0x55af;
71
- const ID_CODEC_ID = 0x86;
72
- const ID_CODEC_PRIVATE = 0x63a2;
73
- const ID_LANGUAGE = 0x22b59c;
74
- const ID_LANGUAGE_BCP47 = 0x22b59d;
75
- const ID_NAME = 0x536e;
76
- const ID_VIDEO = 0xe0;
77
- const ID_AUDIO = 0xe1;
78
- const ID_PIXEL_WIDTH = 0xb0;
79
- const ID_PIXEL_HEIGHT = 0xba;
80
- const ID_DISPLAY_WIDTH = 0x54b0;
81
- const ID_DISPLAY_HEIGHT = 0x54ba;
82
- const ID_SAMPLING_FREQUENCY = 0xb5;
83
- const ID_CHANNELS = 0x9f;
84
- /**
85
- * ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect — the eight
86
- * fields Matroska writes before the text of an SSA/ASS event. See
87
- * {@link MatroskaContainer.cueTextOf} for the quotation this comes from.
88
- */
89
- const ASS_FIELDS_BEFORE_TEXT = 8;
90
-
91
- function readString(buf, el) {
92
- return buf.toString("utf8", el.dataOffset, el.dataOffset + el.size).replace(/\0+$/, "");
93
- }
94
-
95
- export class MatroskaContainer extends Container {
96
- get formatName() {
97
- return "matroska";
98
- }
99
-
100
- static detect(head) {
101
- return isMatroska(head);
102
- }
103
-
104
- /**
105
- * The keyframe times this container's own index states, in ascending seconds.
106
- *
107
- * Static so a caller that has bytes and no container can ask; the instance
108
- * form is {@link Container#readKeyframeIndex}.
109
- *
110
- * @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
111
- * @param {number} fileSize
112
- * @returns {Promise<number[]|null>} Null where the container has no index.
113
- */
114
- static readKeyframeTimes(readRange, fileSize) {
115
- return readMatroskaKeyframeTimes(readRange, fileSize);
116
- }
117
-
118
- /**
119
- * This container's subtitle tracks, its Cues table and the cluster positions
120
- * they name — RFC 9559 §5.1.4 and §5.1.3.
121
- *
122
- * @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
123
- * @param {number} fileSize
124
- * @returns {Promise<object|null>}
125
- */
126
- static readSubtitlePlan(readRange, fileSize) {
127
- return readSubtitlePlan(readRange, fileSize);
128
- }
129
-
130
- /**
131
- * The same reading, over the file this container was built on.
132
- *
133
- * The static form exists for a caller that has bytes and no container; this
134
- * is the one to use otherwise, because the reader is already here.
135
- *
136
- * @returns {Promise<object|null>}
137
- */
138
- readSubtitlePlan() {
139
- return MatroskaContainer.readSubtitlePlan(this.readRange, this.fileSize);
140
- }
141
-
142
- /**
143
- * The blocks one track has inside a cluster, with their times.
144
- *
145
- * The payload is handed back as BYTES: what those bytes mean is
146
- * {@link MatroskaContainer.cueTextOf}'s answer, and this method's subject is
147
- * only where a block sits and how long it lasts.
148
- *
149
- * @param {Buffer} bytes - The cluster, from its own element header onward.
150
- * @param {number} trackNumber
151
- * @param {number} secondsPerTick
152
- * @returns {{ startSeconds: number, endSeconds: number | null, payload: Buffer }[]}
153
- */
154
- static blocksInCluster(bytes, trackNumber, secondsPerTick) {
155
- return harvestCluster(bytes, trackNumber, secondsPerTick);
156
- }
157
-
158
- /**
159
- * The blocks one track has inside a cluster whose bounds are already known —
160
- * RFC 9559 §5.1.3.4 (SimpleBlock) and §5.1.3.5 (BlockGroup).
161
- *
162
- * The same reading as {@link MatroskaContainer.blocksInCluster}, entered where
163
- * the caller has already parsed the cluster's own header.
164
- *
165
- * @param {Buffer} buffer
166
- * @param {{ dataOffset: number, size: number }} cluster
167
- * @param {number} trackNumber
168
- * @param {number} secondsPerTick
169
- * @returns {{ startSeconds: number, durationSeconds: number | null, payload: Buffer }[]}
170
- */
171
- static blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick) {
172
- return blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick);
173
- }
174
-
175
- /**
176
- * Duration and the start of this file's own timeline, per RFC 9559 §5.1.2.
177
- *
178
- * Duration is stated in `Info` as a FLOAT in ticks, so it needs the file's
179
- * `TimestampScale` to become seconds. The start of the timeline is not stated
180
- * anywhere — Matroska has no such element — so it is the timestamp of the
181
- * first Cluster, which is what the first frame is placed against.
182
- *
183
- * @returns {Promise<import("./Container.js").ContainerMediaInfo>}
184
- */
185
- async readMediaInfo() {
186
- if (this.mediaInfo) {
187
- return this.mediaInfo;
188
- }
189
- /** @type {import("./Container.js").ContainerMediaInfo} */
190
- const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: null };
191
- this.mediaInfo = info;
192
- const head = await this.readRange(0, Math.min(HEAD_BYTES - 1, this.fileSize - 1));
193
- if (!head || !isMatroska(head)) {
194
- return info;
195
- }
196
- const segment = findElement(head, ID_SEGMENT, []);
197
- if (!segment) {
198
- return info;
199
- }
200
- const scale = MatroskaContainer.#timestampScaleOf(head, segment.dataOffset);
201
- const infoElement = findElement(head, ID_INFO, [], segment.dataOffset);
202
- if (infoElement) {
203
- const infoEnd = Math.min(head.length, infoElement.dataOffset + infoElement.size);
204
- for (const field of iterateElements(head, infoElement.dataOffset, infoEnd)) {
205
- if (field.id !== ID_DURATION) {
206
- continue;
207
- }
208
- const ticks = readFloat(head, field.dataOffset, field.size);
209
- if (ticks !== null && ticks > 0) {
210
- info.durationSeconds = (ticks * scale) / 1e9;
211
- }
212
- break;
213
- }
214
- }
215
- info.startTimeSeconds = await this.#firstClusterSeconds(head, segment.dataOffset, scale);
216
- return info;
217
- }
218
-
219
- /**
220
- * `TimestampScale` from Info, or the specification's default.
221
- *
222
- * @param {Buffer} head
223
- * @param {number} segmentDataOffset
224
- * @returns {number} Nanoseconds per tick.
225
- */
226
- static #timestampScaleOf(head, segmentDataOffset) {
227
- const infoElement = findElement(head, ID_INFO, [], segmentDataOffset);
228
- if (!infoElement) {
229
- return DEFAULT_TIMESTAMP_SCALE;
230
- }
231
- const infoEnd = Math.min(head.length, infoElement.dataOffset + infoElement.size);
232
- for (const field of iterateElements(head, infoElement.dataOffset, infoEnd)) {
233
- if (field.id === ID_TIMESTAMP_SCALE) {
234
- const scale = readUint(head, field.dataOffset, field.size);
235
- return scale > 0 ? scale : DEFAULT_TIMESTAMP_SCALE;
236
- }
237
- }
238
- return DEFAULT_TIMESTAMP_SCALE;
239
- }
240
-
241
- /**
242
- * The timestamp of the first Cluster, in seconds.
243
- *
244
- * Tried in the head window first, because a muxer writes the first cluster
245
- * straight after Tracks and both usually fit; a file whose Tracks element is
246
- * large enough to push it out is answered from the SeekHead instead, with one
247
- * short read at the position it names.
248
- *
249
- * @param {Buffer} head
250
- * @param {number} segmentDataOffset
251
- * @param {number} scale - Nanoseconds per tick.
252
- * @returns {Promise<number | null>} Null when no cluster could be read.
253
- */
254
- async #firstClusterSeconds(head, segmentDataOffset, scale) {
255
- /**
256
- * @param {Buffer} buffer
257
- * @param {number} dataOffset
258
- * @param {number} end
259
- * @returns {number | null}
260
- */
261
- const timestampIn = (buffer, dataOffset, end) => {
262
- for (const field of iterateElements(buffer, dataOffset, end)) {
263
- if (field.id === ID_TIMESTAMP) {
264
- const ticks = readUint(buffer, field.dataOffset, field.size);
265
- return Number.isFinite(ticks) ? (ticks * scale) / 1e9 : null;
266
- }
267
- // Timestamp is written before any frame. Stopping at the first one keeps
268
- // this from walking a cluster's whole payload, which is megabytes and
269
- // usually not in the buffer at all.
270
- if (field.id === ID_SIMPLE_BLOCK || field.id === ID_BLOCK_GROUP) {
271
- return null;
272
- }
273
- }
274
- return null;
275
- };
276
-
277
- for (const element of iterateElements(head, segmentDataOffset, head.length)) {
278
- if (element.id !== ID_CLUSTER) {
279
- continue;
280
- }
281
- return timestampIn(head, element.dataOffset, Math.min(head.length, element.dataOffset + element.size));
282
- }
283
-
284
- const position = MatroskaContainer.#seekPositionOf(head, segmentDataOffset, ID_CLUSTER);
285
- if (position === null) {
286
- return null;
287
- }
288
- const at = segmentDataOffset + position;
289
- if (at >= this.fileSize) {
290
- return null;
291
- }
292
- const chunk = await this.readRange(at, Math.min(this.fileSize - 1, at + CLUSTER_PROBE_BYTES - 1));
293
- if (!chunk) {
294
- return null;
295
- }
296
- for (const element of iterateElements(chunk, 0, chunk.length)) {
297
- if (element.id !== ID_CLUSTER) {
298
- continue;
299
- }
300
- return timestampIn(chunk, element.dataOffset, Math.min(chunk.length, element.dataOffset + element.size));
301
- }
302
- return null;
303
- }
304
-
305
- /**
306
- * Where the SeekHead says an element lives, relative to the Segment's payload.
307
- *
308
- * @param {Buffer} head
309
- * @param {number} segmentDataOffset
310
- * @param {number} wantedId
311
- * @returns {number | null}
312
- */
313
- static #seekPositionOf(head, segmentDataOffset, wantedId) {
314
- const seekHead = findElement(head, ID_SEEK_HEAD, [], segmentDataOffset);
315
- if (!seekHead) {
316
- return null;
317
- }
318
- const seekHeadEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
319
- for (const seek of iterateElements(head, seekHead.dataOffset, seekHeadEnd)) {
320
- if (seek.id !== ID_SEEK) {
321
- continue;
322
- }
323
- const seekEnd = Math.min(seekHeadEnd, seek.dataOffset + seek.size);
324
- let targetId = null;
325
- let position = null;
326
- for (const field of iterateElements(head, seek.dataOffset, seekEnd)) {
327
- if (field.id === ID_SEEK_ID) {
328
- targetId = readUint(head, field.dataOffset, field.size);
329
- } else if (field.id === ID_SEEK_POSITION) {
330
- position = readUint(head, field.dataOffset, field.size);
331
- }
332
- }
333
- if (targetId === wantedId && position !== null) {
334
- return position;
335
- }
336
- }
337
- return null;
338
- }
339
-
340
-
341
- /**
342
- * Walk the clusters this file's Cues table names and give up the blocks in
343
- * every one that is READABLE now, for every track at once.
344
- *
345
- * One walk for the whole file, not one per track: a Matroska cluster carries
346
- * the blocks of every track that has anything to say over its span, so the
347
- * bytes that answer one track answer them all. Reading them once per track
348
- * meant the same cluster was fetched and parsed as many times as the film has
349
- * subtitle tracks — measured 2026-08-20 on a film with five: five requests
350
- * every fifteen seconds, each costing 0.2-5.2 s, for a few kilobytes of cues.
351
- *
352
- * Nothing is fetched. `isHeld` decides whether a cluster can be read at all,
353
- * and one that is not here yet is left for the next call — turning subtitles
354
- * on must not pull bytes the viewer is not waiting for.
355
- *
356
- * @param {object} plan - From {@link MatroskaContainer.readSubtitlePlan}.
357
- * @param {Set<number>} walked - Cluster positions already read; added to.
358
- * @returns {Promise<Map<number, {startSeconds: number, endSeconds: number|null, text: string}[]>>}
359
- * Track number to the cues found in THIS pass.
360
- */
361
- async readHeldCues(plan, track, progress) {
362
- const found = await this.walkHeldClusters(plan, progress.walked);
363
- return {
364
- found,
365
- // Every track is filled by the same walk, so this is a fact about the
366
- // FILE and reads the same whichever track asked.
367
- covered: progress.walked.size,
368
- indexed: track?.clusterPositions?.length ?? 0
369
- };
370
- }
371
-
372
- async walkHeldClusters(plan, walked) {
373
- /** @type {Map<number, object[]>} */
374
- const found = new Map();
375
- // The union of the tracks' cluster lists: each track's list comes from its
376
- // own Cues entries, so they overlap but do not coincide.
377
- const positions = new Set();
378
- for (const candidate of plan?.tracks ?? []) {
379
- for (const position of candidate.clusterPositions ?? []) {
380
- positions.add(position);
381
- }
382
- }
383
- for (const position of [...positions].sort((left, right) => left - right)) {
384
- if (walked.has(position)) {
385
- continue;
386
- }
387
- // The header first: it says how long the cluster is, and a cluster whose
388
- // bytes are not all here is left for the next time round.
389
- const probeEnd = Math.min(this.fileSize - 1, position + CLUSTER_HEADER_PROBE - 1);
390
- if (!this.isHeld(position, probeEnd)) {
391
- continue;
392
- }
393
- const probe = await this.readHeld(position, probeEnd);
394
- const header = probe && [...iterateElements(probe, 0, probe.length)][0];
395
- if (!header || header.size <= 0 || header.size > MAX_CLUSTER_BYTES) {
396
- walked.add(position); // not a cluster this can read; do not look again
397
- continue;
398
- }
399
- const last = Math.min(this.fileSize - 1, position + header.dataOffset + header.size - 1);
400
- if (!this.isHeld(position, last)) {
401
- continue;
402
- }
403
- const bytes = await this.readHeld(position, last);
404
- if (!bytes) {
405
- continue;
406
- }
407
- walked.add(position);
408
- for (const candidate of plan.tracks) {
409
- const blocks = MatroskaContainer.blocksInCluster(bytes, candidate.trackNumber, plan.secondsPerTick);
410
- if (blocks.length === 0) {
411
- continue;
412
- }
413
- const into = found.get(candidate.trackNumber) ?? [];
414
- for (const block of blocks) {
415
- // The block's bytes become text HERE, where the container that framed
416
- // them is known. A cue kept framed and unframed later cannot be
417
- // unframed at all: nothing downstream knows which container it came
418
- // out of, and guessing from the field count is what showed the
419
- // dialogue row's own fields to the viewer.
420
- into.push({
421
- startSeconds: block.startSeconds,
422
- endSeconds: block.endSeconds,
423
- text: MatroskaContainer.cueTextOf(block.payload, candidate.codecId)
424
- });
425
- }
426
- found.set(candidate.trackNumber, into);
427
- }
428
- }
429
- return found;
430
- }
431
-
432
- async readTracks() {
433
- const head = await this.readRange(0, Math.min(HEAD_BYTES - 1, this.fileSize - 1));
434
- if (!head || !isMatroska(head)) return [];
435
-
436
- // Delegate to subtitle plan reader for subtitle tracks (already handles Flags spec-correct),
437
- // but we need video/audio tracks too. Do a dedicated Tracks walk here for all types,
438
- // then merge subtitle detail (clusterPositions, declaresDefault etc.) from the plan.
439
- const seg = findElement(head, 0x18538067, []);
440
- if (!seg) return [];
441
- const tracksEl = findElement(head, ID_TRACKS, [], seg.dataOffset);
442
- if (!tracksEl) return [];
443
-
444
- const tracksEnd = Math.min(head.length, tracksEl.dataOffset + tracksEl.size);
445
- /** @type {import("../tracks/index.js").ContainerTrack[]} */
446
- const result = [];
447
- // Subtitle declaredIndex is position among subtitle tracks, not global — track per-type counters.
448
- let subtitleDeclaredIndex = -1;
449
- let audioDeclaredIndex = -1;
450
- let videoDeclaredIndex = -1;
451
-
452
- // For subtitle flag enrichment, read the existing plan (it already does Cues walk)
453
- let subtitlePlan = null;
454
- try {
455
- const shim = async (s, e) => this.readRange(s, Math.min(e, this.fileSize - 1));
456
- subtitlePlan = await readSubtitlePlan(shim, this.fileSize);
457
- } catch {
458
- subtitlePlan = null;
459
- }
460
- const declaredByNumber = new Map();
461
- const planTracksByNumber = new Map();
462
- if (subtitlePlan?.declared) {
463
- for (const d of subtitlePlan.declared) declaredByNumber.set(d.trackNumber, d);
464
- }
465
- if (subtitlePlan?.tracks) {
466
- for (const t of subtitlePlan.tracks) planTracksByNumber.set(t.trackNumber, t);
467
- }
468
-
469
- for (const entry of iterateElements(head, tracksEl.dataOffset, tracksEnd)) {
470
- if (entry.id !== ID_TRACK_ENTRY) continue;
471
- const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
472
- let trackNumber = null;
473
- let typeNum = null;
474
- let codecId = "";
475
- let language = "";
476
- let languageBcp47 = "";
477
- let name = "";
478
- let codecPrivateB64 = "";
479
- let isEnabled = true;
480
- let isDefault = true;
481
- let declaresDefault = false;
482
- let isForced = false;
483
- let isHearing = false;
484
- let isVisual = false;
485
- let isOriginal = false;
486
- let isCommentary = false;
487
- let pixelWidth = null;
488
- let pixelHeight = null;
489
- let displayWidth = null;
490
- let displayHeight = null;
491
- let samplingFreq = null;
492
- let channels = null;
493
-
494
- for (const f of iterateElements(head, entry.dataOffset, entryEnd)) {
495
- switch (f.id) {
496
- case ID_TRACK_NUMBER: trackNumber = readUint(head, f.dataOffset, f.size); break;
497
- case ID_TRACK_TYPE: typeNum = readUint(head, f.dataOffset, f.size); break;
498
- case ID_CODEC_ID: codecId = readString(head, f); break;
499
- case ID_CODEC_PRIVATE: codecPrivateB64 = head.toString("base64", f.dataOffset, f.dataOffset + f.size); break;
500
- case ID_LANGUAGE: language = readString(head, f); break;
501
- case ID_LANGUAGE_BCP47: languageBcp47 = readString(head, f); break;
502
- case ID_NAME: name = readString(head, f); break;
503
- case ID_FLAG_ENABLED: isEnabled = f.size === 0 || readUint(head, f.dataOffset, f.size) !== 0; break;
504
- case ID_FLAG_DEFAULT: isDefault = f.size === 0 || readUint(head, f.dataOffset, f.size) === 1; declaresDefault = true; break;
505
- case ID_FLAG_FORCED: isForced = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
506
- case ID_FLAG_HEARING: isHearing = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
507
- case ID_FLAG_VISUAL: isVisual = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
508
- case ID_FLAG_TEXT_DESCR: break;
509
- case ID_FLAG_ORIGINAL: isOriginal = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
510
- case ID_FLAG_COMMENTARY: isCommentary = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
511
- default: break;
512
- }
513
- // Video/Audio sub-elements are nested, not at entry level — read separately below.
514
- }
515
- if (trackNumber === null) continue;
516
-
517
- // Parse Video/Audio sub-elements if present
518
- const videoEl = findElement(head, ID_VIDEO, [], entry.dataOffset, entryEnd);
519
- if (videoEl) {
520
- for (const vf of iterateElements(head, videoEl.dataOffset, Math.min(entryEnd, videoEl.dataOffset + videoEl.size))) {
521
- if (vf.id === ID_PIXEL_WIDTH) pixelWidth = readUint(head, vf.dataOffset, vf.size);
522
- else if (vf.id === ID_PIXEL_HEIGHT) pixelHeight = readUint(head, vf.dataOffset, vf.size);
523
- else if (vf.id === ID_DISPLAY_WIDTH) displayWidth = readUint(head, vf.dataOffset, vf.size);
524
- else if (vf.id === ID_DISPLAY_HEIGHT) displayHeight = readUint(head, vf.dataOffset, vf.size);
525
- }
526
- }
527
- const audioEl = findElement(head, ID_AUDIO, [], entry.dataOffset, entryEnd);
528
- if (audioEl) {
529
- for (const af of iterateElements(head, audioEl.dataOffset, Math.min(entryEnd, audioEl.dataOffset + audioEl.size))) {
530
- if (af.id === ID_SAMPLING_FREQUENCY) {
531
- // SamplingFrequency is float64
532
- if (af.size === 8) samplingFreq = head.readDoubleBE(af.dataOffset);
533
- else samplingFreq = readUint(head, af.dataOffset, af.size);
534
- } else if (af.id === ID_CHANNELS) channels = readUint(head, af.dataOffset, af.size);
535
- }
536
- }
537
-
538
- // RFC 9559 LanguageBCP47 MUST — when present, Language ignored
539
- const resolvedLang = languageBcp47 || language;
540
- const bcpTag = languageBcp47;
541
- const declaresLanguage = resolvedLang.length > 0;
542
-
543
- if (typeNum === 1) {
544
- videoDeclaredIndex += 1;
545
- result.push(new VideoTrack({
546
- trackNumber,
547
- declaredIndex: videoDeclaredIndex,
548
- codecId,
549
- language: resolvedLang,
550
- languageBcp47: bcpTag,
551
- declaresLanguage,
552
- name,
553
- isEnabled,
554
- isDefault,
555
- declaresDefault,
556
- codecPrivateB64,
557
- width: pixelWidth,
558
- height: pixelHeight,
559
- displayWidth,
560
- displayHeight
561
- }));
562
- } else if (typeNum === 2) {
563
- audioDeclaredIndex += 1;
564
- result.push(new AudioTrack({
565
- trackNumber,
566
- declaredIndex: audioDeclaredIndex,
567
- codecId,
568
- language: resolvedLang,
569
- languageBcp47: bcpTag,
570
- declaresLanguage,
571
- name,
572
- isEnabled,
573
- isDefault,
574
- declaresDefault,
575
- codecPrivateB64,
576
- isOriginal,
577
- isCommentary,
578
- isVisualImpaired: isVisual,
579
- channels,
580
- samplingFrequency: samplingFreq
581
- }));
582
- } else if (typeNum === 17) {
583
- subtitleDeclaredIndex += 1;
584
- // Only Forced/Hearing belong to subtitles; Original/Commentary must not leak.
585
- const declared = declaredByNumber.get(trackNumber);
586
- const planTrack = planTracksByNumber.get(trackNumber);
587
- // Prefer plan's flags when available (already spec-correct), else use parsed.
588
- const finalForced = planTrack ? !!planTrack.isForced : isForced;
589
- const finalHearing = planTrack ? !!planTrack.isHearingImpaired : isHearing;
590
- const finalEnabled = declared ? declared.isEnabled !== false : isEnabled;
591
- const finalDefault = declared ? !!declared.isDefault : isDefault;
592
- const finalDeclares = declared ? !!declared.declaresDefault : declaresDefault;
593
- const clusterPositions = planTrack ? planTrack.clusterPositions ?? [] : [];
594
- const isText = TEXT_CODECS_MATROSKA.has(codecId);
595
- // disabled image/text tracks still counted (declaredIndex above) — offerable flag controls visibility
596
- if (isText && finalEnabled) {
597
- result.push(new TextSubtitleTrack({
598
- trackNumber,
599
- declaredIndex: subtitleDeclaredIndex,
600
- codecId,
601
- language: resolvedLang,
602
- languageBcp47: bcpTag,
603
- declaresLanguage,
604
- name,
605
- isEnabled: finalEnabled,
606
- isDefault: finalDefault,
607
- declaresDefault: finalDeclares,
608
- codecPrivateB64,
609
- isForced: finalForced,
610
- isHearingImpaired: finalHearing,
611
- clusterPositions
612
- }));
613
- } else {
614
- // Image or disabled — keep declaredIndex, not offerable if disabled
615
- const Target = isText ? TextSubtitleTrack : ImageSubtitleTrack;
616
- result.push(new Target({
617
- trackNumber,
618
- declaredIndex: subtitleDeclaredIndex,
619
- codecId,
620
- language: resolvedLang,
621
- languageBcp47: bcpTag,
622
- declaresLanguage,
623
- name,
624
- isEnabled: finalEnabled,
625
- isDefault: finalDefault,
626
- declaresDefault: finalDeclares,
627
- codecPrivateB64,
628
- isForced: finalForced,
629
- isHearingImpaired: finalHearing,
630
- clusterPositions: isText ? clusterPositions : []
631
- }));
632
- }
633
- } else {
634
- // Other TrackType (complex, logo, buttons, control) — keep as generic, not video
635
- result.push(new ContainerTrack({
636
- trackNumber,
637
- declaredIndex: -1,
638
- type: "other",
639
- codecId,
640
- language: resolvedLang,
641
- languageBcp47: bcpTag,
642
- declaresLanguage,
643
- name,
644
- isEnabled,
645
- isDefault,
646
- declaresDefault,
647
- codecPrivateB64
648
- }));
649
- }
650
- }
651
- return result;
652
- }
653
-
654
- /**
655
- * The text field of one cue as Matroska frames it.
656
- *
657
- * Two rules, both from `matroska.org/technical/subtitles.html`, "Now, how are
658
- * they stored in Matroska?":
659
- *
660
- * 1. "All text is converted to UTF-8", so the block is decoded as UTF-8 and
661
- * no other encoding is guessed at. A subtitle FILE is a different matter —
662
- * there the bytes may be Windows-1251 and `decodeSubtitleBytes` sniffs for
663
- * it — but a muxer had to convert before writing the block.
664
- * 2. "Events are stored in the Block in this order: ReadOrder, Layer, Style,
665
- * Name, MarginL, MarginR, MarginV, Effect, Text", and "Start & End field
666
- * are used to set TimeStamp and the BlockDuration element". So eight fields
667
- * stand before the text, the two timing fields of the file's own row are
668
- * NOT among them, and a read order takes their place at the front. The text
669
- * itself may hold commas, so everything from the ninth field on is joined
670
- * back together.
671
- *
672
- * `S_TEXT/UTF8` and `S_TEXT/WEBVTT` have no such framing: the block holds the
673
- * cue text and nothing else. (A WebVTT cue's settings, identifier and
674
- * preceding comments live in a BlockAddition, which this proxy does not read;
675
- * losing them costs positioning, not words.)
676
- *
677
- * @param {Buffer} payload - The block's own bytes.
678
- * @param {string} codecId - Matroska CodecID of the track the block belongs to.
679
- * @returns {string}
680
- */
681
- static cueTextOf(payload, codecId) {
682
- const text = Buffer.isBuffer(payload) ? payload.toString("utf8") : String(payload ?? "");
683
- if (codecId !== "S_TEXT/ASS" && codecId !== "S_TEXT/SSA") {
684
- return text;
685
- }
686
- const fields = text.split(",");
687
- return fields.length > ASS_FIELDS_BEFORE_TEXT ? fields.slice(ASS_FIELDS_BEFORE_TEXT).join(",") : "";
688
- }
689
-
690
- async parseKeyframeIndex() {
691
- const times = await readMatroskaKeyframeTimes(this.readRange, this.fileSize);
692
- if (!times) return null;
693
- if (Array.isArray(times)) return { times, tolerance: 0 };
694
- return times;
695
- }
696
- }
697
-
698
- // ---------------------------------------------------------------------------
699
- // Matroska's own reading of its subtitle tracks and its clusters. It lives in
700
- // this module because every line of it is a statement of RFC 9559 about how
701
- // this container stores a subtitle, and the class is the only way in.
702
- // ---------------------------------------------------------------------------
703
-
704
- const ID_BLOCK = 0xa1;
705
- const ID_BLOCK_DURATION = 0x9b;
706
-
707
- /** Bits 1-2 of the flags byte say how a block is laced, or that it is not. */
708
- const LACING_MASK = 0x06;
709
- const LACING_NONE = 0x00;
710
- const LACING_XIPH = 0x02;
711
- const LACING_FIXED = 0x04;
712
- const LACING_EBML = 0x06;
713
-
714
- /**
715
- * @typedef {object} SubtitleBlock
716
- * @property {number} startSeconds - When the cue appears.
717
- * @property {number | null} durationSeconds - How long it stays, or null when
718
- * the block carried no duration (a SimpleBlock; the caller decides).
719
- * @property {Buffer} payload - The block's own bytes, still in the codec's form.
720
- */
721
-
722
- /**
723
- * Read one block's header.
724
- *
725
- * @param {Buffer} buffer
726
- * @param {number} start - First byte of the block's payload.
727
- * @param {number} end - One past its last byte.
728
- * @returns {{ trackNumber: number, relativeTicks: number, flags: number, dataOffset: number } | null}
729
- */
730
- function readBlockHeader(buffer, start, end) {
731
- const track = readVint(buffer, start, false);
732
- if (!track || track.value === null) {
733
- return null;
734
- }
735
- const timestampAt = start + track.length;
736
- // Signed, and it can be negative: a block may belong slightly before the
737
- // cluster it is stored in.
738
- if (timestampAt + 3 > end) {
739
- return null;
740
- }
741
- return {
742
- trackNumber: Number(track.value),
743
- relativeTicks: buffer.readInt16BE(timestampAt),
744
- flags: buffer[timestampAt + 2],
745
- dataOffset: timestampAt + 3
746
- };
747
- }
748
-
749
- /**
750
- * Where a laced block's first frame begins.
751
- *
752
- * Subtitles are rarely laced, but a block that IS laced starts with a frame
753
- * count and a table of sizes, and reading the payload without stepping over
754
- * them yields the table as though it were text.
755
- *
756
- * @param {Buffer} buffer
757
- * @param {number} dataOffset - First byte after the block header.
758
- * @param {number} end
759
- * @param {number} flags
760
- * @returns {number | null} The offset of the first frame, or null when the
761
- * lacing cannot be read.
762
- */
763
- function firstFrameOffset(buffer, dataOffset, end, flags) {
764
- const lacing = flags & LACING_MASK;
765
- if (lacing === LACING_NONE) {
766
- return dataOffset;
767
- }
768
- if (dataOffset >= end) {
769
- return null;
770
- }
771
- const frames = buffer[dataOffset] + 1;
772
- let at = dataOffset + 1;
773
- if (lacing === LACING_FIXED) {
774
- return at;
775
- }
776
- if (lacing === LACING_XIPH) {
777
- // Each size but the last is a run of 0xFF bytes ending in a smaller one.
778
- for (let frame = 0; frame < frames - 1; frame += 1) {
779
- while (at < end && buffer[at] === 0xff) {
780
- at += 1;
781
- }
782
- at += 1;
783
- }
784
- return at <= end ? at : null;
785
- }
786
- if (lacing === LACING_EBML) {
787
- // The first size is a plain variable-length integer, the rest are signed
788
- // differences from it; either way each is one such integer to step over.
789
- for (let frame = 0; frame < frames - 1; frame += 1) {
790
- const size = readVint(buffer, at, false);
791
- if (!size) {
792
- return null;
793
- }
794
- at += size.length;
795
- }
796
- return at <= end ? at : null;
797
- }
798
- return null;
799
- }
800
-
801
- /**
802
- * Every block of one track inside one cluster.
803
- *
804
- * @param {Buffer} buffer - Bytes holding the cluster's payload.
805
- * @param {{ dataOffset: number, size: number }} cluster - Where that payload is.
806
- * @param {number} trackNumber - The track to keep.
807
- * @param {number} secondsPerTick - From the segment's timestamp scale.
808
- * @returns {SubtitleBlock[]}
809
- */
810
- function blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick) {
811
- const end = Math.min(buffer.length, cluster.dataOffset + cluster.size);
812
- /** @type {SubtitleBlock[]} */
813
- const blocks = [];
814
- let clusterTicks = null;
815
-
816
- const take = (blockStart, blockEnd, durationTicks) => {
817
- const header = readBlockHeader(buffer, blockStart, blockEnd);
818
- if (!header || header.trackNumber !== trackNumber || clusterTicks === null) {
819
- return;
820
- }
821
- const payloadAt = firstFrameOffset(buffer, header.dataOffset, blockEnd, header.flags);
822
- if (payloadAt === null || payloadAt >= blockEnd) {
823
- return;
824
- }
825
- blocks.push({
826
- startSeconds: (clusterTicks + header.relativeTicks) * secondsPerTick,
827
- durationSeconds: durationTicks === null ? null : durationTicks * secondsPerTick,
828
- payload: buffer.subarray(payloadAt, blockEnd)
829
- });
830
- };
831
-
832
- for (const element of iterateElements(buffer, cluster.dataOffset, end)) {
833
- const elementEnd = Math.min(end, element.dataOffset + element.size);
834
- if (element.id === ID_TIMESTAMP) {
835
- clusterTicks = readUint(buffer, element.dataOffset, element.size);
836
- continue;
837
- }
838
- if (element.id === ID_SIMPLE_BLOCK) {
839
- take(element.dataOffset, elementEnd, null);
840
- continue;
841
- }
842
- if (element.id !== ID_BLOCK_GROUP) {
843
- continue;
844
- }
845
- // A group holds the block and, for a subtitle, the duration that says when
846
- // the cue leaves the screen. Both are read before either is used, because
847
- // the duration may be written after the block.
848
- let blockStart = null;
849
- let blockEnd = null;
850
- let durationTicks = null;
851
- for (const field of iterateElements(buffer, element.dataOffset, elementEnd)) {
852
- const fieldEnd = Math.min(elementEnd, field.dataOffset + field.size);
853
- if (field.id === ID_BLOCK) {
854
- blockStart = field.dataOffset;
855
- blockEnd = fieldEnd;
856
- } else if (field.id === ID_BLOCK_DURATION) {
857
- durationTicks = readUint(buffer, field.dataOffset, field.size);
858
- }
859
- }
860
- if (blockStart !== null) {
861
- take(blockStart, blockEnd, durationTicks);
862
- }
863
- }
864
- return blocks;
865
- }
866
-
867
-
868
- /**
869
- * The rest of what a TrackEntry says about itself, RFC 9559 §5.1.4.1. Read
870
- * because the file states them and a releaser's own wording in `Name` is the
871
- * only thing we had before: "fors" and "SDH" in a menu were whatever text
872
- * someone happened to type.
873
- *
874
- * `FlagEnabled` defaults to 1 and means "the track is usable"; a track that
875
- * says 0 is counted but not offered. `FlagForced` applies only to subtitles and
876
- * defaults to 0. `FlagHearingImpaired` is set "if and only if the track is
877
- * suitable for users with hearing impairments". `FlagVisualImpaired`,
878
- * `FlagOriginal` and `FlagCommentary` bear on the AUDIO choice and are read
879
- * with that work, not here — see roadmap item 55.
880
- */
881
- const ID_FLAG_HEARING_IMPAIRED = 0x55ab;
882
- /**
883
- * The language as RFC 5646 writes it. The specification is a MUST: "If this
884
- * element is used, then any Language elements used in the same TrackEntry MUST
885
- * be ignored" — so where both are present, this one is the answer and the
886
- * three-letter code is not.
887
- */
888
- const ID_CUES = 0x1c53bb6b;
889
- const ID_CUE_POINT = 0xbb;
890
- const ID_CUE_TRACK_POSITIONS = 0xb7;
891
- const ID_CUE_TRACK = 0xf7;
892
- const ID_CUE_CLUSTER_POSITION = 0xf1;
893
-
894
- /** TrackType 17 is subtitles; 1 is video and 2 audio. */
895
- const TRACK_TYPE_SUBTITLE = 17;
896
- /** How much of the file start to read: the same window the keyframe reader uses. */
897
- /** Cap on the Cues read; a long film indexes to tens of KB. */
898
- const MAX_CUES_BYTES = 8 * 1024 * 1024;
899
-
900
- /**
901
- * The codecs whose blocks are text this proxy can turn into WebVTT.
902
- *
903
- * `S_TEXT/UTF8` is a plain line of text and needs nothing. `S_TEXT/ASS` and
904
- * `S_TEXT/SSA` carry a dialogue row whose fields have to be stripped, and their
905
- * header lives in CodecPrivate — supported, with the stripping done where the
906
- * cue is turned into WebVTT. `S_HDMV/PGS` and `S_VOBSUB` are pictures, not
907
- * text, and are deliberately absent: offering them would promise something this
908
- * path cannot deliver.
909
- */
910
- const TEXT_CODECS = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA"]);
911
-
912
- /**
913
- * @typedef {object} SubtitleTrackPlan
914
- * @property {number} trackNumber - As the blocks name it.
915
- * @property {number} declaredIndex - Its position among ALL of the file's
916
- * subtitle tracks, picture-based ones included — which is the number ffmpeg
917
- * gives the same stream in `0:s:N`, and therefore the only number the browser
918
- * ever names. Text tracks alone are not a numbering: a file whose PGS track
919
- * comes first would have every text track one lower here than in the browser.
920
- * @property {string} codecId
921
- * @property {string} language - The language the file declares: its RFC 5646
922
- * tag where it writes one, and the three-letter code otherwise. The
923
- * specification requires that order — where `LanguageBCP47` is present, the
924
- * `Language` element MUST be ignored.
925
- * @property {string} languageBcp47 - The RFC 5646 tag alone, or "".
926
- * @property {string} name - What the file calls the track, if anything.
927
- * @property {boolean} isDefault
928
- * @property {boolean} isForced - `FlagForced`: the track carries what a viewer
929
- * needs even when they asked for no subtitles — signs, and dialogue in
930
- * another language. It does NOT carry the film's own dialogue.
931
- * @property {boolean} isHearingImpaired - `FlagHearingImpaired`: suitable for
932
- * viewers who cannot hear, so it carries non-speech sound as well as speech.
933
- * @property {string} codecPrivate - The ASS/SSA header, base64, or "".
934
- * @property {number[]} clusterPositions - File offsets of clusters whose cue
935
- * points name this track, ascending. Empty when the file indexes only its
936
- * picture, and then the caller has to walk clusters as they arrive instead.
937
- */
938
-
939
-
940
- /**
941
- * Everything about a file's text subtitle tracks that can be learned without
942
- * reading the film.
943
- *
944
- * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
945
- * @param {number} fileSize
946
- * @returns {Promise<{ tracks: SubtitleTrackPlan[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
947
- */
948
- async function readSubtitlePlan(readRange, fileSize) {
949
- const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
950
- if (!head || head.length < 4 || head.readUInt32BE(0) !== 0x1a45dfa3) {
951
- return null;
952
- }
953
- const segment = findElement(head, ID_SEGMENT, []);
954
- if (!segment) {
955
- return null;
956
- }
957
- const base = segment.dataOffset;
958
-
959
- const info = findElement(head, ID_INFO, [], base);
960
- let scale = DEFAULT_TIMESTAMP_SCALE;
961
- if (info) {
962
- const declared = findElement(head, ID_TIMESTAMP_SCALE, [], info.dataOffset, info.dataOffset + info.size);
963
- if (declared) {
964
- const value = readUint(head, declared.dataOffset, declared.size);
965
- if (value > 0) {
966
- scale = value;
967
- }
968
- }
969
- }
970
-
971
- const tracksElement = findElement(head, ID_TRACKS, [], base);
972
- if (!tracksElement) {
973
- return null;
974
- }
975
- const tracksEnd = Math.min(head.length, tracksElement.dataOffset + tracksElement.size);
976
- /** @type {SubtitleTrackPlan[]} */
977
- const tracks = [];
978
- /**
979
- * Every subtitle track the file declares, in the order the Tracks element
980
- * names them, text or picture. This is not for extraction — `tracks` is —
981
- * but for lining ffmpeg's `0:s:N` numbering up against the container, which
982
- * only holds while nothing is missing from the middle of the list.
983
- *
984
- * @type {Array<{ trackNumber: number, codecId: string, language: string, name: string, isDefault: boolean, declaresDefault: boolean }>}
985
- */
986
- const declared = [];
987
- for (const entry of iterateElements(head, tracksElement.dataOffset, tracksEnd)) {
988
- if (entry.id !== ID_TRACK_ENTRY) {
989
- continue;
990
- }
991
- const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
992
- let trackNumber = null;
993
- let type = null;
994
- let codecId = "";
995
- let language = "";
996
- let name = "";
997
- let codecPrivate = "";
998
- // Matroska's `FlagDefault` DEFAULTS TO 1, so a file whose muxer wrote it on
999
- // no track is indistinguishable, once the default has been applied, from
1000
- // one that wrote it on every track — which is how ffmpeg's banner prints it
1001
- // and why the banner cannot answer this. Both are kept: what the flag
1002
- // amounts to, and whether the file said anything at all.
1003
- let isDefault = true;
1004
- let declaresDefault = false;
1005
- // Defaults straight from RFC 9559: a track is usable and not forced unless
1006
- // the file says otherwise, and the impaired flags are absent until claimed.
1007
- let isEnabled = true;
1008
- let isForced = false;
1009
- let isHearingImpaired = false;
1010
- let languageBcp47 = "";
1011
- for (const field of iterateElements(head, entry.dataOffset, entryEnd)) {
1012
- if (field.id === ID_TRACK_NUMBER) {
1013
- trackNumber = readUint(head, field.dataOffset, field.size);
1014
- } else if (field.id === ID_TRACK_TYPE) {
1015
- type = readUint(head, field.dataOffset, field.size);
1016
- } else if (field.id === ID_CODEC_ID) {
1017
- codecId = readString(head, field);
1018
- } else if (field.id === ID_LANGUAGE) {
1019
- language = readString(head, field);
1020
- } else if (field.id === ID_LANGUAGE_BCP47) {
1021
- languageBcp47 = readString(head, field);
1022
- } else if (field.id === ID_NAME) {
1023
- name = readString(head, field);
1024
- } else if (field.id === ID_FLAG_DEFAULT) {
1025
- isDefault = readUint(head, field.dataOffset, field.size) === 1;
1026
- declaresDefault = true;
1027
- } else if (field.id === ID_FLAG_ENABLED) {
1028
- // An element written with zero length carries its default, which for
1029
- // this one is 1 — so an empty element must not read as "unusable", and
1030
- // neither must a value outside the declared 0-1 range. Only an explicit
1031
- // zero takes a track away.
1032
- isEnabled = field.size === 0 || readUint(head, field.dataOffset, field.size) !== 0;
1033
- } else if (field.id === ID_FLAG_FORCED) {
1034
- isForced = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
1035
- } else if (field.id === ID_FLAG_HEARING_IMPAIRED) {
1036
- isHearingImpaired = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
1037
- } else if (field.id === ID_CODEC_PRIVATE) {
1038
- codecPrivate = head.toString("base64", field.dataOffset, field.dataOffset + field.size);
1039
- }
1040
- }
1041
- if (type !== TRACK_TYPE_SUBTITLE || trackNumber === null) {
1042
- continue;
1043
- }
1044
- // A track the file marks unusable is still COUNTED. FlagEnabled says "the
1045
- // track is usable", and a player should not offer it — but ffmpeg does not
1046
- // drop it: `matroskadec.c` parses `MATROSKA_ID_TRACKFLAGENABLED` as
1047
- // `EBML_NONE`, reading the element and keeping nothing, so the stream is
1048
- // created and numbered like any other. Leaving it out of this list would
1049
- // therefore shift `declaredIndex` off ffmpeg's `0:s:N` for every track
1050
- // after it, which is the numbering defect this file was fixed for a day
1051
- // earlier. It is counted here and refused where it is offered instead.
1052
- //
1053
- // `language` here stays the three-letter code, because this list exists to
1054
- // be lined up against ffmpeg's banner, which prints that code. The RFC 5646
1055
- // tag rides beside it for whoever displays the track.
1056
- const declaresLanguage = languageBcp47.length > 0 || language.length > 0;
1057
- declared.push({
1058
- trackNumber,
1059
- codecId,
1060
- language,
1061
- languageBcp47,
1062
- declaresLanguage,
1063
- name,
1064
- isDefault,
1065
- declaresDefault,
1066
- isEnabled,
1067
- isForced,
1068
- isHearingImpaired
1069
- });
1070
- if (!TEXT_CODECS.has(codecId) || !isEnabled) {
1071
- continue;
1072
- }
1073
- tracks.push({
1074
- trackNumber,
1075
- declaredIndex: declared.length - 1,
1076
- codecId,
1077
- // This list is ours and is not compared with ffmpeg's, so it carries the
1078
- // language the file states most precisely: where RFC 5646 is written, the
1079
- // three-letter code MUST be ignored.
1080
- language: languageBcp47 || language,
1081
- languageBcp47,
1082
- declaresLanguage,
1083
- name,
1084
- isDefault,
1085
- isForced,
1086
- isHearingImpaired,
1087
- codecPrivate,
1088
- clusterPositions: []
1089
- });
1090
- }
1091
- if (tracks.length === 0) {
1092
- return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
1093
- }
1094
-
1095
- // Where the clusters holding those tracks are. A file that indexes only its
1096
- // picture leaves these empty, which is not a failure: the caller then reads
1097
- // the clusters the viewer's own playback brings in.
1098
- const seekHead = findElement(head, ID_SEEK_HEAD, [], base);
1099
- let cuesRelative;
1100
- if (seekHead) {
1101
- const seekEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
1102
- for (const seek of iterateElements(head, seekHead.dataOffset, seekEnd)) {
1103
- if (seek.id !== ID_SEEK) {
1104
- continue;
1105
- }
1106
- let target = null;
1107
- let position = null;
1108
- for (const field of iterateElements(head, seek.dataOffset, Math.min(seekEnd, seek.dataOffset + seek.size))) {
1109
- if (field.id === ID_SEEK_ID) {
1110
- target = readUint(head, field.dataOffset, field.size);
1111
- } else if (field.id === ID_SEEK_POSITION) {
1112
- position = readUint(head, field.dataOffset, field.size);
1113
- }
1114
- }
1115
- if (target === ID_CUES && position !== null) {
1116
- cuesRelative = position;
1117
- }
1118
- }
1119
- }
1120
- if (cuesRelative !== undefined) {
1121
- const cuesAt = base + cuesRelative;
1122
- if (cuesAt > 0 && cuesAt < fileSize) {
1123
- const chunk = await readRange(cuesAt, Math.min(fileSize - 1, cuesAt + MAX_CUES_BYTES));
1124
- const element = chunk && [...iterateElements(chunk, 0, chunk.length)][0];
1125
- if (element && element.id === ID_CUES) {
1126
- const body = chunk.subarray(element.dataOffset, Math.min(chunk.length, element.dataOffset + element.size));
1127
- const byTrack = new Map(tracks.map((track) => [track.trackNumber, new Set()]));
1128
- for (const point of iterateElements(body, 0, body.length)) {
1129
- if (point.id !== ID_CUE_POINT) {
1130
- continue;
1131
- }
1132
- const pointEnd = Math.min(body.length, point.dataOffset + point.size);
1133
- for (const field of iterateElements(body, point.dataOffset, pointEnd)) {
1134
- if (field.id !== ID_CUE_TRACK_POSITIONS) {
1135
- continue;
1136
- }
1137
- let cueTrack = null;
1138
- let position = null;
1139
- for (const inner of iterateElements(body, field.dataOffset, Math.min(pointEnd, field.dataOffset + field.size))) {
1140
- if (inner.id === ID_CUE_TRACK) {
1141
- cueTrack = readUint(body, inner.dataOffset, inner.size);
1142
- } else if (inner.id === ID_CUE_CLUSTER_POSITION) {
1143
- position = readUint(body, inner.dataOffset, inner.size);
1144
- }
1145
- }
1146
- if (position !== null && byTrack.has(cueTrack)) {
1147
- byTrack.get(cueTrack).add(base + position);
1148
- }
1149
- }
1150
- }
1151
- for (const track of tracks) {
1152
- track.clusterPositions = [...byTrack.get(track.trackNumber)].sort((left, right) => left - right);
1153
- }
1154
- }
1155
- }
1156
- }
1157
- return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
1158
- }
1159
-
1160
- /**
1161
- * The cues of one track inside one cluster.
1162
- *
1163
- * @param {Buffer} bytes - The cluster, from its own element header onward.
1164
- * @param {number} trackNumber
1165
- * @param {number} secondsPerTick
1166
- * @returns {{ startSeconds: number, endSeconds: number | null, text: string }[]}
1167
- */
1168
- function harvestCluster(bytes, trackNumber, secondsPerTick) {
1169
- const header = [...iterateElements(bytes, 0, bytes.length)][0];
1170
- if (!header) {
1171
- return [];
1172
- }
1173
- const blocks = blocksOfTrack(
1174
- bytes,
1175
- { dataOffset: header.dataOffset, size: header.size },
1176
- trackNumber,
1177
- secondsPerTick
1178
- );
1179
- // The payload is handed on as BYTES. What those bytes mean — which of them
1180
- // are the text and which are the eight fields Matroska puts before it — is
1181
- // stated by the container's specification and answered by
1182
- // `MatroskaContainer.cueTextOf`, not here: this function's subject is where a
1183
- // block sits and how long it lasts.
1184
- return blocks.map((block) => ({
1185
- startSeconds: block.startSeconds,
1186
- endSeconds: block.durationSeconds === null ? null : block.startSeconds + block.durationSeconds,
1187
- payload: block.payload
1188
- }));
1189
- }
1190
-
1191
- // ---------------------------------------------------------------------------
1192
- // RFC 9559 speaking about Matroska: the Cues table that says where a keyframe is.
1193
- // Here because the class is the only way in.
1194
- // ---------------------------------------------------------------------------
1195
-
1196
- // Element ids, from the Matroska specification.
1197
- const ID_CUE_TIME = 0xb3;
1198
- // TrackType 1 is video; 2 is audio, 17 subtitles, and the rest are rarer still.
1199
- const TRACK_TYPE_VIDEO = 1;
1200
-
1201
- // How much of the file start to read. Must cover the EBML header, the SeekHead
1202
- // and Info; 64 KB is generous for every real muxer (the file measured needed
1203
- // under 4 KB) while still trivial to fetch.
1204
- // Cap on the Cues read. A two-hour film indexes to tens of KB; anything beyond
1205
- // this is not a normal index and not worth pulling over a torrent.
1206
- // Cap on a Tracks read, for the rare file whose Tracks element sits outside the
1207
- // head window. Track entries are small, so a file with dozens of them still
1208
- // fits well inside this.
1209
- const MAX_TRACKS_BYTES = 1024 * 1024;
1210
- // Matroska's default timestamp scale (nanoseconds per tick) when Info omits it.
1211
-
1212
- /**
1213
- * Whether this looks like a Matroska file (the EBML magic `0x1A45DFA3`).
1214
- *
1215
- * @param {Buffer} head
1216
- * @returns {boolean}
1217
- */
1218
- function isMatroska(head) {
1219
- return head.length >= 4 && head.readUInt32BE(0) === 0x1a45dfa3;
1220
- }
1221
-
1222
- /**
1223
- * Locate the Segment element and the SeekHead entries inside it.
1224
- *
1225
- * Seek positions are relative to the start of Segment's payload, not to the
1226
- * file, so that base has to come back with them.
1227
- *
1228
- * @param {Buffer} head
1229
- * @returns {{ segmentDataOffset: number, entries: Map<number, number> } | null}
1230
- */
1231
- function readSeekHead(head) {
1232
- let segmentDataOffset = -1;
1233
- for (const element of iterateElements(head)) {
1234
- if (element.id === ID_SEGMENT) {
1235
- segmentDataOffset = element.dataOffset;
1236
- break;
1237
- }
1238
- }
1239
- if (segmentDataOffset < 0) {
1240
- return null;
1241
- }
1242
-
1243
- const seekHead = findElement(head, ID_SEEK_HEAD, [], segmentDataOffset);
1244
- if (!seekHead) {
1245
- return null;
1246
- }
1247
-
1248
- const entries = new Map();
1249
- const seekHeadEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
1250
- for (const seek of iterateElements(head, seekHead.dataOffset, seekHeadEnd)) {
1251
- if (seek.id !== ID_SEEK) {
1252
- continue;
1253
- }
1254
- const seekEnd = Math.min(seekHeadEnd, seek.dataOffset + seek.size);
1255
- let targetId = null;
1256
- let position = null;
1257
- for (const field of iterateElements(head, seek.dataOffset, seekEnd)) {
1258
- if (field.id === ID_SEEK_ID) {
1259
- targetId = readUint(head, field.dataOffset, field.size);
1260
- } else if (field.id === ID_SEEK_POSITION) {
1261
- position = readUint(head, field.dataOffset, field.size);
1262
- }
1263
- }
1264
- if (targetId !== null && position !== null) {
1265
- entries.set(targetId, position);
1266
- }
1267
- }
1268
- return { segmentDataOffset, entries };
1269
- }
1270
-
1271
- /**
1272
- * Timestamp scale (nanoseconds per tick) declared in Info, or the default.
1273
- *
1274
- * @param {Buffer} head
1275
- * @param {number} segmentDataOffset
1276
- * @returns {number}
1277
- */
1278
- function readTimestampScale(head, segmentDataOffset) {
1279
- const info = findElement(head, ID_INFO, [], segmentDataOffset);
1280
- if (!info) {
1281
- return DEFAULT_TIMESTAMP_SCALE;
1282
- }
1283
- const infoEnd = Math.min(head.length, info.dataOffset + info.size);
1284
- for (const field of iterateElements(head, info.dataOffset, infoEnd)) {
1285
- if (field.id === ID_TIMESTAMP_SCALE) {
1286
- const scale = readUint(head, field.dataOffset, field.size);
1287
- return scale > 0 ? scale : DEFAULT_TIMESTAMP_SCALE;
1288
- }
1289
- }
1290
- return DEFAULT_TIMESTAMP_SCALE;
1291
- }
1292
-
1293
- /**
1294
- * The number of the first video track, from a Tracks payload.
1295
- *
1296
- * The FIRST one, because that is the track ffmpeg is told to copy (`0:v:0`).
1297
- *
1298
- * @param {Buffer} buffer
1299
- * @param {{ dataOffset: number, size: number }} tracks
1300
- * @returns {number | null}
1301
- */
1302
- function readVideoTrackNumber(buffer, tracks) {
1303
- const tracksEnd = Math.min(buffer.length, tracks.dataOffset + tracks.size);
1304
- for (const entry of iterateElements(buffer, tracks.dataOffset, tracksEnd)) {
1305
- if (entry.id !== ID_TRACK_ENTRY) {
1306
- continue;
1307
- }
1308
- const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
1309
- let number = null;
1310
- let type = null;
1311
- for (const field of iterateElements(buffer, entry.dataOffset, entryEnd)) {
1312
- if (field.id === ID_TRACK_NUMBER) {
1313
- number = readUint(buffer, field.dataOffset, field.size);
1314
- } else if (field.id === ID_TRACK_TYPE) {
1315
- type = readUint(buffer, field.dataOffset, field.size);
1316
- }
1317
- }
1318
- if (number !== null && type === TRACK_TYPE_VIDEO) {
1319
- return number;
1320
- }
1321
- }
1322
- return null;
1323
- }
1324
-
1325
- /**
1326
- * Cue times (seconds, ascending) of ONE track, from a Cues payload.
1327
- *
1328
- * The track is the whole point, and leaving it out is what this reader got
1329
- * wrong until 2026-08-18. A CuePoint belongs to the track named inside its
1330
- * CueTrackPositions, and a muxer indexes whatever tracks it likes: RFC 9559
1331
- * says each keyframe of a video track SHOULD be referenced, and that the Cues
1332
- * Element "can be used to index every single timestamp of every Block or they
1333
- * can be indexed selectively". Both field files index their SUBTITLE tracks as
1334
- * well — `Minions.and.Monsters.1080p.mkv` has 2778 video entries every 2.002 s
1335
- * plus 4669 across four subtitle tracks; `Moana.2 … MegaPeer.mkv` has 1055
1336
- * video entries plus 5007 across five.
1337
- *
1338
- * Read without the track, those extra times enter the cut list as though they
1339
- * were keyframes. ffmpeg can only cut a COPIED picture at a real keyframe at or
1340
- * after the time it is given, so every cut asked for at one of them lands late
1341
- * — which is exactly what the field measured: on the first file every deviation
1342
- * was 2.002 s, that file's own keyframe spacing, and on the second the median
1343
- * was 6.3 s with a worst case of 21 s. Never once negative.
1344
- *
1345
- * @param {Buffer} cues
1346
- * @param {number} timestampScale - Nanoseconds per tick.
1347
- * @param {number | null} trackNumber - Null keeps every entry, which is right
1348
- * only for a file that indexes one track.
1349
- * @returns {number[]}
1350
- */
1351
- function readCueTimes(cues, timestampScale, trackNumber) {
1352
- const times = [];
1353
- const secondsPerTick = timestampScale / 1e9;
1354
- for (const point of iterateElements(cues)) {
1355
- if (point.id !== ID_CUE_POINT) {
1356
- continue;
1357
- }
1358
- const pointEnd = Math.min(cues.length, point.dataOffset + point.size);
1359
- let time = null;
1360
- let belongsToTrack = trackNumber === null;
1361
- for (const field of iterateElements(cues, point.dataOffset, pointEnd)) {
1362
- if (field.id === ID_CUE_TIME) {
1363
- time = readUint(cues, field.dataOffset, field.size) * secondsPerTick;
1364
- continue;
1365
- }
1366
- if (field.id !== ID_CUE_TRACK_POSITIONS || belongsToTrack) {
1367
- continue;
1368
- }
1369
- const positionsEnd = Math.min(pointEnd, field.dataOffset + field.size);
1370
- for (const inner of iterateElements(cues, field.dataOffset, positionsEnd)) {
1371
- if (inner.id === ID_CUE_TRACK && readUint(cues, inner.dataOffset, inner.size) === trackNumber) {
1372
- belongsToTrack = true;
1373
- break;
1374
- }
1375
- }
1376
- }
1377
- if (time !== null && belongsToTrack) {
1378
- times.push(time);
1379
- }
1380
- }
1381
- times.sort((left, right) => left - right);
1382
- return times;
1383
- }
1384
-
1385
- /**
1386
- * Read the keyframe times of a Matroska file using only two point reads.
1387
- *
1388
- * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
1389
- * Inclusive byte range reader; returns null when the range is unavailable.
1390
- * @param {number} fileSize
1391
- * @returns {Promise<number[] | null>} Ascending seconds, or null when the file
1392
- * carries no usable index (see the module doc for when that happens).
1393
- */
1394
- async function readMatroskaKeyframeTimes(readRange, fileSize) {
1395
- const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
1396
- if (!head || !isMatroska(head)) {
1397
- return null;
1398
- }
1399
-
1400
- const seekHead = readSeekHead(head);
1401
- if (!seekHead) {
1402
- return null; // No SeekHead — a streamed or truncated mux.
1403
- }
1404
-
1405
- const cuesRelative = seekHead.entries.get(ID_CUES);
1406
- if (cuesRelative === undefined) {
1407
- return null; // Indexless file: live capture, interrupted write, damaged upload.
1408
- }
1409
-
1410
- // SeekHead positions are relative to Segment's payload.
1411
- const cuesOffset = seekHead.segmentDataOffset + cuesRelative;
1412
- if (!Number.isFinite(cuesOffset) || cuesOffset <= 0 || cuesOffset >= fileSize) {
1413
- return null;
1414
- }
1415
-
1416
- // The element header states the payload size, but reading it costs a round
1417
- // trip; fetch a bounded window instead and let the parser stop at the end of
1418
- // what it got. Cues sits near the file end, so the window is clamped there.
1419
- const cuesEnd = Math.min(fileSize - 1, cuesOffset + MAX_CUES_BYTES);
1420
- const cuesChunk = await readRange(cuesOffset, cuesEnd);
1421
- if (!cuesChunk || cuesChunk.length === 0) {
1422
- return null;
1423
- }
1424
-
1425
- // The window starts exactly at the Cues element, so its own header comes
1426
- // first; step over it to reach the CuePoints.
1427
- const cuesElement = [...iterateElements(cuesChunk, 0, cuesChunk.length)][0];
1428
- if (!cuesElement || cuesElement.id !== ID_CUES) {
1429
- return null;
1430
- }
1431
- const payloadEnd = Math.min(cuesChunk.length, cuesElement.dataOffset + cuesElement.size);
1432
- const payload = cuesChunk.subarray(cuesElement.dataOffset, payloadEnd);
1433
-
1434
- // Whose entries to keep. Tracks sits near the head and is normally inside the
1435
- // bytes already fetched; when it is not, SeekHead says where it is and one
1436
- // more short read gets it. Nothing is fetched twice and nothing is scanned.
1437
- const videoTrack = await readVideoTrack(readRange, head, seekHead, fileSize);
1438
- const timestampScale = readTimestampScale(head, seekHead.segmentDataOffset);
1439
- const times = readCueTimes(payload, timestampScale, videoTrack);
1440
- if (times.length > 0) {
1441
- return times;
1442
- }
1443
- if (videoTrack === null) {
1444
- return null;
1445
- }
1446
- // The filter left nothing, and that is not an answer about the file: a table
1447
- // exists, and this reader simply failed to recognise which of its entries
1448
- // belong to the picture — a track numbered one way in Tracks and another in
1449
- // the cue points, or an entry with no CueTrack at all. Returning null here
1450
- // would put an EVEN grid on a copied picture, which is the failure this
1451
- // module exists to prevent, so the unfiltered table is used instead: less
1452
- // exact than the picture's own keyframes, better than a grid that has nothing
1453
- // to do with the file.
1454
- const unfiltered = readCueTimes(payload, timestampScale, null);
1455
- return unfiltered.length > 0 ? unfiltered : null;
1456
- }
1457
-
1458
- /**
1459
- * The video track's number — from the head when it is there, and from one extra
1460
- * short read when it is not.
1461
- *
1462
- * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
1463
- * @param {Buffer} head
1464
- * @param {{ segmentDataOffset: number, entries: Map<number, number> }} seekHead
1465
- * @param {number} fileSize
1466
- * @returns {Promise<number | null>} Null when Tracks cannot be read at all, and
1467
- * then every cue entry is kept — right for a file that indexes only its
1468
- * picture, wrong for one that does not, and nothing here can tell them apart.
1469
- * Refusing the index instead would put an even grid on a copied picture,
1470
- * which is the failure this reader exists to prevent.
1471
- */
1472
- async function readVideoTrack(readRange, head, seekHead, fileSize) {
1473
- const inHead = findElement(head, ID_TRACKS, [], seekHead.segmentDataOffset);
1474
- if (inHead && inHead.dataOffset + inHead.size <= head.length) {
1475
- return readVideoTrackNumber(head, inHead);
1476
- }
1477
- const relative = seekHead.entries.get(ID_TRACKS);
1478
- if (relative === undefined) {
1479
- return null;
1480
- }
1481
- const offset = seekHead.segmentDataOffset + relative;
1482
- if (!Number.isFinite(offset) || offset <= 0 || offset >= fileSize) {
1483
- return null;
1484
- }
1485
- const chunk = await readRange(offset, Math.min(fileSize - 1, offset + MAX_TRACKS_BYTES));
1486
- if (!chunk || chunk.length === 0) {
1487
- return null;
1488
- }
1489
- const element = [...iterateElements(chunk, 0, chunk.length)][0];
1490
- if (!element || element.id !== ID_TRACKS) {
1491
- return null;
1492
- }
1493
- return readVideoTrackNumber(chunk, { dataOffset: element.dataOffset, size: element.size });
1494
- }
1
+ /**
2
+ * @file Matroska/WebM container — RFC 9559.
3
+ *
4
+ * Reads Tracks in one pass for all media types (video, audio, subtitle).
5
+ * Implements spec-accurate flag handling:
6
+ * - FlagEnabled 0xB9 default 1, zero-length element = default (not disabled)
7
+ * - FlagDefault 0x88 default 1, declaresDefault tracks whether element was written
8
+ * - FlagForced 0x55AA only for subtitles, FlagHearingImpaired 0x55AB, FlagVisualImpaired 0x55AC,
9
+ * FlagTextDescriptions 0x55AD, FlagOriginal 0x55AE, FlagCommentary 0x55AF
10
+ * - Language 0x22B59C default "eng", LanguageBCP47 0x22B59D MUST — when present, Language ignored
11
+ * - CodecID 0x86, CodecPrivate 0x63A2, Name 0x536E, TrackType 0x83 (1 video, 2 audio, 17 subtitle)
12
+ *
13
+ * Byte-level EBML element walking is `ebml-reader.js`, which is the format's
14
+ * grammar and not this container's own statements. Everything Matroska DOES
15
+ * state about itself — the Tracks walk, the Cues, the keyframe times they name,
16
+ * the cluster positions, the blocks inside a cluster —
17
+ * is read in this module: each of those is RFC 9559 speaking about Matroska,
18
+ * and the class is the only way in.
19
+ */
20
+
21
+ import { Container } from "./Container.js";
22
+ import { VideoTrack } from "../tracks/VideoTrack.js";
23
+ import { AudioTrack } from "../tracks/AudioTrack.js";
24
+ import { TextSubtitleTrack, TEXT_CODECS_MATROSKA } from "../tracks/TextSubtitleTrack.js";
25
+ import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
26
+ import { ContainerTrack } from "../tracks/ContainerTrack.js";
27
+ import { findElement, iterateElements, readFloat, readUint, readVint } from "./ebml-reader.js";
28
+
29
+ const HEAD_BYTES = 64 * 1024;
30
+ /** Enough to read any cluster's own element header. */
31
+ const CLUSTER_HEADER_PROBE = 64;
32
+ /**
33
+ * The largest cluster this will read whole. Real muxers write clusters of a few
34
+ * megabytes; anything past this is not a cluster boundary we recognised and
35
+ * reading it would be a large read for nothing.
36
+ */
37
+ const MAX_CLUSTER_BYTES = 32 * 1024 * 1024;
38
+
39
+ const ID_SEGMENT = 0x18538067;
40
+ const ID_SEEK_HEAD = 0x114d9b74;
41
+ const ID_SEEK = 0x4dbb;
42
+ const ID_SEEK_ID = 0x53ab;
43
+ const ID_SEEK_POSITION = 0x53ac;
44
+ const ID_INFO = 0x1549a966;
45
+ const ID_TIMESTAMP_SCALE = 0x2ad7b1;
46
+ const ID_DURATION = 0x4489;
47
+ const ID_CLUSTER = 0x1f43b675;
48
+ const ID_TIMESTAMP = 0xe7;
49
+ const ID_SIMPLE_BLOCK = 0xa3;
50
+ const ID_BLOCK_GROUP = 0xa0;
51
+ /** RFC 9559 §5.1.2.1: nanoseconds per tick when Info omits TimestampScale. */
52
+ const DEFAULT_TIMESTAMP_SCALE = 1_000_000;
53
+ /**
54
+ * How much to read at a cluster whose position came from the SeekHead. A
55
+ * cluster's Timestamp is the first child every muxer writes, so this only has
56
+ * to cover the element header and that one field.
57
+ */
58
+ const CLUSTER_PROBE_BYTES = 4 * 1024;
59
+ const ID_TRACKS = 0x1654ae6b;
60
+ const ID_TRACK_ENTRY = 0xae;
61
+ const ID_TRACK_NUMBER = 0xd7;
62
+ const ID_TRACK_TYPE = 0x83;
63
+ const ID_FLAG_ENABLED = 0xb9;
64
+ const ID_FLAG_DEFAULT = 0x88;
65
+ const ID_FLAG_FORCED = 0x55aa;
66
+ const ID_FLAG_HEARING = 0x55ab;
67
+ const ID_FLAG_VISUAL = 0x55ac;
68
+ const ID_FLAG_TEXT_DESCR = 0x55ad;
69
+ const ID_FLAG_ORIGINAL = 0x55ae;
70
+ const ID_FLAG_COMMENTARY = 0x55af;
71
+ const ID_CODEC_ID = 0x86;
72
+ const ID_CODEC_PRIVATE = 0x63a2;
73
+ const ID_LANGUAGE = 0x22b59c;
74
+ const ID_LANGUAGE_BCP47 = 0x22b59d;
75
+ const ID_NAME = 0x536e;
76
+ const ID_VIDEO = 0xe0;
77
+ const ID_AUDIO = 0xe1;
78
+ const ID_PIXEL_WIDTH = 0xb0;
79
+ const ID_PIXEL_HEIGHT = 0xba;
80
+ const ID_DISPLAY_WIDTH = 0x54b0;
81
+ const ID_DISPLAY_HEIGHT = 0x54ba;
82
+ const ID_SAMPLING_FREQUENCY = 0xb5;
83
+ const ID_CHANNELS = 0x9f;
84
+ /**
85
+ * ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect — the eight
86
+ * fields Matroska writes before the text of an SSA/ASS event. See
87
+ * {@link MatroskaContainer.cueTextOf} for the quotation this comes from.
88
+ */
89
+ const ASS_FIELDS_BEFORE_TEXT = 8;
90
+
91
+ function readString(buf, el) {
92
+ return buf.toString("utf8", el.dataOffset, el.dataOffset + el.size).replace(/\0+$/, "");
93
+ }
94
+
95
+ export class MatroskaContainer extends Container {
96
+ get formatName() {
97
+ return "matroska";
98
+ }
99
+
100
+ static detect(head) {
101
+ return isMatroska(head);
102
+ }
103
+
104
+ /**
105
+ * The keyframe times this container's own index states, in ascending seconds.
106
+ *
107
+ * Static so a caller that has bytes and no container can ask; the instance
108
+ * form is {@link Container#readKeyframeIndex}.
109
+ *
110
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
111
+ * @param {number} fileSize
112
+ * @returns {Promise<number[]|null>} Null where the container has no index.
113
+ */
114
+ static readKeyframeTimes(readRange, fileSize) {
115
+ return readMatroskaKeyframeTimes(readRange, fileSize);
116
+ }
117
+
118
+ /**
119
+ * This container's subtitle tracks, its Cues table and the cluster positions
120
+ * they name — RFC 9559 §5.1.4 and §5.1.3.
121
+ *
122
+ * @param {(start:number,end:number)=>Promise<Buffer|null>} readRange
123
+ * @param {number} fileSize
124
+ * @returns {Promise<object|null>}
125
+ */
126
+ static readSubtitlePlan(readRange, fileSize) {
127
+ return readSubtitlePlan(readRange, fileSize);
128
+ }
129
+
130
+ /**
131
+ * The same reading, over the file this container was built on.
132
+ *
133
+ * The static form exists for a caller that has bytes and no container; this
134
+ * is the one to use otherwise, because the reader is already here.
135
+ *
136
+ * @returns {Promise<object|null>}
137
+ */
138
+ readSubtitlePlan() {
139
+ return MatroskaContainer.readSubtitlePlan(this.readRange, this.fileSize);
140
+ }
141
+
142
+ /**
143
+ * The blocks one track has inside a cluster, with their times.
144
+ *
145
+ * The payload is handed back as BYTES: what those bytes mean is
146
+ * {@link MatroskaContainer.cueTextOf}'s answer, and this method's subject is
147
+ * only where a block sits and how long it lasts.
148
+ *
149
+ * @param {Buffer} bytes - The cluster, from its own element header onward.
150
+ * @param {number} trackNumber
151
+ * @param {number} secondsPerTick
152
+ * @returns {{ startSeconds: number, endSeconds: number | null, payload: Buffer }[]}
153
+ */
154
+ static blocksInCluster(bytes, trackNumber, secondsPerTick) {
155
+ return harvestCluster(bytes, trackNumber, secondsPerTick);
156
+ }
157
+
158
+ /**
159
+ * The blocks one track has inside a cluster whose bounds are already known —
160
+ * RFC 9559 §5.1.3.4 (SimpleBlock) and §5.1.3.5 (BlockGroup).
161
+ *
162
+ * The same reading as {@link MatroskaContainer.blocksInCluster}, entered where
163
+ * the caller has already parsed the cluster's own header.
164
+ *
165
+ * @param {Buffer} buffer
166
+ * @param {{ dataOffset: number, size: number }} cluster
167
+ * @param {number} trackNumber
168
+ * @param {number} secondsPerTick
169
+ * @returns {{ startSeconds: number, durationSeconds: number | null, payload: Buffer }[]}
170
+ */
171
+ static blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick) {
172
+ return blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick);
173
+ }
174
+
175
+ /**
176
+ * Duration and the start of this file's own timeline, per RFC 9559 §5.1.2.
177
+ *
178
+ * Duration is stated in `Info` as a FLOAT in ticks, so it needs the file's
179
+ * `TimestampScale` to become seconds. The start of the timeline is not stated
180
+ * anywhere — Matroska has no such element — so it is the timestamp of the
181
+ * first Cluster, which is what the first frame is placed against.
182
+ *
183
+ * @returns {Promise<import("./Container.js").ContainerMediaInfo>}
184
+ */
185
+ async readMediaInfo() {
186
+ if (this.mediaInfo) {
187
+ return this.mediaInfo;
188
+ }
189
+ /** @type {import("./Container.js").ContainerMediaInfo} */
190
+ const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: null };
191
+ this.mediaInfo = info;
192
+ const head = await this.readRange(0, Math.min(HEAD_BYTES - 1, this.fileSize - 1));
193
+ if (!head || !isMatroska(head)) {
194
+ return info;
195
+ }
196
+ const segment = findElement(head, ID_SEGMENT, []);
197
+ if (!segment) {
198
+ return info;
199
+ }
200
+ const scale = MatroskaContainer.#timestampScaleOf(head, segment.dataOffset);
201
+ const infoElement = findElement(head, ID_INFO, [], segment.dataOffset);
202
+ if (infoElement) {
203
+ const infoEnd = Math.min(head.length, infoElement.dataOffset + infoElement.size);
204
+ for (const field of iterateElements(head, infoElement.dataOffset, infoEnd)) {
205
+ if (field.id !== ID_DURATION) {
206
+ continue;
207
+ }
208
+ const ticks = readFloat(head, field.dataOffset, field.size);
209
+ if (ticks !== null && ticks > 0) {
210
+ info.durationSeconds = (ticks * scale) / 1e9;
211
+ }
212
+ break;
213
+ }
214
+ }
215
+ info.startTimeSeconds = await this.#firstClusterSeconds(head, segment.dataOffset, scale);
216
+ return info;
217
+ }
218
+
219
+ /**
220
+ * `TimestampScale` from Info, or the specification's default.
221
+ *
222
+ * @param {Buffer} head
223
+ * @param {number} segmentDataOffset
224
+ * @returns {number} Nanoseconds per tick.
225
+ */
226
+ static #timestampScaleOf(head, segmentDataOffset) {
227
+ const infoElement = findElement(head, ID_INFO, [], segmentDataOffset);
228
+ if (!infoElement) {
229
+ return DEFAULT_TIMESTAMP_SCALE;
230
+ }
231
+ const infoEnd = Math.min(head.length, infoElement.dataOffset + infoElement.size);
232
+ for (const field of iterateElements(head, infoElement.dataOffset, infoEnd)) {
233
+ if (field.id === ID_TIMESTAMP_SCALE) {
234
+ const scale = readUint(head, field.dataOffset, field.size);
235
+ return scale > 0 ? scale : DEFAULT_TIMESTAMP_SCALE;
236
+ }
237
+ }
238
+ return DEFAULT_TIMESTAMP_SCALE;
239
+ }
240
+
241
+ /**
242
+ * The timestamp of the first Cluster, in seconds.
243
+ *
244
+ * Tried in the head window first, because a muxer writes the first cluster
245
+ * straight after Tracks and both usually fit; a file whose Tracks element is
246
+ * large enough to push it out is answered from the SeekHead instead, with one
247
+ * short read at the position it names.
248
+ *
249
+ * @param {Buffer} head
250
+ * @param {number} segmentDataOffset
251
+ * @param {number} scale - Nanoseconds per tick.
252
+ * @returns {Promise<number | null>} Null when no cluster could be read.
253
+ */
254
+ async #firstClusterSeconds(head, segmentDataOffset, scale) {
255
+ /**
256
+ * @param {Buffer} buffer
257
+ * @param {number} dataOffset
258
+ * @param {number} end
259
+ * @returns {number | null}
260
+ */
261
+ const timestampIn = (buffer, dataOffset, end) => {
262
+ for (const field of iterateElements(buffer, dataOffset, end)) {
263
+ if (field.id === ID_TIMESTAMP) {
264
+ const ticks = readUint(buffer, field.dataOffset, field.size);
265
+ return Number.isFinite(ticks) ? (ticks * scale) / 1e9 : null;
266
+ }
267
+ // Timestamp is written before any frame. Stopping at the first one keeps
268
+ // this from walking a cluster's whole payload, which is megabytes and
269
+ // usually not in the buffer at all.
270
+ if (field.id === ID_SIMPLE_BLOCK || field.id === ID_BLOCK_GROUP) {
271
+ return null;
272
+ }
273
+ }
274
+ return null;
275
+ };
276
+
277
+ for (const element of iterateElements(head, segmentDataOffset, head.length)) {
278
+ if (element.id !== ID_CLUSTER) {
279
+ continue;
280
+ }
281
+ return timestampIn(head, element.dataOffset, Math.min(head.length, element.dataOffset + element.size));
282
+ }
283
+
284
+ const position = MatroskaContainer.#seekPositionOf(head, segmentDataOffset, ID_CLUSTER);
285
+ if (position === null) {
286
+ return null;
287
+ }
288
+ const at = segmentDataOffset + position;
289
+ if (at >= this.fileSize) {
290
+ return null;
291
+ }
292
+ const chunk = await this.readRange(at, Math.min(this.fileSize - 1, at + CLUSTER_PROBE_BYTES - 1));
293
+ if (!chunk) {
294
+ return null;
295
+ }
296
+ for (const element of iterateElements(chunk, 0, chunk.length)) {
297
+ if (element.id !== ID_CLUSTER) {
298
+ continue;
299
+ }
300
+ return timestampIn(chunk, element.dataOffset, Math.min(chunk.length, element.dataOffset + element.size));
301
+ }
302
+ return null;
303
+ }
304
+
305
+ /**
306
+ * Where the SeekHead says an element lives, relative to the Segment's payload.
307
+ *
308
+ * @param {Buffer} head
309
+ * @param {number} segmentDataOffset
310
+ * @param {number} wantedId
311
+ * @returns {number | null}
312
+ */
313
+ static #seekPositionOf(head, segmentDataOffset, wantedId) {
314
+ const seekHead = findElement(head, ID_SEEK_HEAD, [], segmentDataOffset);
315
+ if (!seekHead) {
316
+ return null;
317
+ }
318
+ const seekHeadEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
319
+ for (const seek of iterateElements(head, seekHead.dataOffset, seekHeadEnd)) {
320
+ if (seek.id !== ID_SEEK) {
321
+ continue;
322
+ }
323
+ const seekEnd = Math.min(seekHeadEnd, seek.dataOffset + seek.size);
324
+ let targetId = null;
325
+ let position = null;
326
+ for (const field of iterateElements(head, seek.dataOffset, seekEnd)) {
327
+ if (field.id === ID_SEEK_ID) {
328
+ targetId = readUint(head, field.dataOffset, field.size);
329
+ } else if (field.id === ID_SEEK_POSITION) {
330
+ position = readUint(head, field.dataOffset, field.size);
331
+ }
332
+ }
333
+ if (targetId === wantedId && position !== null) {
334
+ return position;
335
+ }
336
+ }
337
+ return null;
338
+ }
339
+
340
+
341
+ /**
342
+ * Walk the clusters this file's Cues table names and give up the blocks in
343
+ * every one that is READABLE now, for every track at once.
344
+ *
345
+ * One walk for the whole file, not one per track: a Matroska cluster carries
346
+ * the blocks of every track that has anything to say over its span, so the
347
+ * bytes that answer one track answer them all. Reading them once per track
348
+ * meant the same cluster was fetched and parsed as many times as the film has
349
+ * subtitle tracks — measured 2026-08-20 on a film with five: five requests
350
+ * every fifteen seconds, each costing 0.2-5.2 s, for a few kilobytes of cues.
351
+ *
352
+ * Nothing is fetched. `isHeld` decides whether a cluster can be read at all,
353
+ * and one that is not here yet is left for the next call — turning subtitles
354
+ * on must not pull bytes the viewer is not waiting for.
355
+ *
356
+ * @param {object} plan - From {@link MatroskaContainer.readSubtitlePlan}.
357
+ * @param {Set<number>} walked - Cluster positions already read; added to.
358
+ * @returns {Promise<Map<number, {startSeconds: number, endSeconds: number|null, text: string}[]>>}
359
+ * Track number to the cues found in THIS pass.
360
+ */
361
+ async readHeldCues(plan, track, progress) {
362
+ const found = await this.walkHeldClusters(plan, progress.walked);
363
+ return {
364
+ found,
365
+ // Every track is filled by the same walk, so this is a fact about the
366
+ // FILE and reads the same whichever track asked.
367
+ covered: progress.walked.size,
368
+ indexed: track?.clusterPositions?.length ?? 0
369
+ };
370
+ }
371
+
372
+ async walkHeldClusters(plan, walked) {
373
+ /** @type {Map<number, object[]>} */
374
+ const found = new Map();
375
+ // The union of the tracks' cluster lists: each track's list comes from its
376
+ // own Cues entries, so they overlap but do not coincide.
377
+ const positions = new Set();
378
+ for (const candidate of plan?.tracks ?? []) {
379
+ for (const position of candidate.clusterPositions ?? []) {
380
+ positions.add(position);
381
+ }
382
+ }
383
+ for (const position of [...positions].sort((left, right) => left - right)) {
384
+ if (walked.has(position)) {
385
+ continue;
386
+ }
387
+ // The header first: it says how long the cluster is, and a cluster whose
388
+ // bytes are not all here is left for the next time round.
389
+ const probeEnd = Math.min(this.fileSize - 1, position + CLUSTER_HEADER_PROBE - 1);
390
+ if (!this.isHeld(position, probeEnd)) {
391
+ continue;
392
+ }
393
+ const probe = await this.readHeld(position, probeEnd);
394
+ const header = probe && [...iterateElements(probe, 0, probe.length)][0];
395
+ if (!header || header.size <= 0 || header.size > MAX_CLUSTER_BYTES) {
396
+ walked.add(position); // not a cluster this can read; do not look again
397
+ continue;
398
+ }
399
+ const last = Math.min(this.fileSize - 1, position + header.dataOffset + header.size - 1);
400
+ if (!this.isHeld(position, last)) {
401
+ continue;
402
+ }
403
+ const bytes = await this.readHeld(position, last);
404
+ if (!bytes) {
405
+ continue;
406
+ }
407
+ walked.add(position);
408
+ for (const candidate of plan.tracks) {
409
+ const blocks = MatroskaContainer.blocksInCluster(bytes, candidate.trackNumber, plan.secondsPerTick);
410
+ if (blocks.length === 0) {
411
+ continue;
412
+ }
413
+ const into = found.get(candidate.trackNumber) ?? [];
414
+ for (const block of blocks) {
415
+ // The block's bytes become text HERE, where the container that framed
416
+ // them is known. A cue kept framed and unframed later cannot be
417
+ // unframed at all: nothing downstream knows which container it came
418
+ // out of, and guessing from the field count is what showed the
419
+ // dialogue row's own fields to the viewer.
420
+ into.push({
421
+ startSeconds: block.startSeconds,
422
+ endSeconds: block.endSeconds,
423
+ text: MatroskaContainer.cueTextOf(block.payload, candidate.codecId)
424
+ });
425
+ }
426
+ found.set(candidate.trackNumber, into);
427
+ }
428
+ }
429
+ return found;
430
+ }
431
+
432
+ async readTracks() {
433
+ const head = await this.readRange(0, Math.min(HEAD_BYTES - 1, this.fileSize - 1));
434
+ if (!head || !isMatroska(head)) return [];
435
+
436
+ // Delegate to subtitle plan reader for subtitle tracks (already handles Flags spec-correct),
437
+ // but we need video/audio tracks too. Do a dedicated Tracks walk here for all types,
438
+ // then merge subtitle detail (clusterPositions, declaresDefault etc.) from the plan.
439
+ const seg = findElement(head, 0x18538067, []);
440
+ if (!seg) return [];
441
+ const tracksEl = findElement(head, ID_TRACKS, [], seg.dataOffset);
442
+ if (!tracksEl) return [];
443
+
444
+ const tracksEnd = Math.min(head.length, tracksEl.dataOffset + tracksEl.size);
445
+ /** @type {import("../tracks/index.js").ContainerTrack[]} */
446
+ const result = [];
447
+ // Subtitle declaredIndex is position among subtitle tracks, not global — track per-type counters.
448
+ let subtitleDeclaredIndex = -1;
449
+ let audioDeclaredIndex = -1;
450
+ let videoDeclaredIndex = -1;
451
+
452
+ // For subtitle flag enrichment, read the existing plan (it already does Cues walk)
453
+ let subtitlePlan = null;
454
+ try {
455
+ const shim = async (s, e) => this.readRange(s, Math.min(e, this.fileSize - 1));
456
+ subtitlePlan = await readSubtitlePlan(shim, this.fileSize);
457
+ } catch {
458
+ subtitlePlan = null;
459
+ }
460
+ const declaredByNumber = new Map();
461
+ const planTracksByNumber = new Map();
462
+ if (subtitlePlan?.declared) {
463
+ for (const d of subtitlePlan.declared) declaredByNumber.set(d.trackNumber, d);
464
+ }
465
+ if (subtitlePlan?.tracks) {
466
+ for (const t of subtitlePlan.tracks) planTracksByNumber.set(t.trackNumber, t);
467
+ }
468
+
469
+ for (const entry of iterateElements(head, tracksEl.dataOffset, tracksEnd)) {
470
+ if (entry.id !== ID_TRACK_ENTRY) continue;
471
+ const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
472
+ let trackNumber = null;
473
+ let typeNum = null;
474
+ let codecId = "";
475
+ let language = "";
476
+ let languageBcp47 = "";
477
+ let name = "";
478
+ let codecPrivateB64 = "";
479
+ let isEnabled = true;
480
+ let isDefault = true;
481
+ let declaresDefault = false;
482
+ let isForced = false;
483
+ let isHearing = false;
484
+ let isVisual = false;
485
+ let isOriginal = false;
486
+ let isCommentary = false;
487
+ let pixelWidth = null;
488
+ let pixelHeight = null;
489
+ let displayWidth = null;
490
+ let displayHeight = null;
491
+ let samplingFreq = null;
492
+ let channels = null;
493
+
494
+ for (const f of iterateElements(head, entry.dataOffset, entryEnd)) {
495
+ switch (f.id) {
496
+ case ID_TRACK_NUMBER: trackNumber = readUint(head, f.dataOffset, f.size); break;
497
+ case ID_TRACK_TYPE: typeNum = readUint(head, f.dataOffset, f.size); break;
498
+ case ID_CODEC_ID: codecId = readString(head, f); break;
499
+ case ID_CODEC_PRIVATE: codecPrivateB64 = head.toString("base64", f.dataOffset, f.dataOffset + f.size); break;
500
+ case ID_LANGUAGE: language = readString(head, f); break;
501
+ case ID_LANGUAGE_BCP47: languageBcp47 = readString(head, f); break;
502
+ case ID_NAME: name = readString(head, f); break;
503
+ case ID_FLAG_ENABLED: isEnabled = f.size === 0 || readUint(head, f.dataOffset, f.size) !== 0; break;
504
+ case ID_FLAG_DEFAULT: isDefault = f.size === 0 || readUint(head, f.dataOffset, f.size) === 1; declaresDefault = true; break;
505
+ case ID_FLAG_FORCED: isForced = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
506
+ case ID_FLAG_HEARING: isHearing = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
507
+ case ID_FLAG_VISUAL: isVisual = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
508
+ case ID_FLAG_TEXT_DESCR: break;
509
+ case ID_FLAG_ORIGINAL: isOriginal = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
510
+ case ID_FLAG_COMMENTARY: isCommentary = f.size > 0 && readUint(head, f.dataOffset, f.size) !== 0; break;
511
+ default: break;
512
+ }
513
+ // Video/Audio sub-elements are nested, not at entry level — read separately below.
514
+ }
515
+ if (trackNumber === null) continue;
516
+
517
+ // Parse Video/Audio sub-elements if present
518
+ const videoEl = findElement(head, ID_VIDEO, [], entry.dataOffset, entryEnd);
519
+ if (videoEl) {
520
+ for (const vf of iterateElements(head, videoEl.dataOffset, Math.min(entryEnd, videoEl.dataOffset + videoEl.size))) {
521
+ if (vf.id === ID_PIXEL_WIDTH) pixelWidth = readUint(head, vf.dataOffset, vf.size);
522
+ else if (vf.id === ID_PIXEL_HEIGHT) pixelHeight = readUint(head, vf.dataOffset, vf.size);
523
+ else if (vf.id === ID_DISPLAY_WIDTH) displayWidth = readUint(head, vf.dataOffset, vf.size);
524
+ else if (vf.id === ID_DISPLAY_HEIGHT) displayHeight = readUint(head, vf.dataOffset, vf.size);
525
+ }
526
+ }
527
+ const audioEl = findElement(head, ID_AUDIO, [], entry.dataOffset, entryEnd);
528
+ if (audioEl) {
529
+ for (const af of iterateElements(head, audioEl.dataOffset, Math.min(entryEnd, audioEl.dataOffset + audioEl.size))) {
530
+ if (af.id === ID_SAMPLING_FREQUENCY) {
531
+ // SamplingFrequency is float64
532
+ if (af.size === 8) samplingFreq = head.readDoubleBE(af.dataOffset);
533
+ else samplingFreq = readUint(head, af.dataOffset, af.size);
534
+ } else if (af.id === ID_CHANNELS) channels = readUint(head, af.dataOffset, af.size);
535
+ }
536
+ }
537
+
538
+ // RFC 9559 LanguageBCP47 MUST — when present, Language ignored
539
+ const resolvedLang = languageBcp47 || language;
540
+ const bcpTag = languageBcp47;
541
+ const declaresLanguage = resolvedLang.length > 0;
542
+
543
+ if (typeNum === 1) {
544
+ videoDeclaredIndex += 1;
545
+ result.push(new VideoTrack({
546
+ trackNumber,
547
+ declaredIndex: videoDeclaredIndex,
548
+ codecId,
549
+ language: resolvedLang,
550
+ languageBcp47: bcpTag,
551
+ declaresLanguage,
552
+ name,
553
+ isEnabled,
554
+ isDefault,
555
+ declaresDefault,
556
+ codecPrivateB64,
557
+ width: pixelWidth,
558
+ height: pixelHeight,
559
+ displayWidth,
560
+ displayHeight
561
+ }));
562
+ } else if (typeNum === 2) {
563
+ audioDeclaredIndex += 1;
564
+ result.push(new AudioTrack({
565
+ trackNumber,
566
+ declaredIndex: audioDeclaredIndex,
567
+ codecId,
568
+ language: resolvedLang,
569
+ languageBcp47: bcpTag,
570
+ declaresLanguage,
571
+ name,
572
+ isEnabled,
573
+ isDefault,
574
+ declaresDefault,
575
+ codecPrivateB64,
576
+ isOriginal,
577
+ isCommentary,
578
+ isVisualImpaired: isVisual,
579
+ channels,
580
+ samplingFrequency: samplingFreq
581
+ }));
582
+ } else if (typeNum === 17) {
583
+ subtitleDeclaredIndex += 1;
584
+ // Only Forced/Hearing belong to subtitles; Original/Commentary must not leak.
585
+ const declared = declaredByNumber.get(trackNumber);
586
+ const planTrack = planTracksByNumber.get(trackNumber);
587
+ // Prefer plan's flags when available (already spec-correct), else use parsed.
588
+ const finalForced = planTrack ? !!planTrack.isForced : isForced;
589
+ const finalHearing = planTrack ? !!planTrack.isHearingImpaired : isHearing;
590
+ const finalEnabled = declared ? declared.isEnabled !== false : isEnabled;
591
+ const finalDefault = declared ? !!declared.isDefault : isDefault;
592
+ const finalDeclares = declared ? !!declared.declaresDefault : declaresDefault;
593
+ const clusterPositions = planTrack ? planTrack.clusterPositions ?? [] : [];
594
+ const isText = TEXT_CODECS_MATROSKA.has(codecId);
595
+ // disabled image/text tracks still counted (declaredIndex above) — offerable flag controls visibility
596
+ if (isText && finalEnabled) {
597
+ result.push(new TextSubtitleTrack({
598
+ trackNumber,
599
+ declaredIndex: subtitleDeclaredIndex,
600
+ codecId,
601
+ language: resolvedLang,
602
+ languageBcp47: bcpTag,
603
+ declaresLanguage,
604
+ name,
605
+ isEnabled: finalEnabled,
606
+ isDefault: finalDefault,
607
+ declaresDefault: finalDeclares,
608
+ codecPrivateB64,
609
+ isForced: finalForced,
610
+ isHearingImpaired: finalHearing,
611
+ clusterPositions
612
+ }));
613
+ } else {
614
+ // Image or disabled — keep declaredIndex, not offerable if disabled
615
+ const Target = isText ? TextSubtitleTrack : ImageSubtitleTrack;
616
+ result.push(new Target({
617
+ trackNumber,
618
+ declaredIndex: subtitleDeclaredIndex,
619
+ codecId,
620
+ language: resolvedLang,
621
+ languageBcp47: bcpTag,
622
+ declaresLanguage,
623
+ name,
624
+ isEnabled: finalEnabled,
625
+ isDefault: finalDefault,
626
+ declaresDefault: finalDeclares,
627
+ codecPrivateB64,
628
+ isForced: finalForced,
629
+ isHearingImpaired: finalHearing,
630
+ clusterPositions: isText ? clusterPositions : []
631
+ }));
632
+ }
633
+ } else {
634
+ // Other TrackType (complex, logo, buttons, control) — keep as generic, not video
635
+ result.push(new ContainerTrack({
636
+ trackNumber,
637
+ declaredIndex: -1,
638
+ type: "other",
639
+ codecId,
640
+ language: resolvedLang,
641
+ languageBcp47: bcpTag,
642
+ declaresLanguage,
643
+ name,
644
+ isEnabled,
645
+ isDefault,
646
+ declaresDefault,
647
+ codecPrivateB64
648
+ }));
649
+ }
650
+ }
651
+ return result;
652
+ }
653
+
654
+ /**
655
+ * The text field of one cue as Matroska frames it.
656
+ *
657
+ * Two rules, both from `matroska.org/technical/subtitles.html`, "Now, how are
658
+ * they stored in Matroska?":
659
+ *
660
+ * 1. "All text is converted to UTF-8", so the block is decoded as UTF-8 and
661
+ * no other encoding is guessed at. A subtitle FILE is a different matter —
662
+ * there the bytes may be Windows-1251 and `decodeSubtitleBytes` sniffs for
663
+ * it — but a muxer had to convert before writing the block.
664
+ * 2. "Events are stored in the Block in this order: ReadOrder, Layer, Style,
665
+ * Name, MarginL, MarginR, MarginV, Effect, Text", and "Start & End field
666
+ * are used to set TimeStamp and the BlockDuration element". So eight fields
667
+ * stand before the text, the two timing fields of the file's own row are
668
+ * NOT among them, and a read order takes their place at the front. The text
669
+ * itself may hold commas, so everything from the ninth field on is joined
670
+ * back together.
671
+ *
672
+ * `S_TEXT/UTF8` and `S_TEXT/WEBVTT` have no such framing: the block holds the
673
+ * cue text and nothing else. (A WebVTT cue's settings, identifier and
674
+ * preceding comments live in a BlockAddition, which this proxy does not read;
675
+ * losing them costs positioning, not words.)
676
+ *
677
+ * @param {Buffer} payload - The block's own bytes.
678
+ * @param {string} codecId - Matroska CodecID of the track the block belongs to.
679
+ * @returns {string}
680
+ */
681
+ static cueTextOf(payload, codecId) {
682
+ const text = Buffer.isBuffer(payload) ? payload.toString("utf8") : String(payload ?? "");
683
+ if (codecId !== "S_TEXT/ASS" && codecId !== "S_TEXT/SSA") {
684
+ return text;
685
+ }
686
+ const fields = text.split(",");
687
+ return fields.length > ASS_FIELDS_BEFORE_TEXT ? fields.slice(ASS_FIELDS_BEFORE_TEXT).join(",") : "";
688
+ }
689
+
690
+ async parseKeyframeIndex() {
691
+ const times = await readMatroskaKeyframeTimes(this.readRange, this.fileSize);
692
+ if (!times) return null;
693
+ if (Array.isArray(times)) return { times, tolerance: 0 };
694
+ return times;
695
+ }
696
+ }
697
+
698
+ // ---------------------------------------------------------------------------
699
+ // Matroska's own reading of its subtitle tracks and its clusters. It lives in
700
+ // this module because every line of it is a statement of RFC 9559 about how
701
+ // this container stores a subtitle, and the class is the only way in.
702
+ // ---------------------------------------------------------------------------
703
+
704
+ const ID_BLOCK = 0xa1;
705
+ const ID_BLOCK_DURATION = 0x9b;
706
+
707
+ /** Bits 1-2 of the flags byte say how a block is laced, or that it is not. */
708
+ const LACING_MASK = 0x06;
709
+ const LACING_NONE = 0x00;
710
+ const LACING_XIPH = 0x02;
711
+ const LACING_FIXED = 0x04;
712
+ const LACING_EBML = 0x06;
713
+
714
+ /**
715
+ * @typedef {object} SubtitleBlock
716
+ * @property {number} startSeconds - When the cue appears.
717
+ * @property {number | null} durationSeconds - How long it stays, or null when
718
+ * the block carried no duration (a SimpleBlock; the caller decides).
719
+ * @property {Buffer} payload - The block's own bytes, still in the codec's form.
720
+ */
721
+
722
+ /**
723
+ * Read one block's header.
724
+ *
725
+ * @param {Buffer} buffer
726
+ * @param {number} start - First byte of the block's payload.
727
+ * @param {number} end - One past its last byte.
728
+ * @returns {{ trackNumber: number, relativeTicks: number, flags: number, dataOffset: number } | null}
729
+ */
730
+ function readBlockHeader(buffer, start, end) {
731
+ const track = readVint(buffer, start, false);
732
+ if (!track || track.value === null) {
733
+ return null;
734
+ }
735
+ const timestampAt = start + track.length;
736
+ // Signed, and it can be negative: a block may belong slightly before the
737
+ // cluster it is stored in.
738
+ if (timestampAt + 3 > end) {
739
+ return null;
740
+ }
741
+ return {
742
+ trackNumber: Number(track.value),
743
+ relativeTicks: buffer.readInt16BE(timestampAt),
744
+ flags: buffer[timestampAt + 2],
745
+ dataOffset: timestampAt + 3
746
+ };
747
+ }
748
+
749
+ /**
750
+ * Where a laced block's first frame begins.
751
+ *
752
+ * Subtitles are rarely laced, but a block that IS laced starts with a frame
753
+ * count and a table of sizes, and reading the payload without stepping over
754
+ * them yields the table as though it were text.
755
+ *
756
+ * @param {Buffer} buffer
757
+ * @param {number} dataOffset - First byte after the block header.
758
+ * @param {number} end
759
+ * @param {number} flags
760
+ * @returns {number | null} The offset of the first frame, or null when the
761
+ * lacing cannot be read.
762
+ */
763
+ function firstFrameOffset(buffer, dataOffset, end, flags) {
764
+ const lacing = flags & LACING_MASK;
765
+ if (lacing === LACING_NONE) {
766
+ return dataOffset;
767
+ }
768
+ if (dataOffset >= end) {
769
+ return null;
770
+ }
771
+ const frames = buffer[dataOffset] + 1;
772
+ let at = dataOffset + 1;
773
+ if (lacing === LACING_FIXED) {
774
+ return at;
775
+ }
776
+ if (lacing === LACING_XIPH) {
777
+ // Each size but the last is a run of 0xFF bytes ending in a smaller one.
778
+ for (let frame = 0; frame < frames - 1; frame += 1) {
779
+ while (at < end && buffer[at] === 0xff) {
780
+ at += 1;
781
+ }
782
+ at += 1;
783
+ }
784
+ return at <= end ? at : null;
785
+ }
786
+ if (lacing === LACING_EBML) {
787
+ // The first size is a plain variable-length integer, the rest are signed
788
+ // differences from it; either way each is one such integer to step over.
789
+ for (let frame = 0; frame < frames - 1; frame += 1) {
790
+ const size = readVint(buffer, at, false);
791
+ if (!size) {
792
+ return null;
793
+ }
794
+ at += size.length;
795
+ }
796
+ return at <= end ? at : null;
797
+ }
798
+ return null;
799
+ }
800
+
801
+ /**
802
+ * Every block of one track inside one cluster.
803
+ *
804
+ * @param {Buffer} buffer - Bytes holding the cluster's payload.
805
+ * @param {{ dataOffset: number, size: number }} cluster - Where that payload is.
806
+ * @param {number} trackNumber - The track to keep.
807
+ * @param {number} secondsPerTick - From the segment's timestamp scale.
808
+ * @returns {SubtitleBlock[]}
809
+ */
810
+ function blocksOfTrack(buffer, cluster, trackNumber, secondsPerTick) {
811
+ const end = Math.min(buffer.length, cluster.dataOffset + cluster.size);
812
+ /** @type {SubtitleBlock[]} */
813
+ const blocks = [];
814
+ let clusterTicks = null;
815
+
816
+ const take = (blockStart, blockEnd, durationTicks) => {
817
+ const header = readBlockHeader(buffer, blockStart, blockEnd);
818
+ if (!header || header.trackNumber !== trackNumber || clusterTicks === null) {
819
+ return;
820
+ }
821
+ const payloadAt = firstFrameOffset(buffer, header.dataOffset, blockEnd, header.flags);
822
+ if (payloadAt === null || payloadAt >= blockEnd) {
823
+ return;
824
+ }
825
+ blocks.push({
826
+ startSeconds: (clusterTicks + header.relativeTicks) * secondsPerTick,
827
+ durationSeconds: durationTicks === null ? null : durationTicks * secondsPerTick,
828
+ payload: buffer.subarray(payloadAt, blockEnd)
829
+ });
830
+ };
831
+
832
+ for (const element of iterateElements(buffer, cluster.dataOffset, end)) {
833
+ const elementEnd = Math.min(end, element.dataOffset + element.size);
834
+ if (element.id === ID_TIMESTAMP) {
835
+ clusterTicks = readUint(buffer, element.dataOffset, element.size);
836
+ continue;
837
+ }
838
+ if (element.id === ID_SIMPLE_BLOCK) {
839
+ take(element.dataOffset, elementEnd, null);
840
+ continue;
841
+ }
842
+ if (element.id !== ID_BLOCK_GROUP) {
843
+ continue;
844
+ }
845
+ // A group holds the block and, for a subtitle, the duration that says when
846
+ // the cue leaves the screen. Both are read before either is used, because
847
+ // the duration may be written after the block.
848
+ let blockStart = null;
849
+ let blockEnd = null;
850
+ let durationTicks = null;
851
+ for (const field of iterateElements(buffer, element.dataOffset, elementEnd)) {
852
+ const fieldEnd = Math.min(elementEnd, field.dataOffset + field.size);
853
+ if (field.id === ID_BLOCK) {
854
+ blockStart = field.dataOffset;
855
+ blockEnd = fieldEnd;
856
+ } else if (field.id === ID_BLOCK_DURATION) {
857
+ durationTicks = readUint(buffer, field.dataOffset, field.size);
858
+ }
859
+ }
860
+ if (blockStart !== null) {
861
+ take(blockStart, blockEnd, durationTicks);
862
+ }
863
+ }
864
+ return blocks;
865
+ }
866
+
867
+
868
+ /**
869
+ * The rest of what a TrackEntry says about itself, RFC 9559 §5.1.4.1. Read
870
+ * because the file states them and a releaser's own wording in `Name` is the
871
+ * only thing we had before: "fors" and "SDH" in a menu were whatever text
872
+ * someone happened to type.
873
+ *
874
+ * `FlagEnabled` defaults to 1 and means "the track is usable"; a track that
875
+ * says 0 is counted but not offered. `FlagForced` applies only to subtitles and
876
+ * defaults to 0. `FlagHearingImpaired` is set "if and only if the track is
877
+ * suitable for users with hearing impairments". `FlagVisualImpaired`,
878
+ * `FlagOriginal` and `FlagCommentary` bear on the AUDIO choice and are read
879
+ * with that work, not here — see roadmap item 55.
880
+ */
881
+ const ID_FLAG_HEARING_IMPAIRED = 0x55ab;
882
+ /**
883
+ * The language as RFC 5646 writes it. The specification is a MUST: "If this
884
+ * element is used, then any Language elements used in the same TrackEntry MUST
885
+ * be ignored" — so where both are present, this one is the answer and the
886
+ * three-letter code is not.
887
+ */
888
+ const ID_CUES = 0x1c53bb6b;
889
+ const ID_CUE_POINT = 0xbb;
890
+ const ID_CUE_TRACK_POSITIONS = 0xb7;
891
+ const ID_CUE_TRACK = 0xf7;
892
+ const ID_CUE_CLUSTER_POSITION = 0xf1;
893
+
894
+ /** TrackType 17 is subtitles; 1 is video and 2 audio. */
895
+ const TRACK_TYPE_SUBTITLE = 17;
896
+ /** How much of the file start to read: the same window the keyframe reader uses. */
897
+ /** Cap on the Cues read; a long film indexes to tens of KB. */
898
+ const MAX_CUES_BYTES = 8 * 1024 * 1024;
899
+
900
+ /**
901
+ * The codecs whose blocks are text this proxy can turn into WebVTT.
902
+ *
903
+ * `S_TEXT/UTF8` is a plain line of text and needs nothing. `S_TEXT/ASS` and
904
+ * `S_TEXT/SSA` carry a dialogue row whose fields have to be stripped, and their
905
+ * header lives in CodecPrivate — supported, with the stripping done where the
906
+ * cue is turned into WebVTT. `S_HDMV/PGS` and `S_VOBSUB` are pictures, not
907
+ * text, and are deliberately absent: offering them would promise something this
908
+ * path cannot deliver.
909
+ */
910
+ const TEXT_CODECS = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA"]);
911
+
912
+ /**
913
+ * @typedef {object} SubtitleTrackPlan
914
+ * @property {number} trackNumber - As the blocks name it.
915
+ * @property {number} declaredIndex - Its position among ALL of the file's
916
+ * subtitle tracks, picture-based ones included — which is the number ffmpeg
917
+ * gives the same stream in `0:s:N`, and therefore the only number the browser
918
+ * ever names. Text tracks alone are not a numbering: a file whose PGS track
919
+ * comes first would have every text track one lower here than in the browser.
920
+ * @property {string} codecId
921
+ * @property {string} language - The language the file declares: its RFC 5646
922
+ * tag where it writes one, and the three-letter code otherwise. The
923
+ * specification requires that order — where `LanguageBCP47` is present, the
924
+ * `Language` element MUST be ignored.
925
+ * @property {string} languageBcp47 - The RFC 5646 tag alone, or "".
926
+ * @property {string} name - What the file calls the track, if anything.
927
+ * @property {boolean} isDefault
928
+ * @property {boolean} isForced - `FlagForced`: the track carries what a viewer
929
+ * needs even when they asked for no subtitles — signs, and dialogue in
930
+ * another language. It does NOT carry the film's own dialogue.
931
+ * @property {boolean} isHearingImpaired - `FlagHearingImpaired`: suitable for
932
+ * viewers who cannot hear, so it carries non-speech sound as well as speech.
933
+ * @property {string} codecPrivate - The ASS/SSA header, base64, or "".
934
+ * @property {number[]} clusterPositions - File offsets of clusters whose cue
935
+ * points name this track, ascending. Empty when the file indexes only its
936
+ * picture, and then the caller has to walk clusters as they arrive instead.
937
+ */
938
+
939
+
940
+ /**
941
+ * Everything about a file's text subtitle tracks that can be learned without
942
+ * reading the film.
943
+ *
944
+ * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
945
+ * @param {number} fileSize
946
+ * @returns {Promise<{ tracks: SubtitleTrackPlan[], declared: object[], secondsPerTick: number, segmentDataOffset: number } | null>}
947
+ */
948
+ async function readSubtitlePlan(readRange, fileSize) {
949
+ const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
950
+ if (!head || head.length < 4 || head.readUInt32BE(0) !== 0x1a45dfa3) {
951
+ return null;
952
+ }
953
+ const segment = findElement(head, ID_SEGMENT, []);
954
+ if (!segment) {
955
+ return null;
956
+ }
957
+ const base = segment.dataOffset;
958
+
959
+ const info = findElement(head, ID_INFO, [], base);
960
+ let scale = DEFAULT_TIMESTAMP_SCALE;
961
+ if (info) {
962
+ const declared = findElement(head, ID_TIMESTAMP_SCALE, [], info.dataOffset, info.dataOffset + info.size);
963
+ if (declared) {
964
+ const value = readUint(head, declared.dataOffset, declared.size);
965
+ if (value > 0) {
966
+ scale = value;
967
+ }
968
+ }
969
+ }
970
+
971
+ const tracksElement = findElement(head, ID_TRACKS, [], base);
972
+ if (!tracksElement) {
973
+ return null;
974
+ }
975
+ const tracksEnd = Math.min(head.length, tracksElement.dataOffset + tracksElement.size);
976
+ /** @type {SubtitleTrackPlan[]} */
977
+ const tracks = [];
978
+ /**
979
+ * Every subtitle track the file declares, in the order the Tracks element
980
+ * names them, text or picture. This is not for extraction — `tracks` is —
981
+ * but for lining ffmpeg's `0:s:N` numbering up against the container, which
982
+ * only holds while nothing is missing from the middle of the list.
983
+ *
984
+ * @type {Array<{ trackNumber: number, codecId: string, language: string, name: string, isDefault: boolean, declaresDefault: boolean }>}
985
+ */
986
+ const declared = [];
987
+ for (const entry of iterateElements(head, tracksElement.dataOffset, tracksEnd)) {
988
+ if (entry.id !== ID_TRACK_ENTRY) {
989
+ continue;
990
+ }
991
+ const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
992
+ let trackNumber = null;
993
+ let type = null;
994
+ let codecId = "";
995
+ let language = "";
996
+ let name = "";
997
+ let codecPrivate = "";
998
+ // Matroska's `FlagDefault` DEFAULTS TO 1, so a file whose muxer wrote it on
999
+ // no track is indistinguishable, once the default has been applied, from
1000
+ // one that wrote it on every track — which is how ffmpeg's banner prints it
1001
+ // and why the banner cannot answer this. Both are kept: what the flag
1002
+ // amounts to, and whether the file said anything at all.
1003
+ let isDefault = true;
1004
+ let declaresDefault = false;
1005
+ // Defaults straight from RFC 9559: a track is usable and not forced unless
1006
+ // the file says otherwise, and the impaired flags are absent until claimed.
1007
+ let isEnabled = true;
1008
+ let isForced = false;
1009
+ let isHearingImpaired = false;
1010
+ let languageBcp47 = "";
1011
+ for (const field of iterateElements(head, entry.dataOffset, entryEnd)) {
1012
+ if (field.id === ID_TRACK_NUMBER) {
1013
+ trackNumber = readUint(head, field.dataOffset, field.size);
1014
+ } else if (field.id === ID_TRACK_TYPE) {
1015
+ type = readUint(head, field.dataOffset, field.size);
1016
+ } else if (field.id === ID_CODEC_ID) {
1017
+ codecId = readString(head, field);
1018
+ } else if (field.id === ID_LANGUAGE) {
1019
+ language = readString(head, field);
1020
+ } else if (field.id === ID_LANGUAGE_BCP47) {
1021
+ languageBcp47 = readString(head, field);
1022
+ } else if (field.id === ID_NAME) {
1023
+ name = readString(head, field);
1024
+ } else if (field.id === ID_FLAG_DEFAULT) {
1025
+ isDefault = readUint(head, field.dataOffset, field.size) === 1;
1026
+ declaresDefault = true;
1027
+ } else if (field.id === ID_FLAG_ENABLED) {
1028
+ // An element written with zero length carries its default, which for
1029
+ // this one is 1 — so an empty element must not read as "unusable", and
1030
+ // neither must a value outside the declared 0-1 range. Only an explicit
1031
+ // zero takes a track away.
1032
+ isEnabled = field.size === 0 || readUint(head, field.dataOffset, field.size) !== 0;
1033
+ } else if (field.id === ID_FLAG_FORCED) {
1034
+ isForced = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
1035
+ } else if (field.id === ID_FLAG_HEARING_IMPAIRED) {
1036
+ isHearingImpaired = field.size > 0 && readUint(head, field.dataOffset, field.size) !== 0;
1037
+ } else if (field.id === ID_CODEC_PRIVATE) {
1038
+ codecPrivate = head.toString("base64", field.dataOffset, field.dataOffset + field.size);
1039
+ }
1040
+ }
1041
+ if (type !== TRACK_TYPE_SUBTITLE || trackNumber === null) {
1042
+ continue;
1043
+ }
1044
+ // A track the file marks unusable is still COUNTED. FlagEnabled says "the
1045
+ // track is usable", and a player should not offer it — but ffmpeg does not
1046
+ // drop it: `matroskadec.c` parses `MATROSKA_ID_TRACKFLAGENABLED` as
1047
+ // `EBML_NONE`, reading the element and keeping nothing, so the stream is
1048
+ // created and numbered like any other. Leaving it out of this list would
1049
+ // therefore shift `declaredIndex` off ffmpeg's `0:s:N` for every track
1050
+ // after it, which is the numbering defect this file was fixed for a day
1051
+ // earlier. It is counted here and refused where it is offered instead.
1052
+ //
1053
+ // `language` here stays the three-letter code, because this list exists to
1054
+ // be lined up against ffmpeg's banner, which prints that code. The RFC 5646
1055
+ // tag rides beside it for whoever displays the track.
1056
+ const declaresLanguage = languageBcp47.length > 0 || language.length > 0;
1057
+ declared.push({
1058
+ trackNumber,
1059
+ codecId,
1060
+ language,
1061
+ languageBcp47,
1062
+ declaresLanguage,
1063
+ name,
1064
+ isDefault,
1065
+ declaresDefault,
1066
+ isEnabled,
1067
+ isForced,
1068
+ isHearingImpaired
1069
+ });
1070
+ if (!TEXT_CODECS.has(codecId) || !isEnabled) {
1071
+ continue;
1072
+ }
1073
+ tracks.push({
1074
+ trackNumber,
1075
+ declaredIndex: declared.length - 1,
1076
+ codecId,
1077
+ // This list is ours and is not compared with ffmpeg's, so it carries the
1078
+ // language the file states most precisely: where RFC 5646 is written, the
1079
+ // three-letter code MUST be ignored.
1080
+ language: languageBcp47 || language,
1081
+ languageBcp47,
1082
+ declaresLanguage,
1083
+ name,
1084
+ isDefault,
1085
+ isForced,
1086
+ isHearingImpaired,
1087
+ codecPrivate,
1088
+ clusterPositions: []
1089
+ });
1090
+ }
1091
+ if (tracks.length === 0) {
1092
+ return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
1093
+ }
1094
+
1095
+ // Where the clusters holding those tracks are. A file that indexes only its
1096
+ // picture leaves these empty, which is not a failure: the caller then reads
1097
+ // the clusters the viewer's own playback brings in.
1098
+ const seekHead = findElement(head, ID_SEEK_HEAD, [], base);
1099
+ let cuesRelative;
1100
+ if (seekHead) {
1101
+ const seekEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
1102
+ for (const seek of iterateElements(head, seekHead.dataOffset, seekEnd)) {
1103
+ if (seek.id !== ID_SEEK) {
1104
+ continue;
1105
+ }
1106
+ let target = null;
1107
+ let position = null;
1108
+ for (const field of iterateElements(head, seek.dataOffset, Math.min(seekEnd, seek.dataOffset + seek.size))) {
1109
+ if (field.id === ID_SEEK_ID) {
1110
+ target = readUint(head, field.dataOffset, field.size);
1111
+ } else if (field.id === ID_SEEK_POSITION) {
1112
+ position = readUint(head, field.dataOffset, field.size);
1113
+ }
1114
+ }
1115
+ if (target === ID_CUES && position !== null) {
1116
+ cuesRelative = position;
1117
+ }
1118
+ }
1119
+ }
1120
+ if (cuesRelative !== undefined) {
1121
+ const cuesAt = base + cuesRelative;
1122
+ if (cuesAt > 0 && cuesAt < fileSize) {
1123
+ const chunk = await readRange(cuesAt, Math.min(fileSize - 1, cuesAt + MAX_CUES_BYTES));
1124
+ const element = chunk && [...iterateElements(chunk, 0, chunk.length)][0];
1125
+ if (element && element.id === ID_CUES) {
1126
+ const body = chunk.subarray(element.dataOffset, Math.min(chunk.length, element.dataOffset + element.size));
1127
+ const byTrack = new Map(tracks.map((track) => [track.trackNumber, new Set()]));
1128
+ for (const point of iterateElements(body, 0, body.length)) {
1129
+ if (point.id !== ID_CUE_POINT) {
1130
+ continue;
1131
+ }
1132
+ const pointEnd = Math.min(body.length, point.dataOffset + point.size);
1133
+ for (const field of iterateElements(body, point.dataOffset, pointEnd)) {
1134
+ if (field.id !== ID_CUE_TRACK_POSITIONS) {
1135
+ continue;
1136
+ }
1137
+ let cueTrack = null;
1138
+ let position = null;
1139
+ for (const inner of iterateElements(body, field.dataOffset, Math.min(pointEnd, field.dataOffset + field.size))) {
1140
+ if (inner.id === ID_CUE_TRACK) {
1141
+ cueTrack = readUint(body, inner.dataOffset, inner.size);
1142
+ } else if (inner.id === ID_CUE_CLUSTER_POSITION) {
1143
+ position = readUint(body, inner.dataOffset, inner.size);
1144
+ }
1145
+ }
1146
+ if (position !== null && byTrack.has(cueTrack)) {
1147
+ byTrack.get(cueTrack).add(base + position);
1148
+ }
1149
+ }
1150
+ }
1151
+ for (const track of tracks) {
1152
+ track.clusterPositions = [...byTrack.get(track.trackNumber)].sort((left, right) => left - right);
1153
+ }
1154
+ }
1155
+ }
1156
+ }
1157
+ return { tracks, declared, secondsPerTick: scale / 1e9, segmentDataOffset: base };
1158
+ }
1159
+
1160
+ /**
1161
+ * The cues of one track inside one cluster.
1162
+ *
1163
+ * @param {Buffer} bytes - The cluster, from its own element header onward.
1164
+ * @param {number} trackNumber
1165
+ * @param {number} secondsPerTick
1166
+ * @returns {{ startSeconds: number, endSeconds: number | null, text: string }[]}
1167
+ */
1168
+ function harvestCluster(bytes, trackNumber, secondsPerTick) {
1169
+ const header = [...iterateElements(bytes, 0, bytes.length)][0];
1170
+ if (!header) {
1171
+ return [];
1172
+ }
1173
+ const blocks = blocksOfTrack(
1174
+ bytes,
1175
+ { dataOffset: header.dataOffset, size: header.size },
1176
+ trackNumber,
1177
+ secondsPerTick
1178
+ );
1179
+ // The payload is handed on as BYTES. What those bytes mean — which of them
1180
+ // are the text and which are the eight fields Matroska puts before it — is
1181
+ // stated by the container's specification and answered by
1182
+ // `MatroskaContainer.cueTextOf`, not here: this function's subject is where a
1183
+ // block sits and how long it lasts.
1184
+ return blocks.map((block) => ({
1185
+ startSeconds: block.startSeconds,
1186
+ endSeconds: block.durationSeconds === null ? null : block.startSeconds + block.durationSeconds,
1187
+ payload: block.payload
1188
+ }));
1189
+ }
1190
+
1191
+ // ---------------------------------------------------------------------------
1192
+ // RFC 9559 speaking about Matroska: the Cues table that says where a keyframe is.
1193
+ // Here because the class is the only way in.
1194
+ // ---------------------------------------------------------------------------
1195
+
1196
+ // Element ids, from the Matroska specification.
1197
+ const ID_CUE_TIME = 0xb3;
1198
+ // TrackType 1 is video; 2 is audio, 17 subtitles, and the rest are rarer still.
1199
+ const TRACK_TYPE_VIDEO = 1;
1200
+
1201
+ // How much of the file start to read. Must cover the EBML header, the SeekHead
1202
+ // and Info; 64 KB is generous for every real muxer (the file measured needed
1203
+ // under 4 KB) while still trivial to fetch.
1204
+ // Cap on the Cues read. A two-hour film indexes to tens of KB; anything beyond
1205
+ // this is not a normal index and not worth pulling over a torrent.
1206
+ // Cap on a Tracks read, for the rare file whose Tracks element sits outside the
1207
+ // head window. Track entries are small, so a file with dozens of them still
1208
+ // fits well inside this.
1209
+ const MAX_TRACKS_BYTES = 1024 * 1024;
1210
+ // Matroska's default timestamp scale (nanoseconds per tick) when Info omits it.
1211
+
1212
+ /**
1213
+ * Whether this looks like a Matroska file (the EBML magic `0x1A45DFA3`).
1214
+ *
1215
+ * @param {Buffer} head
1216
+ * @returns {boolean}
1217
+ */
1218
+ function isMatroska(head) {
1219
+ return head.length >= 4 && head.readUInt32BE(0) === 0x1a45dfa3;
1220
+ }
1221
+
1222
+ /**
1223
+ * Locate the Segment element and the SeekHead entries inside it.
1224
+ *
1225
+ * Seek positions are relative to the start of Segment's payload, not to the
1226
+ * file, so that base has to come back with them.
1227
+ *
1228
+ * @param {Buffer} head
1229
+ * @returns {{ segmentDataOffset: number, entries: Map<number, number> } | null}
1230
+ */
1231
+ function readSeekHead(head) {
1232
+ let segmentDataOffset = -1;
1233
+ for (const element of iterateElements(head)) {
1234
+ if (element.id === ID_SEGMENT) {
1235
+ segmentDataOffset = element.dataOffset;
1236
+ break;
1237
+ }
1238
+ }
1239
+ if (segmentDataOffset < 0) {
1240
+ return null;
1241
+ }
1242
+
1243
+ const seekHead = findElement(head, ID_SEEK_HEAD, [], segmentDataOffset);
1244
+ if (!seekHead) {
1245
+ return null;
1246
+ }
1247
+
1248
+ const entries = new Map();
1249
+ const seekHeadEnd = Math.min(head.length, seekHead.dataOffset + seekHead.size);
1250
+ for (const seek of iterateElements(head, seekHead.dataOffset, seekHeadEnd)) {
1251
+ if (seek.id !== ID_SEEK) {
1252
+ continue;
1253
+ }
1254
+ const seekEnd = Math.min(seekHeadEnd, seek.dataOffset + seek.size);
1255
+ let targetId = null;
1256
+ let position = null;
1257
+ for (const field of iterateElements(head, seek.dataOffset, seekEnd)) {
1258
+ if (field.id === ID_SEEK_ID) {
1259
+ targetId = readUint(head, field.dataOffset, field.size);
1260
+ } else if (field.id === ID_SEEK_POSITION) {
1261
+ position = readUint(head, field.dataOffset, field.size);
1262
+ }
1263
+ }
1264
+ if (targetId !== null && position !== null) {
1265
+ entries.set(targetId, position);
1266
+ }
1267
+ }
1268
+ return { segmentDataOffset, entries };
1269
+ }
1270
+
1271
+ /**
1272
+ * Timestamp scale (nanoseconds per tick) declared in Info, or the default.
1273
+ *
1274
+ * @param {Buffer} head
1275
+ * @param {number} segmentDataOffset
1276
+ * @returns {number}
1277
+ */
1278
+ function readTimestampScale(head, segmentDataOffset) {
1279
+ const info = findElement(head, ID_INFO, [], segmentDataOffset);
1280
+ if (!info) {
1281
+ return DEFAULT_TIMESTAMP_SCALE;
1282
+ }
1283
+ const infoEnd = Math.min(head.length, info.dataOffset + info.size);
1284
+ for (const field of iterateElements(head, info.dataOffset, infoEnd)) {
1285
+ if (field.id === ID_TIMESTAMP_SCALE) {
1286
+ const scale = readUint(head, field.dataOffset, field.size);
1287
+ return scale > 0 ? scale : DEFAULT_TIMESTAMP_SCALE;
1288
+ }
1289
+ }
1290
+ return DEFAULT_TIMESTAMP_SCALE;
1291
+ }
1292
+
1293
+ /**
1294
+ * The number of the first video track, from a Tracks payload.
1295
+ *
1296
+ * The FIRST one, because that is the track ffmpeg is told to copy (`0:v:0`).
1297
+ *
1298
+ * @param {Buffer} buffer
1299
+ * @param {{ dataOffset: number, size: number }} tracks
1300
+ * @returns {number | null}
1301
+ */
1302
+ function readVideoTrackNumber(buffer, tracks) {
1303
+ const tracksEnd = Math.min(buffer.length, tracks.dataOffset + tracks.size);
1304
+ for (const entry of iterateElements(buffer, tracks.dataOffset, tracksEnd)) {
1305
+ if (entry.id !== ID_TRACK_ENTRY) {
1306
+ continue;
1307
+ }
1308
+ const entryEnd = Math.min(tracksEnd, entry.dataOffset + entry.size);
1309
+ let number = null;
1310
+ let type = null;
1311
+ for (const field of iterateElements(buffer, entry.dataOffset, entryEnd)) {
1312
+ if (field.id === ID_TRACK_NUMBER) {
1313
+ number = readUint(buffer, field.dataOffset, field.size);
1314
+ } else if (field.id === ID_TRACK_TYPE) {
1315
+ type = readUint(buffer, field.dataOffset, field.size);
1316
+ }
1317
+ }
1318
+ if (number !== null && type === TRACK_TYPE_VIDEO) {
1319
+ return number;
1320
+ }
1321
+ }
1322
+ return null;
1323
+ }
1324
+
1325
+ /**
1326
+ * Cue times (seconds, ascending) of ONE track, from a Cues payload.
1327
+ *
1328
+ * The track is the whole point, and leaving it out is what this reader got
1329
+ * wrong until 2026-08-18. A CuePoint belongs to the track named inside its
1330
+ * CueTrackPositions, and a muxer indexes whatever tracks it likes: RFC 9559
1331
+ * says each keyframe of a video track SHOULD be referenced, and that the Cues
1332
+ * Element "can be used to index every single timestamp of every Block or they
1333
+ * can be indexed selectively". Both field files index their SUBTITLE tracks as
1334
+ * well — `Minions.and.Monsters.1080p.mkv` has 2778 video entries every 2.002 s
1335
+ * plus 4669 across four subtitle tracks; `Moana.2 … MegaPeer.mkv` has 1055
1336
+ * video entries plus 5007 across five.
1337
+ *
1338
+ * Read without the track, those extra times enter the cut list as though they
1339
+ * were keyframes. ffmpeg can only cut a COPIED picture at a real keyframe at or
1340
+ * after the time it is given, so every cut asked for at one of them lands late
1341
+ * — which is exactly what the field measured: on the first file every deviation
1342
+ * was 2.002 s, that file's own keyframe spacing, and on the second the median
1343
+ * was 6.3 s with a worst case of 21 s. Never once negative.
1344
+ *
1345
+ * @param {Buffer} cues
1346
+ * @param {number} timestampScale - Nanoseconds per tick.
1347
+ * @param {number | null} trackNumber - Null keeps every entry, which is right
1348
+ * only for a file that indexes one track.
1349
+ * @returns {number[]}
1350
+ */
1351
+ function readCueTimes(cues, timestampScale, trackNumber) {
1352
+ const times = [];
1353
+ const secondsPerTick = timestampScale / 1e9;
1354
+ for (const point of iterateElements(cues)) {
1355
+ if (point.id !== ID_CUE_POINT) {
1356
+ continue;
1357
+ }
1358
+ const pointEnd = Math.min(cues.length, point.dataOffset + point.size);
1359
+ let time = null;
1360
+ let belongsToTrack = trackNumber === null;
1361
+ for (const field of iterateElements(cues, point.dataOffset, pointEnd)) {
1362
+ if (field.id === ID_CUE_TIME) {
1363
+ time = readUint(cues, field.dataOffset, field.size) * secondsPerTick;
1364
+ continue;
1365
+ }
1366
+ if (field.id !== ID_CUE_TRACK_POSITIONS || belongsToTrack) {
1367
+ continue;
1368
+ }
1369
+ const positionsEnd = Math.min(pointEnd, field.dataOffset + field.size);
1370
+ for (const inner of iterateElements(cues, field.dataOffset, positionsEnd)) {
1371
+ if (inner.id === ID_CUE_TRACK && readUint(cues, inner.dataOffset, inner.size) === trackNumber) {
1372
+ belongsToTrack = true;
1373
+ break;
1374
+ }
1375
+ }
1376
+ }
1377
+ if (time !== null && belongsToTrack) {
1378
+ times.push(time);
1379
+ }
1380
+ }
1381
+ times.sort((left, right) => left - right);
1382
+ return times;
1383
+ }
1384
+
1385
+ /**
1386
+ * Read the keyframe times of a Matroska file using only two point reads.
1387
+ *
1388
+ * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
1389
+ * Inclusive byte range reader; returns null when the range is unavailable.
1390
+ * @param {number} fileSize
1391
+ * @returns {Promise<number[] | null>} Ascending seconds, or null when the file
1392
+ * carries no usable index (see the module doc for when that happens).
1393
+ */
1394
+ async function readMatroskaKeyframeTimes(readRange, fileSize) {
1395
+ const head = await readRange(0, Math.min(HEAD_BYTES, Math.max(0, fileSize - 1)));
1396
+ if (!head || !isMatroska(head)) {
1397
+ return null;
1398
+ }
1399
+
1400
+ const seekHead = readSeekHead(head);
1401
+ if (!seekHead) {
1402
+ return null; // No SeekHead — a streamed or truncated mux.
1403
+ }
1404
+
1405
+ const cuesRelative = seekHead.entries.get(ID_CUES);
1406
+ if (cuesRelative === undefined) {
1407
+ return null; // Indexless file: live capture, interrupted write, damaged upload.
1408
+ }
1409
+
1410
+ // SeekHead positions are relative to Segment's payload.
1411
+ const cuesOffset = seekHead.segmentDataOffset + cuesRelative;
1412
+ if (!Number.isFinite(cuesOffset) || cuesOffset <= 0 || cuesOffset >= fileSize) {
1413
+ return null;
1414
+ }
1415
+
1416
+ // The element header states the payload size, but reading it costs a round
1417
+ // trip; fetch a bounded window instead and let the parser stop at the end of
1418
+ // what it got. Cues sits near the file end, so the window is clamped there.
1419
+ const cuesEnd = Math.min(fileSize - 1, cuesOffset + MAX_CUES_BYTES);
1420
+ const cuesChunk = await readRange(cuesOffset, cuesEnd);
1421
+ if (!cuesChunk || cuesChunk.length === 0) {
1422
+ return null;
1423
+ }
1424
+
1425
+ // The window starts exactly at the Cues element, so its own header comes
1426
+ // first; step over it to reach the CuePoints.
1427
+ const cuesElement = [...iterateElements(cuesChunk, 0, cuesChunk.length)][0];
1428
+ if (!cuesElement || cuesElement.id !== ID_CUES) {
1429
+ return null;
1430
+ }
1431
+ const payloadEnd = Math.min(cuesChunk.length, cuesElement.dataOffset + cuesElement.size);
1432
+ const payload = cuesChunk.subarray(cuesElement.dataOffset, payloadEnd);
1433
+
1434
+ // Whose entries to keep. Tracks sits near the head and is normally inside the
1435
+ // bytes already fetched; when it is not, SeekHead says where it is and one
1436
+ // more short read gets it. Nothing is fetched twice and nothing is scanned.
1437
+ const videoTrack = await readVideoTrack(readRange, head, seekHead, fileSize);
1438
+ const timestampScale = readTimestampScale(head, seekHead.segmentDataOffset);
1439
+ const times = readCueTimes(payload, timestampScale, videoTrack);
1440
+ if (times.length > 0) {
1441
+ return times;
1442
+ }
1443
+ if (videoTrack === null) {
1444
+ return null;
1445
+ }
1446
+ // The filter left nothing, and that is not an answer about the file: a table
1447
+ // exists, and this reader simply failed to recognise which of its entries
1448
+ // belong to the picture — a track numbered one way in Tracks and another in
1449
+ // the cue points, or an entry with no CueTrack at all. Returning null here
1450
+ // would put an EVEN grid on a copied picture, which is the failure this
1451
+ // module exists to prevent, so the unfiltered table is used instead: less
1452
+ // exact than the picture's own keyframes, better than a grid that has nothing
1453
+ // to do with the file.
1454
+ const unfiltered = readCueTimes(payload, timestampScale, null);
1455
+ return unfiltered.length > 0 ? unfiltered : null;
1456
+ }
1457
+
1458
+ /**
1459
+ * The video track's number — from the head when it is there, and from one extra
1460
+ * short read when it is not.
1461
+ *
1462
+ * @param {(start: number, end: number) => Promise<Buffer | null>} readRange
1463
+ * @param {Buffer} head
1464
+ * @param {{ segmentDataOffset: number, entries: Map<number, number> }} seekHead
1465
+ * @param {number} fileSize
1466
+ * @returns {Promise<number | null>} Null when Tracks cannot be read at all, and
1467
+ * then every cue entry is kept — right for a file that indexes only its
1468
+ * picture, wrong for one that does not, and nothing here can tell them apart.
1469
+ * Refusing the index instead would put an even grid on a copied picture,
1470
+ * which is the failure this reader exists to prevent.
1471
+ */
1472
+ async function readVideoTrack(readRange, head, seekHead, fileSize) {
1473
+ const inHead = findElement(head, ID_TRACKS, [], seekHead.segmentDataOffset);
1474
+ if (inHead && inHead.dataOffset + inHead.size <= head.length) {
1475
+ return readVideoTrackNumber(head, inHead);
1476
+ }
1477
+ const relative = seekHead.entries.get(ID_TRACKS);
1478
+ if (relative === undefined) {
1479
+ return null;
1480
+ }
1481
+ const offset = seekHead.segmentDataOffset + relative;
1482
+ if (!Number.isFinite(offset) || offset <= 0 || offset >= fileSize) {
1483
+ return null;
1484
+ }
1485
+ const chunk = await readRange(offset, Math.min(fileSize - 1, offset + MAX_TRACKS_BYTES));
1486
+ if (!chunk || chunk.length === 0) {
1487
+ return null;
1488
+ }
1489
+ const element = [...iterateElements(chunk, 0, chunk.length)][0];
1490
+ if (!element || element.id !== ID_TRACKS) {
1491
+ return null;
1492
+ }
1493
+ return readVideoTrackNumber(chunk, { dataOffset: element.dataOffset, size: element.size });
1494
+ }