@blockcast/mmt-container 0.1.0 → 0.3.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 (799) hide show
  1. package/README.md +524 -0
  2. package/dist/.build-stamp +0 -0
  3. package/dist/__tests__/test-utils.d.ts +71 -0
  4. package/dist/__tests__/test-utils.d.ts.map +1 -0
  5. package/dist/align.d.ts +152 -0
  6. package/dist/align.d.ts.map +1 -0
  7. package/dist/align.js +88 -0
  8. package/dist/align.js.map +7 -0
  9. package/dist/audio-init-parser.d.ts +66 -0
  10. package/dist/audio-init-parser.d.ts.map +1 -0
  11. package/dist/audio-init-parser.js +118 -0
  12. package/dist/audio-init-parser.js.map +7 -0
  13. package/dist/audio-ring-buffer.d.ts +50 -0
  14. package/dist/audio-ring-buffer.d.ts.map +1 -0
  15. package/dist/audio-ring-buffer.js +80 -0
  16. package/dist/audio-ring-buffer.js.map +7 -0
  17. package/dist/authored-timing.d.ts +32 -0
  18. package/dist/authored-timing.d.ts.map +1 -0
  19. package/dist/authored-timing.js +32 -0
  20. package/dist/authored-timing.js.map +7 -0
  21. package/dist/block-policy.d.ts +314 -0
  22. package/dist/block-policy.d.ts.map +1 -0
  23. package/dist/block-policy.js +466 -0
  24. package/dist/block-policy.js.map +7 -0
  25. package/dist/browser-runtime.js +26549 -0
  26. package/dist/browser-runtime.js.map +7 -0
  27. package/dist/carp-catalog.d.ts +429 -0
  28. package/dist/carp-catalog.d.ts.map +1 -0
  29. package/dist/carp-catalog.js +985 -0
  30. package/dist/carp-catalog.js.map +7 -0
  31. package/dist/catalog-fec-params.d.ts +275 -0
  32. package/dist/catalog-fec-params.d.ts.map +1 -0
  33. package/dist/catalog-fec-params.js +702 -0
  34. package/dist/catalog-fec-params.js.map +7 -0
  35. package/dist/catalog-network-source.d.ts +42 -0
  36. package/dist/catalog-network-source.d.ts.map +1 -0
  37. package/dist/catalog-network-source.js +47 -0
  38. package/dist/catalog-network-source.js.map +7 -0
  39. package/dist/catalog-validator.d.ts +18 -0
  40. package/dist/catalog-validator.d.ts.map +1 -0
  41. package/dist/catalog-validator.js +7747 -0
  42. package/dist/catalog-validator.js.map +7 -0
  43. package/dist/cmaf-block-assembler.d.ts +285 -0
  44. package/dist/cmaf-block-assembler.d.ts.map +1 -0
  45. package/dist/cmaf-block-assembler.js +1966 -0
  46. package/dist/cmaf-block-assembler.js.map +7 -0
  47. package/dist/cmaf-group-batcher.d.ts +38 -0
  48. package/dist/cmaf-group-batcher.d.ts.map +1 -0
  49. package/dist/cmaf-group-batcher.js +176 -0
  50. package/dist/cmaf-group-batcher.js.map +7 -0
  51. package/dist/cmcd-producer.d.ts +104 -0
  52. package/dist/cmcd-producer.d.ts.map +1 -0
  53. package/dist/cmcd-producer.js +183 -0
  54. package/dist/cmcd-producer.js.map +7 -0
  55. package/dist/codec-utils.d.ts +384 -0
  56. package/dist/codec-utils.d.ts.map +1 -0
  57. package/dist/codec-utils.js +1546 -0
  58. package/dist/codec-utils.js.map +7 -0
  59. package/dist/conceal-timeline.d.ts +146 -0
  60. package/dist/conceal-timeline.d.ts.map +1 -0
  61. package/dist/conceal-timeline.js +137 -0
  62. package/dist/conceal-timeline.js.map +7 -0
  63. package/dist/delivery-gap.d.ts +75 -0
  64. package/dist/delivery-gap.d.ts.map +1 -0
  65. package/dist/delivery-gap.js +88 -0
  66. package/dist/delivery-gap.js.map +7 -0
  67. package/dist/duplicate-ledger.d.ts +197 -0
  68. package/dist/duplicate-ledger.d.ts.map +1 -0
  69. package/dist/duplicate-ledger.js +226 -0
  70. package/dist/duplicate-ledger.js.map +7 -0
  71. package/dist/fec-codec.d.ts +32 -0
  72. package/dist/fec-codec.d.ts.map +1 -0
  73. package/dist/fec-codec.js +47 -0
  74. package/dist/fec-codec.js.map +7 -0
  75. package/dist/fec-manager.d.ts +583 -0
  76. package/dist/fec-manager.d.ts.map +1 -0
  77. package/dist/fec-manager.js +3899 -0
  78. package/dist/fec-manager.js.map +7 -0
  79. package/dist/fec-starvation-detector.d.ts +59 -0
  80. package/dist/fec-starvation-detector.d.ts.map +1 -0
  81. package/dist/fec-starvation-detector.js +60 -0
  82. package/dist/fec-starvation-detector.js.map +7 -0
  83. package/dist/fec-state.d.ts +131 -0
  84. package/dist/fec-state.d.ts.map +1 -0
  85. package/dist/fec-state.js +87 -0
  86. package/dist/fec-state.js.map +7 -0
  87. package/dist/fec-worker-setup.d.ts +94 -0
  88. package/dist/fec-worker-setup.d.ts.map +1 -0
  89. package/dist/fec-worker-setup.js +673 -0
  90. package/dist/fec-worker-setup.js.map +7 -0
  91. package/dist/fmp4-completion.d.ts +30 -0
  92. package/dist/fmp4-completion.d.ts.map +1 -0
  93. package/dist/fmp4-completion.js +21 -0
  94. package/dist/fmp4-completion.js.map +7 -0
  95. package/dist/fmp4-reader.d.ts +37 -0
  96. package/dist/fmp4-reader.d.ts.map +1 -0
  97. package/dist/fmp4-reader.js +100 -0
  98. package/dist/fmp4-reader.js.map +7 -0
  99. package/dist/index.d.ts +44 -3
  100. package/dist/index.d.ts.map +1 -1
  101. package/dist/index.js +460 -14
  102. package/dist/index.js.map +7 -1
  103. package/dist/iso-block-locator.d.ts +118 -0
  104. package/dist/iso-block-locator.d.ts.map +1 -0
  105. package/dist/iso-block-locator.js +289 -0
  106. package/dist/iso-block-locator.js.map +7 -0
  107. package/dist/iso-bmff-timing.d.ts +78 -0
  108. package/dist/iso-bmff-timing.d.ts.map +1 -0
  109. package/dist/iso-bmff-timing.js +593 -0
  110. package/dist/iso-bmff-timing.js.map +7 -0
  111. package/dist/loc.d.ts +70 -75
  112. package/dist/loc.d.ts.map +1 -1
  113. package/dist/loc.js +106 -192
  114. package/dist/loc.js.map +7 -1
  115. package/dist/manifest-auth-plan.d.ts +30 -0
  116. package/dist/manifest-auth-plan.d.ts.map +1 -0
  117. package/dist/manifest-auth-plan.js +1266 -0
  118. package/dist/manifest-auth-plan.js.map +7 -0
  119. package/dist/media-router.d.ts +262 -0
  120. package/dist/media-router.d.ts.map +1 -0
  121. package/dist/media-router.js +584 -0
  122. package/dist/media-router.js.map +7 -0
  123. package/dist/mfu-reassembler.d.ts +313 -9
  124. package/dist/mfu-reassembler.d.ts.map +1 -1
  125. package/dist/mfu-reassembler.js +3051 -285
  126. package/dist/mfu-reassembler.js.map +7 -1
  127. package/dist/mfu-reassembly-policy.d.ts +24 -0
  128. package/dist/mfu-reassembly-policy.d.ts.map +1 -0
  129. package/dist/mfu-reassembly-policy.js +99 -0
  130. package/dist/mfu-reassembly-policy.js.map +7 -0
  131. package/dist/mmtp-container-parser.d.ts +568 -0
  132. package/dist/mmtp-container-parser.d.ts.map +1 -0
  133. package/dist/mmtp-container-parser.js +6580 -0
  134. package/dist/mmtp-container-parser.js.map +7 -0
  135. package/dist/mmtp-container-parser.no-modules.js +9616 -0
  136. package/dist/mmtp-container-parser.no-modules.js.map +7 -0
  137. package/dist/mmtp-decode-pipeline.d.ts +138 -0
  138. package/dist/mmtp-decode-pipeline.d.ts.map +1 -0
  139. package/dist/mmtp-decode-pipeline.js +6814 -0
  140. package/dist/mmtp-decode-pipeline.js.map +7 -0
  141. package/dist/mmtp-router.d.ts +679 -0
  142. package/dist/mmtp-router.d.ts.map +1 -0
  143. package/dist/mmtp-router.js +11278 -0
  144. package/dist/mmtp-router.js.map +7 -0
  145. package/dist/mmtp-to-cmaf.d.ts +187 -0
  146. package/dist/mmtp-to-cmaf.d.ts.map +1 -0
  147. package/dist/mmtp-to-cmaf.js +716 -0
  148. package/dist/mmtp-to-cmaf.js.map +7 -0
  149. package/dist/multicast-auth-catalog.d.ts +115 -0
  150. package/dist/multicast-auth-catalog.d.ts.map +1 -0
  151. package/dist/multicast-auth-catalog.js +202 -0
  152. package/dist/multicast-auth-catalog.js.map +7 -0
  153. package/dist/multicast-input.d.ts +141 -0
  154. package/dist/multicast-input.d.ts.map +1 -0
  155. package/dist/multicast-input.js +390 -0
  156. package/dist/multicast-input.js.map +7 -0
  157. package/dist/multicast-manager.d.ts +669 -0
  158. package/dist/multicast-manager.d.ts.map +1 -0
  159. package/dist/multicast-manager.js +19101 -0
  160. package/dist/multicast-manager.js.map +7 -0
  161. package/dist/multicast-transport.d.ts +271 -0
  162. package/dist/multicast-transport.d.ts.map +1 -0
  163. package/dist/multicast-transport.js +2264 -0
  164. package/dist/multicast-transport.js.map +7 -0
  165. package/dist/multicast-unwrap.d.ts +57 -0
  166. package/dist/multicast-unwrap.d.ts.map +1 -0
  167. package/dist/multicast-unwrap.js +418 -0
  168. package/dist/multicast-unwrap.js.map +7 -0
  169. package/dist/no-modules.d.ts +10 -0
  170. package/dist/no-modules.d.ts.map +1 -0
  171. package/dist/no-modules.js +9491 -0
  172. package/dist/no-modules.js.map +7 -0
  173. package/dist/phase14-catalog-override.d.ts +126 -0
  174. package/dist/phase14-catalog-override.d.ts.map +1 -0
  175. package/dist/phase14-catalog-override.js +155 -0
  176. package/dist/phase14-catalog-override.js.map +7 -0
  177. package/dist/repair-fetch.d.ts +270 -0
  178. package/dist/repair-fetch.d.ts.map +1 -0
  179. package/dist/repair-fetch.js +381 -0
  180. package/dist/repair-fetch.js.map +7 -0
  181. package/dist/repair-layers.d.ts +158 -0
  182. package/dist/repair-layers.d.ts.map +1 -0
  183. package/dist/repair-layers.js +344 -0
  184. package/dist/repair-layers.js.map +7 -0
  185. package/dist/sample-combiner.d.ts +212 -0
  186. package/dist/sample-combiner.d.ts.map +1 -0
  187. package/dist/sample-combiner.js +595 -0
  188. package/dist/sample-combiner.js.map +7 -0
  189. package/dist/signaling.d.ts +29 -0
  190. package/dist/signaling.d.ts.map +1 -0
  191. package/dist/signaling.js +572 -0
  192. package/dist/signaling.js.map +7 -0
  193. package/dist/source-auth.d.ts +2 -0
  194. package/dist/source-auth.d.ts.map +1 -0
  195. package/dist/source-auth.js +1209 -0
  196. package/dist/source-auth.js.map +7 -0
  197. package/dist/switching-set-alignment.d.ts +61 -0
  198. package/dist/switching-set-alignment.d.ts.map +1 -0
  199. package/dist/switching-set-alignment.js +70 -0
  200. package/dist/switching-set-alignment.js.map +7 -0
  201. package/dist/timestamp-codec.d.ts +34 -0
  202. package/dist/timestamp-codec.d.ts.map +1 -0
  203. package/dist/timestamp-codec.js +473 -0
  204. package/dist/timestamp-codec.js.map +7 -0
  205. package/dist/track-pipeline-setup.d.ts +83 -0
  206. package/dist/track-pipeline-setup.d.ts.map +1 -0
  207. package/dist/track-pipeline-setup.js +291 -0
  208. package/dist/track-pipeline-setup.js.map +7 -0
  209. package/dist/track-pipeline.d.ts +121 -0
  210. package/dist/track-pipeline.d.ts.map +1 -0
  211. package/dist/track-pipeline.js +209 -0
  212. package/dist/track-pipeline.js.map +7 -0
  213. package/dist/track-session.d.ts +414 -0
  214. package/dist/track-session.d.ts.map +1 -0
  215. package/dist/track-session.js +754 -0
  216. package/dist/track-session.js.map +7 -0
  217. package/dist/transport-pool.d.ts +69 -0
  218. package/dist/transport-pool.d.ts.map +1 -0
  219. package/dist/transport-pool.js +74 -0
  220. package/dist/transport-pool.js.map +7 -0
  221. package/dist/wasm-mfu-reassembler.d.ts +97 -0
  222. package/dist/wasm-mfu-reassembler.d.ts.map +1 -0
  223. package/dist/wasm-mfu-reassembler.js +2133 -0
  224. package/dist/wasm-mfu-reassembler.js.map +7 -0
  225. package/dist/wasm-mmtp-container-parser.d.ts +10 -0
  226. package/dist/wasm-mmtp-container-parser.d.ts.map +1 -0
  227. package/dist/wasm-mmtp-container-parser.js +6834 -0
  228. package/dist/wasm-mmtp-container-parser.js.map +7 -0
  229. package/node_modules/@blockcast/fec-worker/README.md +223 -0
  230. package/node_modules/@blockcast/fec-worker/dist/.build-stamp +0 -0
  231. package/node_modules/@blockcast/fec-worker/dist/block-order.d.ts +38 -0
  232. package/node_modules/@blockcast/fec-worker/dist/block-order.d.ts.map +1 -0
  233. package/node_modules/@blockcast/fec-worker/dist/block-order.js +78 -0
  234. package/node_modules/@blockcast/fec-worker/dist/block-order.js.map +1 -0
  235. package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.d.ts +93 -0
  236. package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.d.ts.map +1 -0
  237. package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.js +346 -0
  238. package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.js.map +1 -0
  239. package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.d.ts +380 -0
  240. package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.d.ts.map +1 -0
  241. package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.js +11 -0
  242. package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.js.map +1 -0
  243. package/node_modules/@blockcast/fec-worker/dist/fec-worker.d.ts +14 -0
  244. package/node_modules/@blockcast/fec-worker/dist/fec-worker.d.ts.map +1 -0
  245. package/node_modules/@blockcast/fec-worker/dist/fec-worker.js +859 -0
  246. package/node_modules/@blockcast/fec-worker/dist/fec-worker.js.map +7 -0
  247. package/node_modules/@blockcast/fec-worker/dist/index.d.ts +10 -0
  248. package/node_modules/@blockcast/fec-worker/dist/index.d.ts.map +1 -0
  249. package/node_modules/@blockcast/fec-worker/dist/index.js +8 -0
  250. package/node_modules/@blockcast/fec-worker/dist/index.js.map +1 -0
  251. package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.d.ts +20 -0
  252. package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.d.ts.map +1 -0
  253. package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.js +370 -0
  254. package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.js.map +7 -0
  255. package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.d.ts +104 -0
  256. package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.d.ts.map +1 -0
  257. package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.js +181 -0
  258. package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.js.map +1 -0
  259. package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.d.ts +194 -0
  260. package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.d.ts.map +1 -0
  261. package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.js +62 -0
  262. package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.js.map +1 -0
  263. package/node_modules/@blockcast/fec-worker/dist/transfer.d.ts +9 -0
  264. package/node_modules/@blockcast/fec-worker/dist/transfer.d.ts.map +1 -0
  265. package/node_modules/@blockcast/fec-worker/dist/transfer.js +13 -0
  266. package/node_modules/@blockcast/fec-worker/dist/transfer.js.map +1 -0
  267. package/node_modules/@blockcast/fec-worker/package.json +59 -0
  268. package/node_modules/@blockcast/fec-worker/src/block-order.ts +93 -0
  269. package/node_modules/@blockcast/fec-worker/src/fec-worker-client.ts +392 -0
  270. package/node_modules/@blockcast/fec-worker/src/fec-worker-types.ts +399 -0
  271. package/node_modules/@blockcast/fec-worker/src/fec-worker.ts +1502 -0
  272. package/node_modules/@blockcast/fec-worker/src/index.ts +35 -0
  273. package/node_modules/@blockcast/fec-worker/src/shred-fec-worker.ts +589 -0
  274. package/node_modules/@blockcast/fec-worker/src/shred-worker-client.ts +222 -0
  275. package/node_modules/@blockcast/fec-worker/src/shred-worker-types.ts +209 -0
  276. package/node_modules/@blockcast/fec-worker/src/transfer.ts +12 -0
  277. package/node_modules/@blockcast/mmt-alta-parse/README.md +181 -0
  278. package/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
  279. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +57 -0
  280. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +1 -0
  281. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js +115 -0
  282. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +1 -0
  283. package/node_modules/@blockcast/mmt-alta-parse/package.json +50 -0
  284. package/node_modules/@blockcast/mmt-alta-parse/src/index.ts +136 -0
  285. package/node_modules/@blockcast/mmt-base/README.md +134 -0
  286. package/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
  287. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts +55 -0
  288. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts.map +1 -0
  289. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js +128 -0
  290. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js.map +1 -0
  291. package/node_modules/@blockcast/mmt-base/dist/index.d.ts +7 -0
  292. package/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +1 -0
  293. package/node_modules/@blockcast/mmt-base/dist/index.js +7 -0
  294. package/node_modules/@blockcast/mmt-base/dist/index.js.map +1 -0
  295. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +292 -0
  296. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +1 -0
  297. package/node_modules/@blockcast/mmt-base/dist/mmtp.js +612 -0
  298. package/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +1 -0
  299. package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts +18 -0
  300. package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts.map +1 -0
  301. package/node_modules/@blockcast/mmt-base/dist/net-constants.js +18 -0
  302. package/node_modules/@blockcast/mmt-base/dist/net-constants.js.map +1 -0
  303. package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts +47 -0
  304. package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts.map +1 -0
  305. package/node_modules/@blockcast/mmt-base/dist/ntp-short.js +54 -0
  306. package/node_modules/@blockcast/mmt-base/dist/ntp-short.js.map +1 -0
  307. package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts +57 -0
  308. package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts.map +1 -0
  309. package/node_modules/@blockcast/mmt-base/dist/serial-number.js +98 -0
  310. package/node_modules/@blockcast/mmt-base/dist/serial-number.js.map +1 -0
  311. package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts +312 -0
  312. package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts.map +1 -0
  313. package/node_modules/@blockcast/mmt-base/dist/signalling.js +637 -0
  314. package/node_modules/@blockcast/mmt-base/dist/signalling.js.map +1 -0
  315. package/node_modules/@blockcast/mmt-base/package.json +49 -0
  316. package/node_modules/@blockcast/mmt-base/src/fec-block-profile.ts +145 -0
  317. package/node_modules/@blockcast/mmt-base/src/index.ts +6 -0
  318. package/node_modules/@blockcast/mmt-base/src/mmtp.ts +892 -0
  319. package/node_modules/@blockcast/mmt-base/src/net-constants.ts +17 -0
  320. package/node_modules/@blockcast/mmt-base/src/ntp-short.ts +67 -0
  321. package/node_modules/@blockcast/mmt-base/src/serial-number.ts +107 -0
  322. package/node_modules/@blockcast/mmt-base/src/signalling.ts +862 -0
  323. package/node_modules/@blockcast/mmt-fec/README.md +271 -0
  324. package/node_modules/@blockcast/mmt-fec/dist/.build-stamp +0 -0
  325. package/node_modules/@blockcast/mmt-fec/dist/block-manager.d.ts +82 -0
  326. package/node_modules/@blockcast/mmt-fec/dist/block-manager.d.ts.map +1 -0
  327. package/node_modules/@blockcast/mmt-fec/dist/block-manager.js +296 -0
  328. package/node_modules/@blockcast/mmt-fec/dist/block-manager.js.map +1 -0
  329. package/node_modules/@blockcast/mmt-fec/dist/block-processor.d.ts +106 -0
  330. package/node_modules/@blockcast/mmt-fec/dist/block-processor.d.ts.map +1 -0
  331. package/node_modules/@blockcast/mmt-fec/dist/block-processor.js +288 -0
  332. package/node_modules/@blockcast/mmt-fec/dist/block-processor.js.map +1 -0
  333. package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.d.ts +46 -0
  334. package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.d.ts.map +1 -0
  335. package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.js +96 -0
  336. package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.js.map +1 -0
  337. package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.d.ts +33 -0
  338. package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.d.ts.map +1 -0
  339. package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.js +57 -0
  340. package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.js.map +1 -0
  341. package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.d.ts +205 -0
  342. package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.d.ts.map +1 -0
  343. package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.js +240 -0
  344. package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.js.map +1 -0
  345. package/node_modules/@blockcast/mmt-fec/dist/index.d.ts +28 -0
  346. package/node_modules/@blockcast/mmt-fec/dist/index.d.ts.map +1 -0
  347. package/node_modules/@blockcast/mmt-fec/dist/index.js +28 -0
  348. package/node_modules/@blockcast/mmt-fec/dist/index.js.map +1 -0
  349. package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.d.ts +20 -0
  350. package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.d.ts.map +1 -0
  351. package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.js +28 -0
  352. package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.js.map +1 -0
  353. package/node_modules/@blockcast/mmt-fec/dist/oti.d.ts +258 -0
  354. package/node_modules/@blockcast/mmt-fec/dist/oti.d.ts.map +1 -0
  355. package/node_modules/@blockcast/mmt-fec/dist/oti.js +382 -0
  356. package/node_modules/@blockcast/mmt-fec/dist/oti.js.map +1 -0
  357. package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.d.ts +41 -0
  358. package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.d.ts.map +1 -0
  359. package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.js +81 -0
  360. package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.js.map +1 -0
  361. package/node_modules/@blockcast/mmt-fec/dist/repair-group.d.ts +82 -0
  362. package/node_modules/@blockcast/mmt-fec/dist/repair-group.d.ts.map +1 -0
  363. package/node_modules/@blockcast/mmt-fec/dist/repair-group.js +170 -0
  364. package/node_modules/@blockcast/mmt-fec/dist/repair-group.js.map +1 -0
  365. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/README.md +181 -0
  366. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
  367. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +57 -0
  368. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +1 -0
  369. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.js +115 -0
  370. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +1 -0
  371. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/package.json +50 -0
  372. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/src/index.ts +136 -0
  373. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/README.md +134 -0
  374. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
  375. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts +55 -0
  376. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts.map +1 -0
  377. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js +128 -0
  378. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js.map +1 -0
  379. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.d.ts +7 -0
  380. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +1 -0
  381. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.js +7 -0
  382. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.js.map +1 -0
  383. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +292 -0
  384. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +1 -0
  385. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.js +612 -0
  386. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +1 -0
  387. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts +18 -0
  388. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts.map +1 -0
  389. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/net-constants.js +18 -0
  390. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/net-constants.js.map +1 -0
  391. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts +47 -0
  392. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts.map +1 -0
  393. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/ntp-short.js +54 -0
  394. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/ntp-short.js.map +1 -0
  395. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts +57 -0
  396. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts.map +1 -0
  397. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/serial-number.js +98 -0
  398. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/serial-number.js.map +1 -0
  399. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/signalling.d.ts +312 -0
  400. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/signalling.d.ts.map +1 -0
  401. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/signalling.js +637 -0
  402. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/signalling.js.map +1 -0
  403. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/package.json +49 -0
  404. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/fec-block-profile.ts +145 -0
  405. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/index.ts +6 -0
  406. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/mmtp.ts +892 -0
  407. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/net-constants.ts +17 -0
  408. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/ntp-short.ts +67 -0
  409. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/serial-number.ts +107 -0
  410. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/signalling.ts +862 -0
  411. package/node_modules/@blockcast/mmt-fec/package.json +79 -0
  412. package/node_modules/@blockcast/mmt-fec/src/block-manager.ts +371 -0
  413. package/node_modules/@blockcast/mmt-fec/src/block-processor.ts +439 -0
  414. package/node_modules/@blockcast/mmt-fec/src/cmaf-recovery.ts +127 -0
  415. package/node_modules/@blockcast/mmt-fec/src/cmaf-symbols.ts +68 -0
  416. package/node_modules/@blockcast/mmt-fec/src/fec-block-mapper.ts +310 -0
  417. package/node_modules/@blockcast/mmt-fec/src/index.ts +94 -0
  418. package/node_modules/@blockcast/mmt-fec/src/loc-symbols.ts +34 -0
  419. package/node_modules/@blockcast/mmt-fec/src/oti.ts +463 -0
  420. package/node_modules/@blockcast/mmt-fec/src/reorder-buffer.ts +101 -0
  421. package/node_modules/@blockcast/mmt-fec/src/repair-group.ts +195 -0
  422. package/node_modules/@blockcast/mmt-manifest-verify/dist/.build-stamp +0 -0
  423. package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.d.ts +28 -0
  424. package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.d.ts.map +1 -0
  425. package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.js +128 -0
  426. package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.js.map +1 -0
  427. package/node_modules/@blockcast/mmt-manifest-verify/dist/index.d.ts +4 -0
  428. package/node_modules/@blockcast/mmt-manifest-verify/dist/index.d.ts.map +1 -0
  429. package/node_modules/@blockcast/mmt-manifest-verify/dist/index.js +4 -0
  430. package/node_modules/@blockcast/mmt-manifest-verify/dist/index.js.map +1 -0
  431. package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.d.ts +37 -0
  432. package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.d.ts.map +1 -0
  433. package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.js +179 -0
  434. package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.js.map +1 -0
  435. package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.d.ts +99 -0
  436. package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.d.ts.map +1 -0
  437. package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.js +492 -0
  438. package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.js.map +1 -0
  439. package/node_modules/@blockcast/mmt-manifest-verify/package.json +45 -0
  440. package/node_modules/@blockcast/mmt-manifest-verify/src/admission.ts +171 -0
  441. package/node_modules/@blockcast/mmt-manifest-verify/src/index.ts +3 -0
  442. package/node_modules/@blockcast/mmt-manifest-verify/src/source-auth.ts +230 -0
  443. package/node_modules/@blockcast/mmt-manifest-verify/src/verifier.ts +669 -0
  444. package/node_modules/@noble/hashes/LICENSE +21 -0
  445. package/node_modules/@noble/hashes/README.md +521 -0
  446. package/node_modules/@noble/hashes/_assert.d.ts +17 -0
  447. package/node_modules/@noble/hashes/_assert.d.ts.map +1 -0
  448. package/node_modules/@noble/hashes/_assert.js +18 -0
  449. package/node_modules/@noble/hashes/_assert.js.map +1 -0
  450. package/node_modules/@noble/hashes/_blake.d.ts +14 -0
  451. package/node_modules/@noble/hashes/_blake.d.ts.map +1 -0
  452. package/node_modules/@noble/hashes/_blake.js +50 -0
  453. package/node_modules/@noble/hashes/_blake.js.map +1 -0
  454. package/node_modules/@noble/hashes/_md.d.ts +51 -0
  455. package/node_modules/@noble/hashes/_md.d.ts.map +1 -0
  456. package/node_modules/@noble/hashes/_md.js +162 -0
  457. package/node_modules/@noble/hashes/_md.js.map +1 -0
  458. package/node_modules/@noble/hashes/_u64.d.ts +55 -0
  459. package/node_modules/@noble/hashes/_u64.d.ts.map +1 -0
  460. package/node_modules/@noble/hashes/_u64.js +90 -0
  461. package/node_modules/@noble/hashes/_u64.js.map +1 -0
  462. package/node_modules/@noble/hashes/argon2.d.ts +32 -0
  463. package/node_modules/@noble/hashes/argon2.d.ts.map +1 -0
  464. package/node_modules/@noble/hashes/argon2.js +401 -0
  465. package/node_modules/@noble/hashes/argon2.js.map +1 -0
  466. package/node_modules/@noble/hashes/blake1.d.ts +106 -0
  467. package/node_modules/@noble/hashes/blake1.d.ts.map +1 -0
  468. package/node_modules/@noble/hashes/blake1.js +459 -0
  469. package/node_modules/@noble/hashes/blake1.js.map +1 -0
  470. package/node_modules/@noble/hashes/blake2.d.ts +116 -0
  471. package/node_modules/@noble/hashes/blake2.d.ts.map +1 -0
  472. package/node_modules/@noble/hashes/blake2.js +420 -0
  473. package/node_modules/@noble/hashes/blake2.js.map +1 -0
  474. package/node_modules/@noble/hashes/blake2b.d.ts +11 -0
  475. package/node_modules/@noble/hashes/blake2b.d.ts.map +1 -0
  476. package/node_modules/@noble/hashes/blake2b.js +14 -0
  477. package/node_modules/@noble/hashes/blake2b.js.map +1 -0
  478. package/node_modules/@noble/hashes/blake2s.d.ts +20 -0
  479. package/node_modules/@noble/hashes/blake2s.d.ts.map +1 -0
  480. package/node_modules/@noble/hashes/blake2s.js +24 -0
  481. package/node_modules/@noble/hashes/blake2s.js.map +1 -0
  482. package/node_modules/@noble/hashes/blake3.d.ts +54 -0
  483. package/node_modules/@noble/hashes/blake3.d.ts.map +1 -0
  484. package/node_modules/@noble/hashes/blake3.js +255 -0
  485. package/node_modules/@noble/hashes/blake3.js.map +1 -0
  486. package/node_modules/@noble/hashes/crypto.d.ts +2 -0
  487. package/node_modules/@noble/hashes/crypto.d.ts.map +1 -0
  488. package/node_modules/@noble/hashes/crypto.js +5 -0
  489. package/node_modules/@noble/hashes/crypto.js.map +1 -0
  490. package/node_modules/@noble/hashes/cryptoNode.d.ts +2 -0
  491. package/node_modules/@noble/hashes/cryptoNode.d.ts.map +1 -0
  492. package/node_modules/@noble/hashes/cryptoNode.js +18 -0
  493. package/node_modules/@noble/hashes/cryptoNode.js.map +1 -0
  494. package/node_modules/@noble/hashes/eskdf.d.ts +47 -0
  495. package/node_modules/@noble/hashes/eskdf.d.ts.map +1 -0
  496. package/node_modules/@noble/hashes/eskdf.js +166 -0
  497. package/node_modules/@noble/hashes/eskdf.js.map +1 -0
  498. package/node_modules/@noble/hashes/esm/_assert.d.ts +17 -0
  499. package/node_modules/@noble/hashes/esm/_assert.d.ts.map +1 -0
  500. package/node_modules/@noble/hashes/esm/_assert.js +15 -0
  501. package/node_modules/@noble/hashes/esm/_assert.js.map +1 -0
  502. package/node_modules/@noble/hashes/esm/_blake.d.ts +14 -0
  503. package/node_modules/@noble/hashes/esm/_blake.d.ts.map +1 -0
  504. package/node_modules/@noble/hashes/esm/_blake.js +45 -0
  505. package/node_modules/@noble/hashes/esm/_blake.js.map +1 -0
  506. package/node_modules/@noble/hashes/esm/_md.d.ts +51 -0
  507. package/node_modules/@noble/hashes/esm/_md.d.ts.map +1 -0
  508. package/node_modules/@noble/hashes/esm/_md.js +155 -0
  509. package/node_modules/@noble/hashes/esm/_md.js.map +1 -0
  510. package/node_modules/@noble/hashes/esm/_u64.d.ts +55 -0
  511. package/node_modules/@noble/hashes/esm/_u64.d.ts.map +1 -0
  512. package/node_modules/@noble/hashes/esm/_u64.js +67 -0
  513. package/node_modules/@noble/hashes/esm/_u64.js.map +1 -0
  514. package/node_modules/@noble/hashes/esm/argon2.d.ts +32 -0
  515. package/node_modules/@noble/hashes/esm/argon2.d.ts.map +1 -0
  516. package/node_modules/@noble/hashes/esm/argon2.js +392 -0
  517. package/node_modules/@noble/hashes/esm/argon2.js.map +1 -0
  518. package/node_modules/@noble/hashes/esm/blake1.d.ts +106 -0
  519. package/node_modules/@noble/hashes/esm/blake1.d.ts.map +1 -0
  520. package/node_modules/@noble/hashes/esm/blake1.js +452 -0
  521. package/node_modules/@noble/hashes/esm/blake1.js.map +1 -0
  522. package/node_modules/@noble/hashes/esm/blake2.d.ts +116 -0
  523. package/node_modules/@noble/hashes/esm/blake2.d.ts.map +1 -0
  524. package/node_modules/@noble/hashes/esm/blake2.js +413 -0
  525. package/node_modules/@noble/hashes/esm/blake2.js.map +1 -0
  526. package/node_modules/@noble/hashes/esm/blake2b.d.ts +11 -0
  527. package/node_modules/@noble/hashes/esm/blake2b.d.ts.map +1 -0
  528. package/node_modules/@noble/hashes/esm/blake2b.js +11 -0
  529. package/node_modules/@noble/hashes/esm/blake2b.js.map +1 -0
  530. package/node_modules/@noble/hashes/esm/blake2s.d.ts +20 -0
  531. package/node_modules/@noble/hashes/esm/blake2s.d.ts.map +1 -0
  532. package/node_modules/@noble/hashes/esm/blake2s.js +21 -0
  533. package/node_modules/@noble/hashes/esm/blake2s.js.map +1 -0
  534. package/node_modules/@noble/hashes/esm/blake3.d.ts +54 -0
  535. package/node_modules/@noble/hashes/esm/blake3.d.ts.map +1 -0
  536. package/node_modules/@noble/hashes/esm/blake3.js +251 -0
  537. package/node_modules/@noble/hashes/esm/blake3.js.map +1 -0
  538. package/node_modules/@noble/hashes/esm/crypto.d.ts +2 -0
  539. package/node_modules/@noble/hashes/esm/crypto.d.ts.map +1 -0
  540. package/node_modules/@noble/hashes/esm/crypto.js +2 -0
  541. package/node_modules/@noble/hashes/esm/crypto.js.map +1 -0
  542. package/node_modules/@noble/hashes/esm/cryptoNode.d.ts +2 -0
  543. package/node_modules/@noble/hashes/esm/cryptoNode.d.ts.map +1 -0
  544. package/node_modules/@noble/hashes/esm/cryptoNode.js +15 -0
  545. package/node_modules/@noble/hashes/esm/cryptoNode.js.map +1 -0
  546. package/node_modules/@noble/hashes/esm/eskdf.d.ts +47 -0
  547. package/node_modules/@noble/hashes/esm/eskdf.d.ts.map +1 -0
  548. package/node_modules/@noble/hashes/esm/eskdf.js +160 -0
  549. package/node_modules/@noble/hashes/esm/eskdf.js.map +1 -0
  550. package/node_modules/@noble/hashes/esm/hkdf.d.ts +36 -0
  551. package/node_modules/@noble/hashes/esm/hkdf.d.ts.map +1 -0
  552. package/node_modules/@noble/hashes/esm/hkdf.js +82 -0
  553. package/node_modules/@noble/hashes/esm/hkdf.js.map +1 -0
  554. package/node_modules/@noble/hashes/esm/hmac.d.ts +35 -0
  555. package/node_modules/@noble/hashes/esm/hmac.d.ts.map +1 -0
  556. package/node_modules/@noble/hashes/esm/hmac.js +86 -0
  557. package/node_modules/@noble/hashes/esm/hmac.js.map +1 -0
  558. package/node_modules/@noble/hashes/esm/index.d.ts +2 -0
  559. package/node_modules/@noble/hashes/esm/index.d.ts.map +1 -0
  560. package/node_modules/@noble/hashes/esm/index.js +33 -0
  561. package/node_modules/@noble/hashes/esm/index.js.map +1 -0
  562. package/node_modules/@noble/hashes/esm/legacy.d.ts +71 -0
  563. package/node_modules/@noble/hashes/esm/legacy.d.ts.map +1 -0
  564. package/node_modules/@noble/hashes/esm/legacy.js +281 -0
  565. package/node_modules/@noble/hashes/esm/legacy.js.map +1 -0
  566. package/node_modules/@noble/hashes/esm/package.json +10 -0
  567. package/node_modules/@noble/hashes/esm/pbkdf2.d.ts +23 -0
  568. package/node_modules/@noble/hashes/esm/pbkdf2.d.ts.map +1 -0
  569. package/node_modules/@noble/hashes/esm/pbkdf2.js +97 -0
  570. package/node_modules/@noble/hashes/esm/pbkdf2.js.map +1 -0
  571. package/node_modules/@noble/hashes/esm/ripemd160.d.ts +13 -0
  572. package/node_modules/@noble/hashes/esm/ripemd160.d.ts.map +1 -0
  573. package/node_modules/@noble/hashes/esm/ripemd160.js +13 -0
  574. package/node_modules/@noble/hashes/esm/ripemd160.js.map +1 -0
  575. package/node_modules/@noble/hashes/esm/scrypt.d.ts +34 -0
  576. package/node_modules/@noble/hashes/esm/scrypt.d.ts.map +1 -0
  577. package/node_modules/@noble/hashes/esm/scrypt.js +228 -0
  578. package/node_modules/@noble/hashes/esm/scrypt.js.map +1 -0
  579. package/node_modules/@noble/hashes/esm/sha1.d.ts +11 -0
  580. package/node_modules/@noble/hashes/esm/sha1.d.ts.map +1 -0
  581. package/node_modules/@noble/hashes/esm/sha1.js +11 -0
  582. package/node_modules/@noble/hashes/esm/sha1.js.map +1 -0
  583. package/node_modules/@noble/hashes/esm/sha2.d.ts +159 -0
  584. package/node_modules/@noble/hashes/esm/sha2.d.ts.map +1 -0
  585. package/node_modules/@noble/hashes/esm/sha2.js +375 -0
  586. package/node_modules/@noble/hashes/esm/sha2.js.map +1 -0
  587. package/node_modules/@noble/hashes/esm/sha256.d.ts +20 -0
  588. package/node_modules/@noble/hashes/esm/sha256.d.ts.map +1 -0
  589. package/node_modules/@noble/hashes/esm/sha256.js +20 -0
  590. package/node_modules/@noble/hashes/esm/sha256.js.map +1 -0
  591. package/node_modules/@noble/hashes/esm/sha3-addons.d.ts +142 -0
  592. package/node_modules/@noble/hashes/esm/sha3-addons.d.ts.map +1 -0
  593. package/node_modules/@noble/hashes/esm/sha3-addons.js +393 -0
  594. package/node_modules/@noble/hashes/esm/sha3-addons.js.map +1 -0
  595. package/node_modules/@noble/hashes/esm/sha3.d.ts +53 -0
  596. package/node_modules/@noble/hashes/esm/sha3.d.ts.map +1 -0
  597. package/node_modules/@noble/hashes/esm/sha3.js +234 -0
  598. package/node_modules/@noble/hashes/esm/sha3.js.map +1 -0
  599. package/node_modules/@noble/hashes/esm/sha512.d.ts +26 -0
  600. package/node_modules/@noble/hashes/esm/sha512.d.ts.map +1 -0
  601. package/node_modules/@noble/hashes/esm/sha512.js +26 -0
  602. package/node_modules/@noble/hashes/esm/sha512.js.map +1 -0
  603. package/node_modules/@noble/hashes/esm/utils.d.ts +161 -0
  604. package/node_modules/@noble/hashes/esm/utils.d.ts.map +1 -0
  605. package/node_modules/@noble/hashes/esm/utils.js +281 -0
  606. package/node_modules/@noble/hashes/esm/utils.js.map +1 -0
  607. package/node_modules/@noble/hashes/hkdf.d.ts +36 -0
  608. package/node_modules/@noble/hashes/hkdf.d.ts.map +1 -0
  609. package/node_modules/@noble/hashes/hkdf.js +88 -0
  610. package/node_modules/@noble/hashes/hkdf.js.map +1 -0
  611. package/node_modules/@noble/hashes/hmac.d.ts +35 -0
  612. package/node_modules/@noble/hashes/hmac.d.ts.map +1 -0
  613. package/node_modules/@noble/hashes/hmac.js +91 -0
  614. package/node_modules/@noble/hashes/hmac.js.map +1 -0
  615. package/node_modules/@noble/hashes/index.d.ts +1 -0
  616. package/node_modules/@noble/hashes/index.d.ts.map +1 -0
  617. package/node_modules/@noble/hashes/index.js +33 -0
  618. package/node_modules/@noble/hashes/index.js.map +1 -0
  619. package/node_modules/@noble/hashes/legacy.d.ts +71 -0
  620. package/node_modules/@noble/hashes/legacy.d.ts.map +1 -0
  621. package/node_modules/@noble/hashes/legacy.js +287 -0
  622. package/node_modules/@noble/hashes/legacy.js.map +1 -0
  623. package/node_modules/@noble/hashes/package.json +266 -0
  624. package/node_modules/@noble/hashes/pbkdf2.d.ts +23 -0
  625. package/node_modules/@noble/hashes/pbkdf2.d.ts.map +1 -0
  626. package/node_modules/@noble/hashes/pbkdf2.js +101 -0
  627. package/node_modules/@noble/hashes/pbkdf2.js.map +1 -0
  628. package/node_modules/@noble/hashes/ripemd160.d.ts +13 -0
  629. package/node_modules/@noble/hashes/ripemd160.d.ts.map +1 -0
  630. package/node_modules/@noble/hashes/ripemd160.js +16 -0
  631. package/node_modules/@noble/hashes/ripemd160.js.map +1 -0
  632. package/node_modules/@noble/hashes/scrypt.d.ts +34 -0
  633. package/node_modules/@noble/hashes/scrypt.d.ts.map +1 -0
  634. package/node_modules/@noble/hashes/scrypt.js +232 -0
  635. package/node_modules/@noble/hashes/scrypt.js.map +1 -0
  636. package/node_modules/@noble/hashes/sha1.d.ts +11 -0
  637. package/node_modules/@noble/hashes/sha1.d.ts.map +1 -0
  638. package/node_modules/@noble/hashes/sha1.js +14 -0
  639. package/node_modules/@noble/hashes/sha1.js.map +1 -0
  640. package/node_modules/@noble/hashes/sha2.d.ts +159 -0
  641. package/node_modules/@noble/hashes/sha2.d.ts.map +1 -0
  642. package/node_modules/@noble/hashes/sha2.js +384 -0
  643. package/node_modules/@noble/hashes/sha2.js.map +1 -0
  644. package/node_modules/@noble/hashes/sha256.d.ts +20 -0
  645. package/node_modules/@noble/hashes/sha256.d.ts.map +1 -0
  646. package/node_modules/@noble/hashes/sha256.js +23 -0
  647. package/node_modules/@noble/hashes/sha256.js.map +1 -0
  648. package/node_modules/@noble/hashes/sha3-addons.d.ts +142 -0
  649. package/node_modules/@noble/hashes/sha3-addons.d.ts.map +1 -0
  650. package/node_modules/@noble/hashes/sha3-addons.js +402 -0
  651. package/node_modules/@noble/hashes/sha3-addons.js.map +1 -0
  652. package/node_modules/@noble/hashes/sha3.d.ts +53 -0
  653. package/node_modules/@noble/hashes/sha3.d.ts.map +1 -0
  654. package/node_modules/@noble/hashes/sha3.js +239 -0
  655. package/node_modules/@noble/hashes/sha3.js.map +1 -0
  656. package/node_modules/@noble/hashes/sha512.d.ts +26 -0
  657. package/node_modules/@noble/hashes/sha512.d.ts.map +1 -0
  658. package/node_modules/@noble/hashes/sha512.js +29 -0
  659. package/node_modules/@noble/hashes/sha512.js.map +1 -0
  660. package/node_modules/@noble/hashes/src/_assert.ts +22 -0
  661. package/node_modules/@noble/hashes/src/_blake.ts +50 -0
  662. package/node_modules/@noble/hashes/src/_md.ts +176 -0
  663. package/node_modules/@noble/hashes/src/_u64.ts +91 -0
  664. package/node_modules/@noble/hashes/src/argon2.ts +497 -0
  665. package/node_modules/@noble/hashes/src/blake1.ts +534 -0
  666. package/node_modules/@noble/hashes/src/blake2.ts +486 -0
  667. package/node_modules/@noble/hashes/src/blake2b.ts +10 -0
  668. package/node_modules/@noble/hashes/src/blake2s.ts +20 -0
  669. package/node_modules/@noble/hashes/src/blake3.ts +272 -0
  670. package/node_modules/@noble/hashes/src/crypto.ts +9 -0
  671. package/node_modules/@noble/hashes/src/cryptoNode.ts +15 -0
  672. package/node_modules/@noble/hashes/src/eskdf.ts +187 -0
  673. package/node_modules/@noble/hashes/src/hkdf.ts +88 -0
  674. package/node_modules/@noble/hashes/src/hmac.ts +94 -0
  675. package/node_modules/@noble/hashes/src/index.ts +31 -0
  676. package/node_modules/@noble/hashes/src/legacy.ts +293 -0
  677. package/node_modules/@noble/hashes/src/pbkdf2.ts +122 -0
  678. package/node_modules/@noble/hashes/src/ripemd160.ts +12 -0
  679. package/node_modules/@noble/hashes/src/scrypt.ts +257 -0
  680. package/node_modules/@noble/hashes/src/sha1.ts +10 -0
  681. package/node_modules/@noble/hashes/src/sha2.ts +402 -0
  682. package/node_modules/@noble/hashes/src/sha256.ts +24 -0
  683. package/node_modules/@noble/hashes/src/sha3-addons.ts +499 -0
  684. package/node_modules/@noble/hashes/src/sha3.ts +258 -0
  685. package/node_modules/@noble/hashes/src/sha512.ts +34 -0
  686. package/node_modules/@noble/hashes/src/utils.ts +395 -0
  687. package/node_modules/@noble/hashes/utils.d.ts +161 -0
  688. package/node_modules/@noble/hashes/utils.d.ts.map +1 -0
  689. package/node_modules/@noble/hashes/utils.js +313 -0
  690. package/node_modules/@noble/hashes/utils.js.map +1 -0
  691. package/package.json +179 -8
  692. package/schemas/catalog.schema.json +357 -0
  693. package/src/align.ts +239 -0
  694. package/src/audio-init-parser.ts +189 -0
  695. package/src/audio-ring-buffer.ts +120 -0
  696. package/src/authored-timing.ts +63 -0
  697. package/src/block-policy.ts +903 -0
  698. package/src/carp-catalog.ts +1065 -0
  699. package/src/catalog-fec-params.ts +729 -0
  700. package/src/catalog-network-source.ts +74 -0
  701. package/src/catalog-validator.ts +34 -0
  702. package/src/cmaf-block-assembler.ts +1255 -0
  703. package/src/cmaf-group-batcher.ts +104 -0
  704. package/src/cmcd-producer.ts +329 -0
  705. package/src/codec-utils.ts +1984 -0
  706. package/src/conceal-timeline.ts +293 -0
  707. package/src/delivery-gap.ts +147 -0
  708. package/src/duplicate-ledger.ts +372 -0
  709. package/src/fec-codec.ts +61 -0
  710. package/src/fec-manager.ts +4298 -0
  711. package/src/fec-starvation-detector.ts +128 -0
  712. package/src/fec-state.ts +219 -0
  713. package/src/fec-worker-setup.ts +283 -0
  714. package/src/fmp4-completion.ts +47 -0
  715. package/src/fmp4-reader.ts +112 -0
  716. package/src/index.ts +590 -21
  717. package/src/iso-block-locator.ts +329 -0
  718. package/src/iso-bmff-timing.ts +813 -0
  719. package/src/loc.ts +128 -154
  720. package/src/manifest-auth-plan.ts +107 -0
  721. package/src/media-router.ts +841 -0
  722. package/src/mfu-reassembler.ts +3480 -155
  723. package/src/mfu-reassembly-policy.ts +134 -0
  724. package/src/mmtp-container-parser.ts +2380 -0
  725. package/src/mmtp-decode-pipeline.ts +406 -0
  726. package/src/mmtp-router.ts +4860 -0
  727. package/src/mmtp-to-cmaf.ts +977 -0
  728. package/src/multicast-auth-catalog.ts +340 -0
  729. package/src/multicast-input.ts +793 -0
  730. package/src/multicast-manager.ts +2783 -0
  731. package/src/multicast-transport.ts +1058 -0
  732. package/src/multicast-unwrap.ts +132 -0
  733. package/src/no-modules.ts +21 -0
  734. package/src/phase14-catalog-override.ts +336 -0
  735. package/src/repair-fetch.ts +772 -0
  736. package/src/repair-layers.ts +533 -0
  737. package/src/sample-combiner.ts +938 -0
  738. package/src/signaling.ts +78 -0
  739. package/src/source-auth.ts +1 -0
  740. package/src/switching-set-alignment.ts +109 -0
  741. package/src/timestamp-codec.ts +265 -0
  742. package/src/track-pipeline-setup.ts +205 -0
  743. package/src/track-pipeline.ts +332 -0
  744. package/src/track-session.ts +1010 -0
  745. package/src/transport-pool.ts +128 -0
  746. package/src/wasm-mfu-reassembler.ts +405 -0
  747. package/src/wasm-mmtp-container-parser.ts +23 -0
  748. package/test-vectors/align.json +199 -0
  749. package/test-vectors/catalog/README.md +106 -0
  750. package/test-vectors/catalog/negative/archive-origin-with-path.json +33 -0
  751. package/test-vectors/catalog/negative/archive-unknown-field.json +34 -0
  752. package/test-vectors/catalog/negative/fec-block-profile-missing.json +51 -0
  753. package/test-vectors/catalog/negative/fec-block-profile-unknown.json +52 -0
  754. package/test-vectors/catalog/negative/fec-enhancement-repair-removed.json +28 -0
  755. package/test-vectors/catalog/negative/fec-repair-layer-geometry.json +33 -0
  756. package/test-vectors/catalog/negative/fec-repair-layer-missing-symbols.json +31 -0
  757. package/test-vectors/catalog/negative/fec-repair-multiple-depends.json +32 -0
  758. package/test-vectors/catalog/negative/fec-repair-overlay-with-layer.json +34 -0
  759. package/test-vectors/catalog/negative/fec-repair-priority-out-of-band.json +24 -0
  760. package/test-vectors/catalog/negative/fec-repair-source-symbols-without-scope.json +33 -0
  761. package/test-vectors/catalog/negative/legacy-selection-params.json +15 -0
  762. package/test-vectors/catalog/negative/media-track-repair-layer.json +16 -0
  763. package/test-vectors/catalog/negative/missing-mmtp-fields.json +12 -0
  764. package/test-vectors/catalog/negative/multicast-auth-padded-key.json +37 -0
  765. package/test-vectors/catalog/negative/multicast-auth-reused-track-format.json +53 -0
  766. package/test-vectors/catalog/negative/multicast-auth-trailing-pad-bits.json +37 -0
  767. package/test-vectors/catalog/negative/multicast-endpoint-missing-protocol-and-source.json +24 -0
  768. package/test-vectors/catalog/negative/multicast-network-source-empty.json +44 -0
  769. package/test-vectors/catalog/negative/multicast-packet-id-signaling.json +26 -0
  770. package/test-vectors/catalog/negative/network-source-object.json +25 -0
  771. package/test-vectors/catalog/negative/raptorq-source-symbols-over-max.json +24 -0
  772. package/test-vectors/catalog/negative/raptorq-unaligned-symbol.json +27 -0
  773. package/test-vectors/catalog/negative/repair-track-legacy-shape.json +19 -0
  774. package/test-vectors/catalog/negative/track-role-empty.json +16 -0
  775. package/test-vectors/catalog/negative/track-role-non-string.json +16 -0
  776. package/test-vectors/catalog/positive/archive-origin.json +34 -0
  777. package/test-vectors/catalog/positive/fec-iso-ssbg1.json +52 -0
  778. package/test-vectors/catalog/positive/fec-layered-repair.json +106 -0
  779. package/test-vectors/catalog/positive/fec-multicast.json +52 -0
  780. package/test-vectors/catalog/positive/flat-av.json +30 -0
  781. package/test-vectors/catalog/positive/multicast-auth-rotation.json +55 -0
  782. package/test-vectors/catalog/positive/multicast-native-ssm-no-network-source.json +43 -0
  783. package/test-vectors/catalog/positive/ticks-and-mixed-timescales.json +39 -0
  784. package/test-vectors/catalog/positive/track-role.json +49 -0
  785. package/test-vectors/catalog/releases/moqcast-native-v16-v1/catalog.json +65 -0
  786. package/test-vectors/catalog/releases/moqcast-native-v16-v1.archive.sha256 +1 -0
  787. package/test-vectors/catalog-multicast-auth-v1.json +26 -0
  788. package/test-vectors/catalog-validation.json +732 -0
  789. package/test-vectors/catalog-validator-public-api.mjs +25 -0
  790. package/test-vectors/generate-source-auth-fixture.mjs +114 -0
  791. package/test-vectors/hevc-x265-repeat-headers-v1.json +40 -0
  792. package/test-vectors/source-auth-v1.json +61 -0
  793. package/test-vectors/source-contiguity-v1.json +131 -0
  794. package/test-vectors/subsample-placement-v1.json +230 -0
  795. package/dist/mmtp.d.ts +0 -73
  796. package/dist/mmtp.d.ts.map +0 -1
  797. package/dist/mmtp.js +0 -210
  798. package/dist/mmtp.js.map +0 -1
  799. package/src/mmtp.ts +0 -277
@@ -0,0 +1,4860 @@
1
+ /**
2
+ * MMTP packet processing and frame routing layer.
3
+ *
4
+ * Delegates MMTP header parsing, MFU extraction, reassembly, and keyframe
5
+ * detection to MmtpContainerParser while owning FEC orchestration, sync
6
+ * gating, CMAF assembly, and callback routing.
7
+ *
8
+ * Extracted from MulticastManager — this module owns MMTP-layer concerns:
9
+ * FEC symbol feeding, sync gating, MSE segment assembly, and frame routing.
10
+ * It does NOT own network I/O, watchdog timers, or multicast join/leave.
11
+ *
12
+ * @packageDocumentation
13
+ */
14
+
15
+ import {
16
+ MMTP_SIGNALING_PACKET_ID,
17
+ parseMMTPHeader,
18
+ parseMoqRepairFrameWithHeader,
19
+ serial32Add,
20
+ serial32Distance,
21
+ serial32Precedes,
22
+ type FecBlockProfile,
23
+ type MMTPHeader,
24
+ } from "@blockcast/mmt-base";
25
+ import type { IsoHeldSource } from "./fec-manager.js";
26
+ import type { IsoPlacedSource, IsoRepairPlacement } from "./iso-block-locator.js";
27
+ import { isMfuReassemblyError } from "./mfu-reassembler.js";
28
+ import type {
29
+ MfuReassemblerBackend,
30
+ MmtpSourcePath,
31
+ MmtpDeliveryProvenance,
32
+ } from "./mfu-reassembler.js";
33
+ import type { MfuReassemblyPolicy } from "./mfu-reassembly-policy.js";
34
+ import { mmtpTimestampToMicros } from "./codec-utils.js";
35
+ import { scaleAuthoredTiming, type AuthoredTiming } from "./authored-timing.js";
36
+ import {
37
+ processFecBlock,
38
+ processFecSymbol,
39
+ FecBlockMapper,
40
+ type ProcessFecBlockResult,
41
+ } from "@blockcast/mmt-fec";
42
+ import {
43
+ CmafBlockAssembler,
44
+ type BlockAssemblerConfig,
45
+ type CmafBlockSample,
46
+ } from "./cmaf-block-assembler.js";
47
+ import {
48
+ MmtpContainerParser,
49
+ isMfuKeyframeMetadata,
50
+ type FecReleaseGate,
51
+ type FrameMeta as ParserFrameMeta,
52
+ type ParsedMmtpPacket,
53
+ } from "./mmtp-container-parser.js";
54
+ import { type TrackRole } from "./carp-catalog.js";
55
+ import {
56
+ decideGap,
57
+ type BlockClass,
58
+ type ClassifierMode,
59
+ type ClassifierReason,
60
+ type FrameGapDecision,
61
+ type GapFrameEvidence,
62
+ type SourceSymbolEvidence,
63
+ } from "./block-policy.js";
64
+ import {
65
+ duplicateRetentionMs,
66
+ StreamDuplicateLedgers,
67
+ type DuplicateHorizon,
68
+ } from "./duplicate-ledger.js";
69
+ import type {
70
+ ManifestAuthMode,
71
+ ManifestObjectIdentity,
72
+ ManifestTrackContext,
73
+ ManifestVerifier,
74
+ } from "@blockcast/mmt-manifest-verify";
75
+
76
+ // ─── Constants ─────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Keyframe loss timeout slack factor (Phase 13, tracks WITHOUT FEC only).
80
+ *
81
+ * FEC tracks never arm the keyframe-lost timer: vanished FEC blocks are
82
+ * materialised from SBN evidence and resolve through their catalog-derived
83
+ * repair and fail deadlines, and the stall/conceal decision rides on the
84
+ * first frame released after the gap (`RoutedFrameMeta.gapBefore`).
85
+ *
86
+ * Derivation: The keyframe loss timer must fire after one full keyframe group
87
+ * has been missed. Group duration must come from explicit catalog timing; FEC
88
+ * sourceSymbols is a block width, not a GOP length. Multicast jitter means the
89
+ * last packet of a group may arrive up to ~0.5 group durations late (network
90
+ * reordering + FEC recovery latency). The total timeout is therefore:
91
+ *
92
+ * timeout = group_duration * (1.0 + 0.5_jitter) = group_duration * 1.5
93
+ *
94
+ * The 0.5 jitter margin covers: (a) FEC interleave recovery window (up to D
95
+ * frames), (b) network reordering within one interleave depth, and (c) OS
96
+ * socket buffer draining latency. This is tight enough to detect loss within
97
+ * one missing group while avoiding false positives from normal jitter.
98
+ */
99
+ export const KEYFRAME_LOST_TIMEOUT_SLACK = 1.5;
100
+
101
+ /**
102
+ * Largest delay a JavaScript timer honours: HTML and Node store it as a signed
103
+ * 32-bit millisecond count, and a larger value overflows to fire almost at
104
+ * once. A keyframe-lost timeout above it, fixed or read, would therefore
105
+ * declare every keyframe lost immediately, so it is refused like any other
106
+ * unusable value.
107
+ */
108
+ const MAX_TIMER_DELAY_MS = 0x7fffffff;
109
+
110
+ function isRouterDebugEnabled(): boolean {
111
+ const globalDebug = (globalThis as { __MMTP_ROUTER_DEBUG?: unknown }).__MMTP_ROUTER_DEBUG;
112
+ if (globalDebug === true || globalDebug === "true" || globalDebug === "1") return true;
113
+ const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
114
+ ?.env;
115
+ return env?.["MMTP_ROUTER_DEBUG"] === "1" || env?.["MMTP_ROUTER_DEBUG"] === "true";
116
+ }
117
+ function debugRouter(message: string): void {
118
+ if (isRouterDebugEnabled()) console.debug(message);
119
+ }
120
+
121
+ // ─── Types ──────────────────────────────────────────────────────────
122
+
123
+ export type RoutedFrameMeta = {
124
+ /** Authored ISO-BMFF presentation timestamp, in microseconds. */
125
+ timestamp: number;
126
+ /**
127
+ * Raw MMTP short-NTP header timestamp. This is transport timing metadata
128
+ * only; it is deliberately separate from the authored media timestamp.
129
+ */
130
+ mmtpTimestamp?: bigint;
131
+ keyframe: boolean;
132
+ /** Exact ISO-BMFF timing in the media track's native timescale. */
133
+ authoredTiming?: AuthoredTiming;
134
+ /** Transport that supplied the accepted FI=1 head (or complete FI=0 frame). */
135
+ source?: MmtpSourcePath;
136
+ /**
137
+ * Whole-frame delivery verdict folded across every contributing fragment.
138
+ *
139
+ * Distinct from `source` on purpose. `source` answers a routing question —
140
+ * which transport's copy is this — and deliberately lets the head fragment
141
+ * decide. This answers an evidence question: did every byte of this frame
142
+ * physically arrive over multicast? A frame can be routed as 'multicast'
143
+ * and still be 'mixed'.
144
+ */
145
+ deliveryProvenance?: MmtpDeliveryProvenance;
146
+ packetId?: number;
147
+ packetSequenceNumber?: number;
148
+ /** Every authoritative MMTP source-packet sequence that formed this frame. */
149
+ packetSequenceNumbers?: number[];
150
+ /** Subset of packetSequenceNumbers reconstructed by FEC. */
151
+ repairedPacketSequenceNumbers?: number[];
152
+ /** Native MMTP MPU group coordinate. */
153
+ mpuSequenceNumber?: number;
154
+ /**
155
+ * Timed-MFU sample identity `(movie_fragment_sequence_number, sample_number)`
156
+ * from the MFU data-unit header. Absent when the MFU header is untimed.
157
+ */
158
+ movieFragmentSequenceNumber?: number;
159
+ sampleNumber?: number;
160
+ /** Immutable catalog identity supplied by the owning ingress subscription. */
161
+ catalogTrackName?: string;
162
+ recovered?: boolean;
163
+ /**
164
+ * True when this frame opens playback after the router suppressed earlier
165
+ * video deltas while waiting for a sync keyframe. Downstream decoders can
166
+ * use this to treat the following dependency chain as provisional.
167
+ */
168
+ sourceDiscontinuity?: boolean;
169
+ /**
170
+ * Stall/conceal decision for the first frame released after a gap: its
171
+ * FEC blocks resolved with lost media, or authored decode time shows a
172
+ * missing sample. Sinks act on it in order, on this frame. It is the same
173
+ * object the manager emits as its `gap` event.
174
+ */
175
+ gapBefore?: FrameGapDecision;
176
+ };
177
+
178
+ export interface MmtpIngressContext {
179
+ tracks: readonly Readonly<{ name: string; packetId: number }>[];
180
+ }
181
+
182
+ /** One physical stream: an MMTP packet ID and the catalog track it carries. */
183
+ export interface MmtpStreamIdentity {
184
+ readonly packetId: number;
185
+ readonly catalogTrackName: string;
186
+ }
187
+
188
+ /**
189
+ * Where a staged stream switch committed, relative to the outgoing stream.
190
+ *
191
+ * - `outgoing-reached-rap`: the outgoing stream was about to deliver a frame
192
+ * at or past the incoming RAP's presentation time. The splice is exact:
193
+ * the outgoing stream delivered every frame before the RAP that reached
194
+ * the router, and none at or after it. An MSE assembler is the one
195
+ * exception, since its flush is unbounded (see `stageStreamSwitch`).
196
+ * - `outgoing-ahead`: when the incoming RAP arrived, the outgoing stream had
197
+ * already delivered a frame at or past it. The switch commits on that RAP
198
+ * at once; incoming frames up to `outgoingHighWater` cover media time the
199
+ * outgoing stream already delivered.
200
+ * - `outgoing-overdue`: the incoming stream reached its next RAP before the
201
+ * outgoing stream reached the one held for it, so the outgoing stream is at
202
+ * least a GOP behind (stalled, or losing media) and waiting longer only
203
+ * widens the gap. The switch commits on the held RAP: the held GOP is
204
+ * delivered, then the newer RAP. It overlaps nothing, since every outgoing
205
+ * frame delivered was compared against the held RAP and is before it.
206
+ */
207
+ export type StreamSwitchCommitReason =
208
+ | "outgoing-reached-rap"
209
+ | "outgoing-ahead"
210
+ | "outgoing-overdue";
211
+
212
+ /** Presentation position of one frame, in the router's frame vocabulary. */
213
+ export interface StreamSwitchPosition {
214
+ /** Authored presentation timestamp, in microseconds. */
215
+ readonly timestamp: number;
216
+ /** Exact authored timing, when the frame carried it. */
217
+ readonly authoredTiming?: Readonly<AuthoredTiming>;
218
+ }
219
+
220
+ /** A staged stream switch, at the moment it commits. */
221
+ export interface StreamSwitchCommit {
222
+ readonly from: MmtpStreamIdentity;
223
+ readonly to: MmtpStreamIdentity;
224
+ /** The incoming RAP the switch commits on: the first `to` frame delivered. */
225
+ readonly rap: StreamSwitchPosition & {
226
+ readonly mpuSequenceNumber?: number;
227
+ };
228
+ /**
229
+ * The furthest-presented outgoing frame delivered before the commit,
230
+ * counting the gap fills the commit released, or undefined when the
231
+ * outgoing stream delivered none.
232
+ */
233
+ readonly outgoingHighWater: StreamSwitchPosition | undefined;
234
+ readonly reason: StreamSwitchCommitReason;
235
+ /**
236
+ * Incoming frames never delivered: the deltas before the incoming stream's
237
+ * first RAP (the GOP it was admitted into mid-way), and a held GOP a reset
238
+ * of the incoming stream invalidated.
239
+ */
240
+ readonly droppedIncomingFrames: number;
241
+ }
242
+
243
+ /** What MmtpRouter.stageStreamSwitch needs. */
244
+ export interface StreamSwitchOptions {
245
+ /** The stream being replaced: a registered video packet ID. */
246
+ readonly from: MmtpStreamIdentity;
247
+ /** The replacing stream: a registered video packet ID other than `from`'s. */
248
+ readonly to: MmtpStreamIdentity;
249
+ /**
250
+ * Called synchronously at the commit, after `from`'s packet ID is retired
251
+ * and before `to`'s withheld init and its RAP are delivered. Throwing fails
252
+ * the commit closed: `to` is retired as well and nothing of it is
253
+ * delivered, since `from` can no longer be restored.
254
+ */
255
+ readonly onCommit: (commit: StreamSwitchCommit) => void;
256
+ }
257
+
258
+ /** A switch MmtpRouter.stageStreamSwitch staged. */
259
+ export interface StagedStreamSwitch {
260
+ /**
261
+ * Abandon the switch before it commits: `to` is no longer withheld and its
262
+ * withheld init is dropped. Returns false when the switch already committed
263
+ * or was cancelled.
264
+ */
265
+ cancel(): boolean;
266
+ }
267
+
268
+ /** One incoming frame withheld until the switch commits. */
269
+ interface HeldSwitchFrame {
270
+ readonly data: Uint8Array;
271
+ readonly meta: ParserFrameMeta;
272
+ /** The FEC coordinates the frame arrived under, for its MSE assembler. */
273
+ readonly fecInfo: { sbn: number; esi: number; oversized?: boolean } | undefined;
274
+ }
275
+
276
+ /** Router-side state of a staged stream switch. */
277
+ interface StreamSwitchState {
278
+ readonly from: MmtpStreamIdentity;
279
+ readonly to: MmtpStreamIdentity;
280
+ readonly onCommit: (commit: StreamSwitchCommit) => void;
281
+ /** Null until the incoming stream's first RAP; then that RAP and every incoming frame after it. */
282
+ held: HeldSwitchFrame[] | null;
283
+ /** The incoming stream's newest init, withheld until the commit. */
284
+ heldInit: Uint8Array | undefined;
285
+ droppedIncomingFrames: number;
286
+ }
287
+
288
+ function assertStreamIdentity(role: string, stream: MmtpStreamIdentity | undefined): void {
289
+ if (
290
+ stream === undefined ||
291
+ !Number.isInteger(stream.packetId) ||
292
+ stream.packetId <= MMTP_SIGNALING_PACKET_ID ||
293
+ typeof stream.catalogTrackName !== "string" ||
294
+ stream.catalogTrackName.length === 0
295
+ ) {
296
+ throw new Error(
297
+ `[MmtpRouter] stream switch ${role} needs a track packet ID and a non-empty catalog track name`,
298
+ );
299
+ }
300
+ }
301
+
302
+ function isStream(
303
+ meta: { packetId: number; catalogTrackName?: string },
304
+ stream: MmtpStreamIdentity,
305
+ ): boolean {
306
+ return meta.packetId === stream.packetId && meta.catalogTrackName === stream.catalogTrackName;
307
+ }
308
+
309
+ /**
310
+ * Presentation order of two frames of different renditions. Exact when both
311
+ * carry authored timing (renditions may use different timescales); otherwise
312
+ * by the microsecond timestamps the router emits.
313
+ */
314
+ function comparePresentation(a: StreamSwitchPosition, b: StreamSwitchPosition): number {
315
+ const at = a.authoredTiming;
316
+ const bt = b.authoredTiming;
317
+ if (at !== undefined && bt !== undefined) {
318
+ const left = at.presentationTime * BigInt(bt.mediaTimescale);
319
+ const right = bt.presentationTime * BigInt(at.mediaTimescale);
320
+ return left < right ? -1 : left > right ? 1 : 0;
321
+ }
322
+ return a.timestamp - b.timestamp;
323
+ }
324
+
325
+ function switchPosition(meta: {
326
+ timestamp: number;
327
+ authoredTiming?: AuthoredTiming;
328
+ }): StreamSwitchPosition {
329
+ return Object.freeze({
330
+ timestamp: meta.timestamp,
331
+ ...(meta.authoredTiming === undefined
332
+ ? {}
333
+ : { authoredTiming: Object.freeze({ ...meta.authoredTiming }) }),
334
+ });
335
+ }
336
+
337
+ /** What one ingress track list says about one packet ID. */
338
+ interface IngressPacketEntry {
339
+ /** The one non-empty catalog name the list gives the packet ID, if exactly one. */
340
+ readonly name?: string;
341
+ /** More than one non-empty catalog name claims the packet ID. */
342
+ readonly ambiguous: boolean;
343
+ }
344
+
345
+ function indexIngressTracks(
346
+ tracks: MmtpIngressContext["tracks"],
347
+ ): ReadonlyMap<number, IngressPacketEntry> {
348
+ const names = new Map<number, Set<string>>();
349
+ for (const track of tracks) {
350
+ let packetNames = names.get(track.packetId);
351
+ if (!packetNames) {
352
+ packetNames = new Set();
353
+ names.set(track.packetId, packetNames);
354
+ }
355
+ if (track.name.length > 0) packetNames.add(track.name);
356
+ }
357
+ const index = new Map<number, IngressPacketEntry>();
358
+ for (const [packetId, packetNames] of names) {
359
+ index.set(
360
+ packetId,
361
+ packetNames.size === 1
362
+ ? { name: packetNames.values().next().value, ambiguous: false }
363
+ : { ambiguous: packetNames.size > 1 },
364
+ );
365
+ }
366
+ return index;
367
+ }
368
+
369
+ /**
370
+ * Index of each frozen ingress track list (BLO-36599). MulticastManager.join
371
+ * freezes the list it hands the transport, which passes that same list with
372
+ * every packet, so the packetId -> name mapping is derived once per join
373
+ * rather than filtered up to four times per packet. A list that is not frozen
374
+ * may still change, so it is indexed afresh on every packet as before.
375
+ */
376
+ const frozenIngressIndexes = new WeakMap<object, ReadonlyMap<number, IngressPacketEntry>>();
377
+
378
+ function ingressEntry(
379
+ ingress: MmtpIngressContext,
380
+ packetId: number,
381
+ ): IngressPacketEntry | undefined {
382
+ const tracks = ingress.tracks;
383
+ if (!Object.isFrozen(tracks)) return indexIngressTracks(tracks).get(packetId);
384
+ let index = frozenIngressIndexes.get(tracks);
385
+ if (!index) {
386
+ index = indexIngressTracks(tracks);
387
+ frozenIngressIndexes.set(tracks, index);
388
+ }
389
+ return index.get(packetId);
390
+ }
391
+
392
+ function catalogTrackNameForPacket(
393
+ ingress: MmtpIngressContext | undefined,
394
+ packetId: number,
395
+ ): string | undefined {
396
+ if (!ingress) return undefined;
397
+ return ingressEntry(ingress, packetId)?.name;
398
+ }
399
+
400
+ function hasAmbiguousCatalogTrackName(
401
+ ingress: MmtpIngressContext | undefined,
402
+ packetId: number,
403
+ ): boolean {
404
+ if (!ingress) return false;
405
+ return ingressEntry(ingress, packetId)?.ambiguous === true;
406
+ }
407
+
408
+ function ingressHasPacketId(
409
+ ingress: MmtpIngressContext | undefined,
410
+ packetId: number,
411
+ ): boolean {
412
+ // packet_id 0 is the MMTP signaling flow, never a track's, whatever the ingress.
413
+ if (packetId === MMTP_SIGNALING_PACKET_ID) return false;
414
+ // Direct/legacy callers have no catalog-backed ingress policy to enforce.
415
+ // MulticastManager supplies a populated context whenever packetIds are selected.
416
+ return ingress === undefined || ingressEntry(ingress, packetId) !== undefined;
417
+ }
418
+
419
+ /** Callback for decoded video/audio frames. */
420
+ export type FrameCallback = (data: Uint8Array, meta: RoutedFrameMeta) => void;
421
+
422
+ /** Metadata accompanying an accepted raw MMTP source packet. */
423
+ export type RoutedPacketMeta = {
424
+ packetId: number;
425
+ packetSequenceNumber: number;
426
+ /** True when FEC reconstructed this source packet. */
427
+ recovered: boolean;
428
+ /** True when the packet has no trailing Source FEC Payload ID. */
429
+ skipFecPayloadId: boolean;
430
+ /** Immutable catalog identity supplied by the owning ingress subscription. */
431
+ catalogTrackName?: string;
432
+ /**
433
+ * Strict provenance for the fragments this packet produces. Set for
434
+ * FEC-recovered packets, which carry no `source` of their own but inherit
435
+ * their FEC block's symbol provenance.
436
+ */
437
+ deliveryProvenance?: MmtpDeliveryProvenance;
438
+ };
439
+
440
+ /** Callback for accepted raw MMTP source packets. */
441
+ export type TrackPacketCallback = (packet: Uint8Array, meta: RoutedPacketMeta) => void;
442
+
443
+ type PendingRecoveredVideoFrame = {
444
+ data: Uint8Array;
445
+ meta: RoutedFrameMeta;
446
+ };
447
+
448
+ type PendingRecoveredAudioFrame = {
449
+ data: Uint8Array;
450
+ meta: RoutedFrameMeta;
451
+ };
452
+
453
+ type DirectAudioCursor = {
454
+ timestamp: number;
455
+ sequenceNumber: number;
456
+ };
457
+
458
+ /**
459
+ * One physical stream's stable key and duplicate ledgers, keyed by wire
460
+ * numbers rather than strings.
461
+ */
462
+ interface StreamLedgers {
463
+ /** JSON tuple naming the stream; see #mediaStreamKey. */
464
+ readonly key: string;
465
+ /**
466
+ * The stream's three duplicate ledgers, each retained for the stream's FEC
467
+ * horizon (see duplicate-ledger.ts):
468
+ *
469
+ * - `sourceSeqs`: packet_sequence_numbers already received on the direct
470
+ * source path. Recovered FEC blocks contain all K source symbols, so skip
471
+ * direct-path duplicates even when the source frame is still buffered by
472
+ * parser reordering.
473
+ * - `routedSeqs`: packet_sequence_numbers already handed to the track packet
474
+ * callback.
475
+ * - `parsedSymbols`: Source FEC Payload IDs (SS_ID) already parsed for
476
+ * playback. An overlap copy and its multicast twin carry the same symbol,
477
+ * and the copy never feeds FEC, so FEC's first-arrival check cannot dedup
478
+ * the pair.
479
+ */
480
+ readonly duplicates: StreamDuplicateLedgers;
481
+ }
482
+
483
+ const NO_FEC_MANAGER_HORIZON: DuplicateHorizon = Object.freeze({
484
+ kind: "none",
485
+ reason: "no-fec-manager",
486
+ });
487
+ const NO_FEC_GEOMETRY_HORIZON: DuplicateHorizon = Object.freeze({
488
+ kind: "none",
489
+ reason: "no-fec-geometry",
490
+ });
491
+ const NO_BLOCK_SPAN_HORIZON: DuplicateHorizon = Object.freeze({
492
+ kind: "none",
493
+ reason: "no-block-span",
494
+ });
495
+
496
+ const DIRECT_RECOVERED_VIDEO_QUEUE_LIMIT = 512;
497
+ const DIRECT_RECOVERED_AUDIO_QUEUE_LIMIT = 512;
498
+
499
+ type ParserDiagnostics = {
500
+ packets: number;
501
+ frames: number;
502
+ initSegments: number;
503
+ noFragment: number;
504
+ fragments: number;
505
+ reassembled: number;
506
+ pendingBuffers: number;
507
+ pendingFragments: number;
508
+ pendingSamples?: unknown[];
509
+ lastPacketLength: number;
510
+ lastSequenceNumber: number;
511
+ lastFecSsId?: number;
512
+ lastFrameTimestamp?: number;
513
+ directAudioSourceEmitted: number;
514
+ directAudioSourceRegressions: number;
515
+ directAudioRecoveredQueued: number;
516
+ directAudioRecoveredDrained: number;
517
+ directAudioRecoveredStaleDropped: number;
518
+ };
519
+
520
+ /**
521
+ * FECManager interface — the contract MmtpRouter expects for FEC
522
+ * source/repair symbol processing and block lifecycle events.
523
+ *
524
+ * Concrete implementation: `FECManager` class in `./fec-manager.ts`.
525
+ * MmtpRouter only interacts with FEC through this interface.
526
+ */
527
+ export interface FECManagerLike {
528
+ /** Feed a source symbol to the FEC decoder for a given track.
529
+ * Returns recovered block bytes if recovery completes, else null.
530
+ *
531
+ * `symbolData` is the complete MMTP packet without its Source FEC Payload
532
+ * ID, unpadded and possibly a view into the received packet. The manager
533
+ * zero-pads it to the packet's own symbol size T.
534
+ *
535
+ * Optional `alta` carries the detached ALTA authenticator extracted from
536
+ * the MMTP header-extension trailer (08-07 Path 2) — present on signed
537
+ * source packets, absent on relay-reassembled / unsigned streams. When
538
+ * provided, the fec-worker runs verify_detached instead of counting the
539
+ * symbol as `altaPending`. */
540
+ feedSourceSymbol(
541
+ packetId: number,
542
+ ssId: number,
543
+ symbolData: Uint8Array,
544
+ alta?: { auth: Uint8Array; payloadEnd: number },
545
+ catalogTrackName?: string,
546
+ ): Uint8Array | null;
547
+
548
+ /** Feed a repair symbol to the FEC decoder for a given track.
549
+ * Returns recovered block bytes if recovery completes, else null. */
550
+ feedRepairSymbol(
551
+ packetId: number,
552
+ ssStart: number,
553
+ ssbLength: number,
554
+ rsId: number,
555
+ repairData: Uint8Array,
556
+ catalogTrackName?: string,
557
+ ): Uint8Array | null;
558
+
559
+ /** Delivery-gap sample for one multicast packet (FECManager.noteMulticastArrival). */
560
+ noteMulticastArrival?(packetId: number, packetSequenceNumber: number): void;
561
+
562
+ /** Record a transport symbol for first-arrival-wins dedup and path counters. */
563
+ feedSymbolFromSource(
564
+ packetId: number,
565
+ sbn: number,
566
+ esi: number,
567
+ data: Uint8Array,
568
+ source: "multicast" | "moq",
569
+ catalogTrackName?: string,
570
+ ): boolean;
571
+
572
+ /** Strict provenance for a block: 'multicast' only when no symbol arrived
573
+ * over MoQ. Recovered bytes inherit it, since a recovered symbol is a
574
+ * function of every symbol that entered the block. */
575
+ blockDeliveryProvenance?(
576
+ sbn: number,
577
+ packetId: number,
578
+ catalogTrackName?: string,
579
+ ): MmtpDeliveryProvenance;
580
+
581
+ /** Track a first-arrival network source symbol for block completion and
582
+ * classification. Refreshes the inactivity deadline and marks clean when
583
+ * K arrive. */
584
+ trackBlock(
585
+ sbn: number,
586
+ packetId: number,
587
+ catalogTrackName: string | undefined,
588
+ esi: number,
589
+ evidence: SourceSymbolEvidence,
590
+ ): void;
591
+
592
+ /** True once the packetId has a per-track FEC configuration. */
593
+ isTrackConfigured?(packetId: number): boolean;
594
+
595
+ /** Lifecycle resolution of one block for in-order frame release. */
596
+ blockResolution?(
597
+ sbn: number,
598
+ packetId: number,
599
+ catalogTrackName?: string,
600
+ ): "pending" | "complete" | "failed" | "untracked";
601
+
602
+ /** Highest SBN seen for a stream in the current epoch. */
603
+ highestSbn?(packetId: number, catalogTrackName?: string): number | undefined;
604
+
605
+ /** Catalog/reset epoch of a packet's streams. */
606
+ epochFor?(packetId: number): number;
607
+
608
+ /**
609
+ * Catalog `fec.interleaveDepthMs` (§5.1 full block span) of a packetId; 0
610
+ * when the stream has none.
611
+ */
612
+ interleaveDepthMsForPacket?(packetId: number): number;
613
+
614
+ /**
615
+ * How long a terminal block stays authoritative before it retires to
616
+ * untracked. The release gate holds a frame at most this long, so its loss
617
+ * scan never reads a retired, delivered block as lost. Required whenever
618
+ * blockResolution is.
619
+ */
620
+ terminalRetentionMsForPacket?(packetId: number): number;
621
+
622
+ /** Configured codec of a packetId. */
623
+ codecFor?(packetId: number): string | undefined;
624
+
625
+ /** Current classifier mode of a stream. */
626
+ classifierModeFor?(
627
+ packetId: number,
628
+ catalogTrackName?: string,
629
+ ): { mode: ClassifierMode; reason: ClassifierReason | null };
630
+
631
+ /** Whether an MPU boundary still bounds the stream's RAPs. */
632
+ mpuBoundsRapsFor?(packetId: number, catalogTrackName?: string): boolean;
633
+
634
+ /** Record a RAP released in order (RAP-spacing invariant). */
635
+ noteReleasedRap?(
636
+ packetId: number,
637
+ catalogTrackName: string | undefined,
638
+ mpu: number,
639
+ presentationTime: bigint,
640
+ mediaTimescale: number,
641
+ ): void;
642
+
643
+ /** Refresh the block inactivity deadline after accepted repair progress. */
644
+ noteBlockActivity?(sbn: number, packetId: number, catalogTrackName?: string): void;
645
+
646
+ /** Cancel block timeout (block completed via clean or FEC recovery). */
647
+ cancelBlockTimeout(sbn: number, packetId?: number, catalogTrackName?: string): void;
648
+
649
+ /** Check if a block is already completed (clean or recovered). */
650
+ isBlockCompleted(sbn: number, packetId?: number, catalogTrackName?: string): boolean;
651
+
652
+ /** Mark a block as completed externally (after routing recovered data). */
653
+ markBlockCompleted(sbn: number, packetId?: number, catalogTrackName?: string): void;
654
+
655
+ /** Increment blocksRecovered stat (called after routing recovery). */
656
+ countRecovery(packetId?: number, sbn?: number, catalogTrackName?: string): void;
657
+
658
+ /**
659
+ * The configured catalog `fec.blockProfile` of a packetId, or undefined
660
+ * while it is unconfigured. Required: a Source or Repair FEC Payload ID is
661
+ * read under exactly one profile, and the router never guesses it.
662
+ */
663
+ blockProfileForPacket(packetId: number): FecBlockProfile | undefined;
664
+ /** iso-ssbg1-v1: whether a protected packet fits one ssbg_mode1 symbol. */
665
+ isoSourceFits?(packetId: number, packetLength: number): boolean;
666
+ /** iso-ssbg1-v1: locate a source in a block a repair already stated. */
667
+ locateIsoSource?(
668
+ packetId: number,
669
+ catalogTrackName: string | undefined,
670
+ ssId: number,
671
+ ): { sbn: number; esi: number } | null;
672
+ /** iso-ssbg1-v1: hold a first-arrival source until a repair states its block. */
673
+ holdIsoSource?(
674
+ packetId: number,
675
+ catalogTrackName: string | undefined,
676
+ ssId: number,
677
+ source: IsoHeldSource,
678
+ ): boolean;
679
+ /** iso-ssbg1-v1: state a repair's block and hand back the held sources it places. */
680
+ placeIsoRepair?(
681
+ packetId: number,
682
+ catalogTrackName: string | undefined,
683
+ ssStart: number,
684
+ ssbLength: number,
685
+ ): IsoRepairPlacement<IsoHeldSource>;
686
+ /** iso-ssbg1-v1: resolution of the SS_IDs strictly between two released frames. */
687
+ isoSpanResolution?(
688
+ packetId: number,
689
+ catalogTrackName: string | undefined,
690
+ fromSsId: number,
691
+ toSsId: number,
692
+ ): readonly number[] | null;
693
+
694
+ /** FEC source symbol count K per block. */
695
+ readonly k: number;
696
+ kForPacket?(packetId: number): number;
697
+
698
+ /**
699
+ * The longest a block stays open after its last symbol (inactivity fail
700
+ * bound plus D-28 repair grace). With interleaveDepthMsForPacket it bounds
701
+ * how long the router keeps a stream's duplicate ledgers.
702
+ */
703
+ blockLifetimeMsForPacket?(packetId: number): number;
704
+
705
+ /** FEC symbol size T in bytes (0 = unknown / no FEC). */
706
+ readonly symbolSize: number;
707
+ symbolSizeForPacket?(packetId: number): number;
708
+
709
+ /** Update framerate — used to refine FEC block timeout. */
710
+ setFramerate(fps: number): void;
711
+
712
+ /** Per-block state snapshot for FecBlockGrid visualization. */
713
+ getBlockStates(): Array<{
714
+ sbn: number;
715
+ packetId?: number;
716
+ state: "collecting" | "complete" | "failed";
717
+ sourceCount: number;
718
+ k: number;
719
+ progress: number;
720
+ }>;
721
+
722
+ /** FEC statistics snapshot. */
723
+ readonly fecStats: {
724
+ sourcePackets: number;
725
+ repairPackets: number;
726
+ blocksClean: number;
727
+ blocksRecovered: number;
728
+ blocksFailed: number;
729
+ };
730
+
731
+ // ── Event hooks (set by MmtpRouter in constructor) ──
732
+
733
+ /** Called when a block receives its first source symbol. */
734
+ onBlockStarted:
735
+ | ((sbn: number, packetId: number, catalogTrackName?: string) => void)
736
+ | null;
737
+
738
+ /** Called when a block completes cleanly (all K sources arrived). */
739
+ onBlockComplete:
740
+ | ((sbn: number, packetId: number, catalogTrackName?: string) => void)
741
+ | null;
742
+
743
+ /** Called when a block times out (unrecoverable), with its class. */
744
+ onBlockFailed:
745
+ | ((
746
+ sbn: number,
747
+ packetId: number,
748
+ catalogTrackName: string | undefined,
749
+ blockClass: BlockClass,
750
+ ) => void)
751
+ | null;
752
+
753
+ /** Called when a block reaches any terminal state. */
754
+ onBlockResolved?:
755
+ | ((sbn: number, packetId: number, catalogTrackName: string | undefined) => void)
756
+ | null;
757
+
758
+ /** Called when a stream's FEC source restarted (SBN discontinuity). */
759
+ onSourceDiscontinuity?:
760
+ | ((packetId: number, catalogTrackName: string | undefined) => void)
761
+ | null;
762
+
763
+ /** Dispose timers and state. */
764
+ dispose(): void;
765
+ }
766
+
767
+ // ─── MmtpRouter ─────────────────────────────────────────────────────
768
+
769
+ export interface ManifestIdentityResolutionInput {
770
+ packetId: number;
771
+ sbn: number;
772
+ esi: number;
773
+ /**
774
+ * The packet's SS_ID and its MPU's mpu_sequence_number. Under
775
+ * iso-ssbg1-v1 blocks vary in length, so a packet's media group comes from
776
+ * its own MPU header, never from SBN arithmetic (BLO-35597 D2a).
777
+ */
778
+ ssId?: number;
779
+ mpuSequenceNumber?: number;
780
+ catalogTrackName?: string;
781
+ recovered: boolean;
782
+ sourceSymbolFormat: "full-mmtp-packet" | "fec-padded-recovery";
783
+ }
784
+
785
+ export type ManifestIdentityResolver = (
786
+ input: ManifestIdentityResolutionInput,
787
+ ) => ManifestObjectIdentity | null;
788
+
789
+ /**
790
+ * Classification evidence carried by one first-arrival network source
791
+ * packet: the MMTP header R bit and the MPU / data-unit identity it states
792
+ * (movie fragment, sample, DU offset). `idx` is the 0-based sample position
793
+ * (`sample_number` is 1-based). The packet_sequence_number rides along for
794
+ * FECManager's forward-jump bound.
795
+ */
796
+ function sourceSymbolEvidence(
797
+ packet: Uint8Array,
798
+ header: MMTPHeader,
799
+ rapFlag: boolean,
800
+ packetSequenceNumber: number,
801
+ ): SourceSymbolEvidence {
802
+ const identity = MmtpContainerParser.sourcePacketIdentity(packet, header);
803
+ return {
804
+ rap: rapFlag,
805
+ packetSequenceNumber,
806
+ ...(identity === null ? {} : { mpu: identity.mpuSequenceNumber }),
807
+ ...(identity?.sampleNumber !== undefined && identity.sampleNumber >= 1
808
+ ? { idx: identity.sampleNumber - 1 }
809
+ : {}),
810
+ ...(identity?.movieFragmentSequenceNumber !== undefined
811
+ ? { movieFragment: identity.movieFragmentSequenceNumber }
812
+ : {}),
813
+ ...(identity?.offset !== undefined ? { offset: identity.offset } : {}),
814
+ };
815
+ }
816
+
817
+ export class MmtpRouter {
818
+ readonly #parser: MmtpContainerParser;
819
+ // Source AND FEC-recovered MFU fragments share ONE reassembler — the parser's
820
+ // (see #parser.reassembler). A keyframe split between source-received and
821
+ // FEC-recovered fragments must reassemble in a single buffer (BLO-8151).
822
+ readonly #fecManager: FECManagerLike | null;
823
+ #defaultFecMapper: FecBlockMapper | null;
824
+ #fecMappersByPacketId = new Map<number, FecBlockMapper>();
825
+ readonly #manifestVerifier: ManifestVerifier | null;
826
+ readonly #ownsManifestVerifier: boolean;
827
+ readonly #resolveManifestIdentity:
828
+ | ((
829
+ packetId: number,
830
+ sbn: number,
831
+ esi: number,
832
+ catalogTrackName?: string,
833
+ ) => ManifestObjectIdentity | null)
834
+ | null;
835
+ readonly #resolveManifestIdentityForRoute: ManifestIdentityResolver | null;
836
+ #manifestRecoveryQueues = new Map<
837
+ string,
838
+ Array<{
839
+ blockId: number;
840
+ result: ProcessFecBlockResult;
841
+ ownerPacketId: number;
842
+ catalogTrackName?: string;
843
+ }>
844
+ >();
845
+
846
+ // Temp state set by #processMMTP before calling parser.feed(),
847
+ // read by the parser's onFrame callback for CMAF assembly + dedup.
848
+ #currentFecInfo:
849
+ | { sbn: number; esi: number; oversized?: boolean }
850
+ | undefined;
851
+ #currentSeqNum = 0;
852
+
853
+ // Track type registry: which packetIds are video vs audio (from catalog)
854
+ #videoPacketIds = new Set<number>();
855
+ #audioPacketIds = new Set<number>();
856
+ #catalogTrackNamesByPacketId = new Map<number, Set<string>>();
857
+
858
+ // Per-track codec string for keyframe detection (e.g. 'avc1.42001e', 'av01.0.04M.08')
859
+ #trackCodecs = new Map<number, string>();
860
+
861
+ // Per-track frame callbacks keyed by packetId — used for ABR / multi-track routing
862
+ #trackCallbacks = new Map<
863
+ number,
864
+ FrameCallback
865
+ >();
866
+ #trackPacketCallbacks = new Map<number, TrackPacketCallback>();
867
+ // Cached resolved callbacks: per-track + legacy composite per packetId.
868
+ // Invalidated when any callback is registered (onTrackFrame, onVideoFrame, onAudioFrame).
869
+ #resolvedCallbacks = new Map<number, FrameCallback>();
870
+
871
+ // Per-ingress-stream sync flag: suppress direct frame emission until the first complete
872
+ // FEC block confirms we've joined mid-stream cleanly. Prevents corrupt partial
873
+ // frames from reaching the decoder on multicast mid-stream join. Catalog
874
+ // identity keeps renditions that reuse a packet ID from opening each other's gate.
875
+ #trackSynced = new Set<string>();
876
+ #droppedPreSyncVideo = new Set<string>();
877
+
878
+ // One entry per physical stream (packetId, catalog track name): its stable
879
+ // JSON key and its per-packet duplicate ledgers. The ledgers used to be
880
+ // three router-wide Sets of "<streamKey>:<coordinates>" strings, so every
881
+ // packet built and hashed up to three strings, and resetTrack() scanned
882
+ // every retained key by prefix (BLO-36599). They now hold the wire numbers
883
+ // themselves, and a reset clears one stream's Sets.
884
+ #streams = new Map<number, Map<string | undefined, StreamLedgers>>();
885
+
886
+ // Live WebCodecs callbacks cannot accept late recovered inter-frame video.
887
+ // MSE recovery routes through CmafBlockAssembler and can reorder by block; the
888
+ // direct callback path has to preserve decode order before frames leave libmmt.
889
+ #latestVideoSeqByStream = new Map<string, number>();
890
+ #lastDirectSourceVideoTsByStream = new Map<string, number>();
891
+ #lastDirectEmittedVideoTsByStream = new Map<string, number>();
892
+ #pendingDirectRecoveredVideoByStream = new Map<string, Map<string, PendingRecoveredVideoFrame>>();
893
+ // AudioDecoder input must remain monotonic too. A repair that completes after
894
+ // the live source cursor passed its AAC access unit is useful for FEC
895
+ // accounting, but injecting it into the decoder can stall PCM output.
896
+ #lastDirectSourceAudioCursorByStream = new Map<string, DirectAudioCursor>();
897
+ #lastDirectEmittedAudioCursorByStream = new Map<string, DirectAudioCursor>();
898
+ #pendingDirectRecoveredAudioByStream = new Map<string, Map<string, PendingRecoveredAudioFrame>>();
899
+
900
+ // The furthest-presented video frame each stream delivered, which a staged
901
+ // switch reads as its outgoing high water. Recorded where a frame reaches a
902
+ // decoder callback or an MSE assembler, not on arrival: a recovered frame
903
+ // can be queued ahead of the source cursor and discarded at the commit. A
904
+ // max, because an MSE assembler takes a late FEC gap fill below frames
905
+ // already delivered; an under-stated high water reports an overlapping
906
+ // splice as exact.
907
+ #deliveredVideoHighWaterByStream = new Map<string, StreamSwitchPosition>();
908
+
909
+ // Per-track CMAF block assemblers for MSE mode. When active, frames are buffered per-SBN
910
+ // and emitted as multi-sample CMAF segments on block completion instead of per-frame.
911
+ #mseAssemblers = new Map<string, CmafBlockAssembler>();
912
+
913
+ // Callbacks (legacy — used when per-track callbacks are not registered)
914
+ #onVideoFrame: FrameCallback | null = null;
915
+ #onAudioFrame: FrameCallback | null = null;
916
+
917
+ // Init segment (ftyp+moov) from MMTP MPU metadata — needed for MSE SourceBuffer
918
+ #onInitSegment: ((data: Uint8Array) => void) | null = null;
919
+
920
+ // Per-track init segment callbacks keyed by packetId
921
+ #trackInitCallbacks = new Map<number, (data: Uint8Array) => void>();
922
+ // Track whether init has been delivered per physical stream — prevent duplicates
923
+ #trackInitDelivered = new Set<string>();
924
+
925
+ // FEC block lifecycle. A failed block is NOT a decoder instruction: the
926
+ // stall/conceal decision rides on the first frame released after the gap.
927
+ #onBlockStarted: ((sbn: number, packetId: number) => void) | null = null;
928
+ /** Diagnostic subscriber for unrecoverable FEC blocks, with their class. */
929
+ #onBlockFailed:
930
+ | ((
931
+ sbn: number,
932
+ packetId: number,
933
+ catalogTrackName: string | undefined,
934
+ blockClass: BlockClass,
935
+ ) => void)
936
+ | null = null;
937
+ /** Typed observer of every gap decision the release gate makes. */
938
+ readonly #onFrameGap: ((decision: FrameGapDecision) => void) | null;
939
+ // Pokes requested while a packet is being processed are deferred to its
940
+ // end: a synchronous recovery completes its block BEFORE routing the
941
+ // recovered frames, and releasing a held gap in between would strand them.
942
+ #fecPokeDepth = 0;
943
+ #pendingFecPokes = new Map<string, { packetId: number; catalogTrackName?: string }>();
944
+ // ── Phase 14: per-role packetId dispatch (D-26, D-27, D-28) ───────
945
+ /** Maps packetId → TrackRole. Default: 'single' when absent (legacy path). */
946
+ #packetIdRoles: Map<number, TrackRole> = new Map();
947
+ /** Explicit catalog ownership for dedicated repair-track packet IDs. */
948
+ #repairOwnersByPacketId = new Map<number, Set<number>>();
949
+ /** Callback fired when a delta-track FEC block completes or fails. */
950
+ #onDeltaBlockCompleteCb:
951
+ | ((packetId: number, failed: boolean) => void)
952
+ | null = null;
953
+
954
+ // Debug counters
955
+ #pidCounts: Record<number, number> = {};
956
+ #repairPidCounts: Record<number, number> = {};
957
+ #moqRepairPidCounts: Record<number, number> = {};
958
+ #moqRepairOwnerCounts: Record<number, number> = {};
959
+ #parserDiagnostics = new Map<string, ParserDiagnostics>();
960
+
961
+ // ── Keyframe loss detection state ───────────────────────────────
962
+ /** Catalog-derived keyframe loss timeout (ms), or a reader evaluated at each arm. null = not configured (warns on video track registration; Phase-13 keyframe repair stays off). */
963
+ #keyframeLostTimeoutMs: number | (() => number) | null;
964
+ /** Why the last reader evaluation left the timer unarmed; null while arming. Dedups the warning. */
965
+ #keyframeLostUnarmedReason: string | null = null;
966
+ /** Active keyframe loss timer handle. Single timer per video track — cleared on each rapFlag and on dispose(). */
967
+ #keyframeLostTimer: ReturnType<typeof setTimeout> | null = null;
968
+ /** Monotonic counter incremented on each emitted video keyframe. Tracks current GOP position. */
969
+ #lastKeyframeGroupId = 0;
970
+ /** Callback fired when no complete keyframe is emitted within keyframeLostTimeoutMs. Argument: next expected groupId. */
971
+ #onKeyframeLost: ((expectedGroupId: number) => void) | null = null;
972
+
973
+ /**
974
+ * Owner-held state to reset when a multicast stream's publisher restarts
975
+ * (BLO-36599); see #processMMTP. The router has already reset its own state
976
+ * for the stream when this runs.
977
+ */
978
+ #onPublisherRestart: ((packetId: number, catalogTrackName: string) => void) | null;
979
+ #publisherRestarts = 0;
980
+ // FEC Payload IDs left unread because their (owner) packetId had no
981
+ // configured fec.blockProfile (see #noteUnreadableFecPayloadId).
982
+ #fecPayloadIdsWithoutProfile = 0;
983
+
984
+ /** The staged in-place stream switch, if any (stageStreamSwitch). */
985
+ #staged: StreamSwitchState | null = null;
986
+
987
+ #disposed = false;
988
+
989
+ constructor(config?: {
990
+ fecManager?: FECManagerLike;
991
+ fecMapper?: FecBlockMapper;
992
+ /** MFU reassembly timeout (ms) for FEC recovery path. Derive from FEC interleaveDepthMs. */
993
+ mfuTimeoutMs?: number;
994
+ reassemblyPolicy?: MfuReassemblyPolicy;
995
+ reassembler?: MfuReassemblerBackend;
996
+ maxBufferedMfus?: number;
997
+ /** Catalog-derived keyframe loss timeout (ms). Derive from explicit
998
+ * keyframe/group duration; do not use FEC sourceSymbols as GOP length.
999
+ * Pass a reader when its inputs are measured later; see
1000
+ * setKeyframeLostTimeoutMs. */
1001
+ keyframeLostTimeoutMs?: number | (() => number);
1002
+ manifestVerifier?: ManifestVerifier;
1003
+ /** Existing behavior is router ownership; shared browser plans opt into caller ownership. */
1004
+ manifestVerifierOwnership?: "caller" | "router";
1005
+ resolveManifestIdentity?: (
1006
+ packetId: number,
1007
+ sbn: number,
1008
+ esi: number,
1009
+ catalogTrackName?: string,
1010
+ ) => ManifestObjectIdentity | null;
1011
+ resolveManifestIdentityForRoute?: ManifestIdentityResolver;
1012
+ /** Internal terminal-failure sink owned by the composing transport. */
1013
+ onBlockFailed?: (
1014
+ sbn: number,
1015
+ packetId: number,
1016
+ catalogTrackName: string | undefined,
1017
+ blockClass: BlockClass,
1018
+ ) => void;
1019
+ /** Internal gap-decision sink owned by the composing transport. */
1020
+ onFrameGap?: (decision: FrameGapDecision) => void;
1021
+ /**
1022
+ * Called when a multicast stream's publisher restarted its wire counters,
1023
+ * after the router reset its own state for the stream, so the composing
1024
+ * transport can reset what it holds (FEC blocks, repair I/O) before the
1025
+ * packet that revealed the restart is processed.
1026
+ */
1027
+ onPublisherRestart?: (packetId: number, catalogTrackName: string) => void;
1028
+ }) {
1029
+ this.#onPublisherRestart = config?.onPublisherRestart ?? null;
1030
+ this.#keyframeLostTimeoutMs = config?.keyframeLostTimeoutMs ?? null;
1031
+ this.#fecManager = config?.fecManager ?? null;
1032
+ this.#onFrameGap = config?.onFrameGap ?? null;
1033
+ this.#defaultFecMapper = config?.fecMapper ?? null;
1034
+ this.#manifestVerifier = config?.manifestVerifier ?? null;
1035
+ this.#ownsManifestVerifier = config?.manifestVerifierOwnership !== "caller";
1036
+ this.#resolveManifestIdentity = config?.resolveManifestIdentity ?? null;
1037
+ this.#resolveManifestIdentityForRoute = config?.resolveManifestIdentityForRoute ?? null;
1038
+
1039
+ // Create MMTP parser — handles MFU extraction, reassembly, keyframe detection.
1040
+ // Its MFUReassembler is the single reassembler for BOTH the source path
1041
+ // (parser.feed) and the FEC-recovery path (#routeRecoveredBlock), so a frame
1042
+ // split across both completes (BLO-8151). mfuTimeoutMs threads the
1043
+ // catalog-derived timeout through.
1044
+ this.#parser = new MmtpContainerParser({
1045
+ mfuTimeoutMs: config?.mfuTimeoutMs,
1046
+ reassemblyPolicy: config?.reassemblyPolicy,
1047
+ reassembler: config?.reassembler,
1048
+ maxBufferedMfus: config?.maxBufferedMfus,
1049
+ ...(this.#fecManager ? { fecReleaseGate: this.#createFecReleaseGate() } : {}),
1050
+ });
1051
+
1052
+ // Wire parser init callback → router's init delivery (dedup).
1053
+ this.#parser.onInit((data, packetId, catalogTrackName) => {
1054
+ this.#diagFor(packetId, catalogTrackName).initSegments++;
1055
+ this.#deliverInit(packetId, data, catalogTrackName);
1056
+ });
1057
+
1058
+ // Wire parser frame callback → router's callback routing + CMAF assembly.
1059
+ this.#parser.onFrame((data, meta) => this.#routeParsedFrame(data, meta));
1060
+
1061
+ // Wire up FECManager block lifecycle events.
1062
+ // NOTE: Recovery routing is handled from feedSourceSymbol/feedRepairSymbol
1063
+ // return values in #processMMTP — no onBlockRecovered event needed.
1064
+ this.#wireFecLifecycle(config?.onBlockFailed);
1065
+ }
1066
+
1067
+ /**
1068
+ * Deliver one parser frame: MSE assembly, sync gating, and callback
1069
+ * routing. Reads #currentFecInfo, the FEC coordinates of the packet the
1070
+ * frame completed on.
1071
+ */
1072
+ #deliverParsedFrame(data: Uint8Array, meta: ParserFrameMeta): void {
1073
+ const packetId = meta.packetId;
1074
+ const diag = this.#diagFor(packetId, meta.catalogTrackName);
1075
+ diag.frames++;
1076
+ diag.lastFrameTimestamp = meta.timestamp;
1077
+ const isVideo = this.#classifyPacketId(packetId) === true;
1078
+ const frameMeta: RoutedFrameMeta = {
1079
+ timestamp: meta.timestamp,
1080
+ ...(meta.mmtpTimestamp === undefined
1081
+ ? {}
1082
+ : { mmtpTimestamp: meta.mmtpTimestamp }),
1083
+ keyframe: isVideo ? meta.keyframe : true,
1084
+ packetId,
1085
+ packetSequenceNumber: meta.packetSequenceNumber ?? this.#currentSeqNum,
1086
+ ...(meta.authoredTiming === undefined
1087
+ ? {}
1088
+ : { authoredTiming: meta.authoredTiming }),
1089
+ ...(meta.packetSequenceNumbers === undefined
1090
+ ? {}
1091
+ : { packetSequenceNumbers: meta.packetSequenceNumbers }),
1092
+ ...(meta.repairedPacketSequenceNumbers === undefined
1093
+ ? {}
1094
+ : { repairedPacketSequenceNumbers: meta.repairedPacketSequenceNumbers }),
1095
+ ...(meta.mpuSequenceNumber === undefined
1096
+ ? {}
1097
+ : { mpuSequenceNumber: meta.mpuSequenceNumber }),
1098
+ ...(meta.movieFragmentSequenceNumber === undefined
1099
+ ? {}
1100
+ : { movieFragmentSequenceNumber: meta.movieFragmentSequenceNumber }),
1101
+ ...(meta.sampleNumber === undefined
1102
+ ? {}
1103
+ : { sampleNumber: meta.sampleNumber }),
1104
+ ...(meta.catalogTrackName === undefined
1105
+ ? {}
1106
+ : { catalogTrackName: meta.catalogTrackName }),
1107
+ };
1108
+ if (meta.source) frameMeta.source = meta.source;
1109
+ // Forward whenever present, not when truthy: 'unknown' is a verdict the
1110
+ // parser reached, not a value to drop.
1111
+ if (meta.deliveryProvenance !== undefined)
1112
+ frameMeta.deliveryProvenance = meta.deliveryProvenance;
1113
+ if (meta.recovered) frameMeta.recovered = true;
1114
+ if (meta.gapBefore) frameMeta.gapBefore = meta.gapBefore;
1115
+ const sourceDiscontinuity = this.#opensFecSync(
1116
+ packetId,
1117
+ frameMeta.keyframe,
1118
+ frameMeta.catalogTrackName,
1119
+ );
1120
+
1121
+ // Route to MSE assembler if active for this track.
1122
+ const assembler = this.#mseAssemblerFor(packetId, frameMeta.catalogTrackName);
1123
+ if (assembler && frameMeta.recovered) {
1124
+ // An FEC-recovered frame released in order by the FEC release gate.
1125
+ assembler.addRecoveredBlock(meta.fecSbn ?? -1, [{
1126
+ data,
1127
+ timestamp: frameMeta.timestamp,
1128
+ keyframe: frameMeta.keyframe,
1129
+ ...(frameMeta.mmtpTimestamp === undefined ? {} : { mmtpTimestamp: frameMeta.mmtpTimestamp }),
1130
+ ...(frameMeta.authoredTiming === undefined ? {} : { authoredTiming: frameMeta.authoredTiming }),
1131
+ ...(frameMeta.packetSequenceNumber === undefined
1132
+ ? {}
1133
+ : { packetSequenceNumber: frameMeta.packetSequenceNumber }),
1134
+ ...(frameMeta.packetSequenceNumbers === undefined
1135
+ ? {}
1136
+ : { packetSequenceNumbers: frameMeta.packetSequenceNumbers }),
1137
+ ...(frameMeta.repairedPacketSequenceNumbers === undefined
1138
+ ? {}
1139
+ : { repairedPacketSequenceNumbers: frameMeta.repairedPacketSequenceNumbers }),
1140
+ ...(frameMeta.mpuSequenceNumber === undefined ? {} : { mpuSequenceNumber: frameMeta.mpuSequenceNumber }),
1141
+ ...(frameMeta.movieFragmentSequenceNumber === undefined
1142
+ ? {}
1143
+ : { movieFragmentSequenceNumber: frameMeta.movieFragmentSequenceNumber }),
1144
+ ...(frameMeta.sampleNumber === undefined ? {} : { sampleNumber: frameMeta.sampleNumber }),
1145
+ ...(frameMeta.catalogTrackName === undefined ? {} : { catalogTrackName: frameMeta.catalogTrackName }),
1146
+ ...(frameMeta.deliveryProvenance === undefined
1147
+ ? {}
1148
+ : { deliveryProvenance: frameMeta.deliveryProvenance }),
1149
+ ...(frameMeta.gapBefore === undefined ? {} : { gapBefore: frameMeta.gapBefore }),
1150
+ }]);
1151
+ } else if (assembler) {
1152
+ const fecInfo = this.#currentFecInfo;
1153
+ if (fecInfo && !fecInfo.oversized) {
1154
+ assembler.addFrame(
1155
+ fecInfo.sbn,
1156
+ fecInfo.esi,
1157
+ data,
1158
+ frameMeta.timestamp,
1159
+ frameMeta.keyframe,
1160
+ frameMeta.catalogTrackName,
1161
+ frameMeta.mpuSequenceNumber,
1162
+ frameMeta.packetSequenceNumber,
1163
+ frameMeta.packetSequenceNumbers,
1164
+ frameMeta.repairedPacketSequenceNumbers,
1165
+ frameMeta.authoredTiming,
1166
+ frameMeta.deliveryProvenance,
1167
+ frameMeta.mmtpTimestamp,
1168
+ frameMeta.movieFragmentSequenceNumber,
1169
+ frameMeta.sampleNumber,
1170
+ frameMeta.gapBefore,
1171
+ );
1172
+ } else {
1173
+ assembler.addBypassFrame(
1174
+ data,
1175
+ frameMeta.timestamp,
1176
+ frameMeta.keyframe,
1177
+ frameMeta.catalogTrackName,
1178
+ frameMeta.mpuSequenceNumber,
1179
+ frameMeta.packetSequenceNumber,
1180
+ frameMeta.packetSequenceNumbers,
1181
+ frameMeta.repairedPacketSequenceNumbers,
1182
+ frameMeta.authoredTiming,
1183
+ frameMeta.deliveryProvenance,
1184
+ frameMeta.mmtpTimestamp,
1185
+ frameMeta.movieFragmentSequenceNumber,
1186
+ frameMeta.sampleNumber,
1187
+ frameMeta.gapBefore,
1188
+ );
1189
+ }
1190
+ }
1191
+
1192
+ if (assembler && isVideo) {
1193
+ this.#noteDeliveredVideo(this.#mediaStreamKey(packetId, frameMeta.catalogTrackName), frameMeta);
1194
+ }
1195
+
1196
+ if (!this.#shouldEmitFrame(
1197
+ packetId,
1198
+ frameMeta.keyframe,
1199
+ frameMeta.catalogTrackName,
1200
+ )) return;
1201
+ if (sourceDiscontinuity) frameMeta.sourceDiscontinuity = true;
1202
+
1203
+ this.#noteEmittedKeyframe(packetId, frameMeta.keyframe);
1204
+
1205
+ // The emitted-sequence high-water mark (#noteEmittedVideoSeq) is recorded
1206
+ // at the actual emit inside #emitDirectVideo, not here — a recovered frame
1207
+ // can be queued ahead of the source cursor, so it must track real emits.
1208
+
1209
+ // Route to per-track or legacy callback. Even when an MSE block-assembler
1210
+ // is registered, this callback still fires — downstream consumers
1211
+ // (`mc.onVideoFrame` → takeover detection, multicast frame counter,
1212
+ // `confirmVideo`/`confirmAudio`) need every reassembled frame. The
1213
+ // SourceBuffer-write side of this callback path is suppressed inside
1214
+ // `MseBackend.feedVideo`/`feedAudio` when the assembler owns the track,
1215
+ // which is configured via `MseBackend.setAssemblerOwns('video' | 'audio')`.
1216
+ // Prevents the dual-CMAF-timeline collision (BLO-3035 layer 3).
1217
+ const cb = this.#getFrameCallback(packetId);
1218
+ if (isVideo && !assembler) {
1219
+ if (frameMeta.recovered) {
1220
+ this.#routeDirectRecoveredVideo(packetId, data, frameMeta, cb);
1221
+ } else {
1222
+ this.#routeDirectSourceVideo(packetId, data, frameMeta, cb);
1223
+ }
1224
+ } else if (!isVideo && !assembler) {
1225
+ if (frameMeta.recovered) {
1226
+ this.#routeDirectRecoveredAudio(packetId, data, frameMeta, cb);
1227
+ } else {
1228
+ this.#routeDirectSourceAudio(packetId, data, frameMeta, cb);
1229
+ }
1230
+ } else {
1231
+ cb?.(data, frameMeta);
1232
+ }
1233
+ }
1234
+
1235
+ #wireFecLifecycle(
1236
+ onBlockFailed:
1237
+ | ((
1238
+ sbn: number,
1239
+ packetId: number,
1240
+ catalogTrackName: string | undefined,
1241
+ blockClass: BlockClass,
1242
+ ) => void)
1243
+ | undefined,
1244
+ ): void {
1245
+ if (this.#fecManager) {
1246
+ this.#fecManager.onBlockStarted = (
1247
+ sbn: number,
1248
+ packetId: number,
1249
+ _catalogTrackName?: string,
1250
+ ) => {
1251
+ this.#onBlockStarted?.(sbn, packetId);
1252
+ };
1253
+
1254
+ this.#fecManager.onBlockResolved = (
1255
+ _sbn: number,
1256
+ packetId: number,
1257
+ catalogTrackName: string | undefined,
1258
+ ) => {
1259
+ this.#requestFecPoke(packetId, catalogTrackName);
1260
+ };
1261
+ this.#fecManager.onSourceDiscontinuity = (
1262
+ packetId: number,
1263
+ catalogTrackName: string | undefined,
1264
+ ) => {
1265
+ // Mark now, drain through the same deferral as every other gate
1266
+ // wake: this fires from inside FECManager.trackBlock, and a
1267
+ // synchronous drain would re-enter FECManager (noteReleasedRap) and
1268
+ // the downstream sink mid-update.
1269
+ this.#parser.markFecDiscontinuity(packetId, catalogTrackName);
1270
+ this.#requestFecPoke(packetId, catalogTrackName);
1271
+ };
1272
+
1273
+ this.#fecManager.onBlockComplete = (
1274
+ sbn: number,
1275
+ packetId: number,
1276
+ catalogTrackName?: string,
1277
+ ) => {
1278
+ this.#mseAssemblerFor(packetId, catalogTrackName)?.completeBlock(sbn);
1279
+ // Phase 14 D-26: fan-out to delta callback when delta-role packetId completes.
1280
+ if (
1281
+ this.#isCatalogTrackUnambiguous(packetId, catalogTrackName) &&
1282
+ this.#packetIdRoles.get(packetId) === "delta"
1283
+ ) {
1284
+ this.#onDeltaBlockCompleteCb?.(packetId, false); // failed=false
1285
+ }
1286
+ };
1287
+
1288
+ this.#fecManager.onBlockFailed = (
1289
+ sbn: number,
1290
+ packetId: number,
1291
+ catalogTrackName: string | undefined,
1292
+ blockClass: BlockClass,
1293
+ ) => {
1294
+ this.#mseAssemblerFor(packetId, catalogTrackName)?.failBlock(sbn);
1295
+ if (
1296
+ this.#isCatalogTrackUnambiguous(packetId, catalogTrackName) &&
1297
+ this.#packetIdRoles.get(packetId) === "delta"
1298
+ ) {
1299
+ this.#onDeltaBlockCompleteCb?.(packetId, true); // failed=true
1300
+ }
1301
+ onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
1302
+ this.#onBlockFailed?.(sbn, packetId, catalogTrackName, blockClass);
1303
+ };
1304
+ }
1305
+ }
1306
+
1307
+ // ── Accessors ───────────────────────────────────────────────────
1308
+
1309
+ get fecManager(): FECManagerLike | null {
1310
+ return this.#fecManager;
1311
+ }
1312
+
1313
+ /**
1314
+ * How long this router keeps a packet ID's duplicate ledgers, derived from
1315
+ * the FEC manager's geometry for it (see duplicate-ledger.ts), or why no
1316
+ * horizon is derivable (the stream then keeps count-bounded ledgers, or none
1317
+ * without an FEC manager). A stream reads this when it moves to a new FEC
1318
+ * block, so a catalog change applies from the next block.
1319
+ */
1320
+ duplicateHorizonFor(packetId: number): DuplicateHorizon {
1321
+ const fec = this.#fecManager;
1322
+ if (!fec) return NO_FEC_MANAGER_HORIZON;
1323
+ if (!fec.interleaveDepthMsForPacket || !fec.blockLifetimeMsForPacket) {
1324
+ return NO_FEC_GEOMETRY_HORIZON;
1325
+ }
1326
+ const blockSpanMs = fec.interleaveDepthMsForPacket(packetId);
1327
+ // FECManager reports 0 for a stream the catalog gave no block span.
1328
+ if (blockSpanMs === 0) return NO_BLOCK_SPAN_HORIZON;
1329
+ return {
1330
+ kind: "fec-horizon",
1331
+ retentionMs: duplicateRetentionMs({
1332
+ blockSpanMs,
1333
+ blockLifetimeMs: fec.blockLifetimeMsForPacket(packetId),
1334
+ }),
1335
+ };
1336
+ }
1337
+
1338
+ /** Publisher restarts detected on the multicast leg since construction. */
1339
+ get publisherRestartCount(): number {
1340
+ return this.#publisherRestarts;
1341
+ }
1342
+
1343
+ /**
1344
+ * Packets whose Source or Repair FEC Payload ID was left unread because
1345
+ * their (owner) packetId had no configured `fec.blockProfile`: FEC not
1346
+ * configured yet for a track whose packets already flow, or a track whose
1347
+ * catalog FEC was unusable. Such a source packet's media is routed as on a
1348
+ * track without FEC; such a repair packet is dropped. Neither is read as a
1349
+ * block coordinate under a guessed profile, and neither touches FEC state.
1350
+ */
1351
+ get fecPayloadIdsWithoutProfile(): number {
1352
+ return this.#fecPayloadIdsWithoutProfile;
1353
+ }
1354
+
1355
+ get reassembler(): MfuReassemblerBackend {
1356
+ return this.#parser.reassembler;
1357
+ }
1358
+
1359
+ getDiagnostics(): Record<number, ParserDiagnostics> {
1360
+ const out: Record<number, ParserDiagnostics> = {};
1361
+ // The reassembler is shared by every stream; its occupancy is one reading
1362
+ // taken now, not a per-packet snapshot (BLO-36599).
1363
+ const status = this.#parserDiagnostics.size > 0
1364
+ ? this.#parser.reassembler.getStatus()
1365
+ : null;
1366
+ for (const [key, diag] of this.#parserDiagnostics) {
1367
+ if (status) {
1368
+ diag.pendingBuffers = status.bufferedMFUs;
1369
+ diag.pendingFragments = status.pendingFragments;
1370
+ diag.pendingSamples = status.pendingSamples;
1371
+ }
1372
+ const packetId = this.#packetIdFromStreamKey(key);
1373
+ const existing = out[packetId];
1374
+ if (!existing) {
1375
+ out[packetId] = { ...diag };
1376
+ continue;
1377
+ }
1378
+ const latest =
1379
+ diag.lastSequenceNumber >= existing.lastSequenceNumber ? diag : existing;
1380
+ out[packetId] = {
1381
+ packets: existing.packets + diag.packets,
1382
+ frames: existing.frames + diag.frames,
1383
+ initSegments: existing.initSegments + diag.initSegments,
1384
+ noFragment: existing.noFragment + diag.noFragment,
1385
+ fragments: existing.fragments + diag.fragments,
1386
+ reassembled: existing.reassembled + diag.reassembled,
1387
+ pendingBuffers: existing.pendingBuffers + diag.pendingBuffers,
1388
+ pendingFragments: existing.pendingFragments + diag.pendingFragments,
1389
+ directAudioSourceEmitted:
1390
+ existing.directAudioSourceEmitted + diag.directAudioSourceEmitted,
1391
+ directAudioSourceRegressions:
1392
+ existing.directAudioSourceRegressions + diag.directAudioSourceRegressions,
1393
+ directAudioRecoveredQueued:
1394
+ existing.directAudioRecoveredQueued + diag.directAudioRecoveredQueued,
1395
+ directAudioRecoveredDrained:
1396
+ existing.directAudioRecoveredDrained + diag.directAudioRecoveredDrained,
1397
+ directAudioRecoveredStaleDropped:
1398
+ existing.directAudioRecoveredStaleDropped + diag.directAudioRecoveredStaleDropped,
1399
+ pendingSamples: [
1400
+ ...((existing.pendingSamples as unknown[]) ?? []),
1401
+ ...((diag.pendingSamples as unknown[]) ?? []),
1402
+ ],
1403
+ lastPacketLength: latest.lastPacketLength,
1404
+ lastSequenceNumber: latest.lastSequenceNumber,
1405
+ ...(latest.lastFecSsId === undefined ? {} : { lastFecSsId: latest.lastFecSsId }),
1406
+ ...(latest.lastFrameTimestamp === undefined
1407
+ ? {}
1408
+ : { lastFrameTimestamp: latest.lastFrameTimestamp }),
1409
+ };
1410
+ }
1411
+ return out;
1412
+ }
1413
+
1414
+ // ── Track configuration ─────────────────────────────────────────
1415
+
1416
+ /** Register callback for keyframe loss detection.
1417
+ * Fires when no complete keyframe is emitted within keyframeLostTimeoutMs.
1418
+ * Argument: next expected groupId (lastKeyframeGroupId + 1). */
1419
+ onKeyframeLost(cb: (expectedGroupId: number) => void): void {
1420
+ this.#onKeyframeLost = cb;
1421
+ }
1422
+
1423
+ /**
1424
+ * Set the keyframe loss timeout after construction.
1425
+ * Must be called BEFORE setPacketIdType('video').
1426
+ * A fixed timeout derives from explicit keyframe/group duration:
1427
+ * Math.ceil(groupDurationMs * KEYFRAME_LOST_TIMEOUT_SLACK).
1428
+ * A timeout whose inputs are measured after configuration (e.g. keyframe
1429
+ * cadence plus measured playout lateness) is passed as a reader, evaluated
1430
+ * each time the timer is armed. Both forms pass the same check at each arm:
1431
+ * a throw, or a value that is not finite, positive and within the timer
1432
+ * maximum, leaves that keyframe unarmed (fail closed per keyframe, not per
1433
+ * session). A fixed NaN, e.g. derived from a catalog without a group
1434
+ * duration, therefore never reaches setTimeout.
1435
+ */
1436
+ setKeyframeLostTimeoutMs(ms: number | (() => number)): void {
1437
+ this.#keyframeLostTimeoutMs = ms;
1438
+ this.#keyframeLostUnarmedReason = null;
1439
+ }
1440
+
1441
+ /** Update MFU reassembly timeout. Source and FEC-recovery paths share the
1442
+ * parser's single reassembler (BLO-8151), so one update covers both. */
1443
+ setMfuTimeoutMs(ms: number): void {
1444
+ if (ms <= 0) throw new Error("[MmtpRouter] mfuTimeoutMs must be > 0");
1445
+ this.#parser.setMfuTimeoutMs(ms);
1446
+ }
1447
+
1448
+ /** Update parser frame reordering for unordered multicast delivery.
1449
+ * Callers derive this from the catalog FEC block span (§5.1 — the
1450
+ * interleave value IS the span): positive round-half-up of span / frameMs. */
1451
+ setReorderWindow(windowSize: number): void {
1452
+ this.#parser.setReorderWindow(windowSize);
1453
+ }
1454
+
1455
+ /** Register MMTP packetId → track type mapping.
1456
+ * Call BEFORE packets arrive to ensure correct routing from the first packet.
1457
+ * If keyframeLostTimeoutMs is not provided, Phase 13 keyframe repair is
1458
+ * disabled (video still routes/decodes normally). framerate is OPTIONAL per
1459
+ * draft-ietf-moq-catalogformat-01 §3.2.23, so we can't require the timeout. */
1460
+ setPacketIdType(packetId: number, trackType: "video" | "audio"): void {
1461
+ if (trackType === "video" && this.#keyframeLostTimeoutMs === null) {
1462
+ console.warn(
1463
+ "[MmtpRouter] keyframeLostTimeoutMs not configured — Phase 13 keyframe " +
1464
+ "repair disabled for this track. Set it via setKeyframeLostTimeoutMs() " +
1465
+ "or provide in constructor config to enable: " +
1466
+ "Math.ceil(groupDurationMs * KEYFRAME_LOST_TIMEOUT_SLACK).",
1467
+ );
1468
+ }
1469
+ if (trackType === "video") this.#videoPacketIds.add(packetId);
1470
+ else this.#audioPacketIds.add(packetId);
1471
+ // Sync track info to parser for codec-aware keyframe detection.
1472
+ const codec = this.#trackCodecs.get(packetId) ?? "";
1473
+ this.#parser.setTrack(packetId, { type: trackType, codec });
1474
+ }
1475
+
1476
+ /** Media kind registered for a packetId, if any. */
1477
+ trackKindFor(packetId: number): "video" | "audio" | undefined {
1478
+ const classification = this.#classifyPacketId(packetId);
1479
+ return classification === null ? undefined : classification ? "video" : "audio";
1480
+ }
1481
+
1482
+ /** Register codec string for a packetId — enables codec-aware keyframe detection.
1483
+ * Without this, only H.264 IDR (NAL type 5) is detected as keyframe fallback.
1484
+ * AV1 (OBU_SEQUENCE_HEADER) and VP9 (frame_type bit) require the codec string. */
1485
+ setTrackCodec(packetId: number, codec: string): void {
1486
+ this.#trackCodecs.set(packetId, codec);
1487
+ // Sync to parser. Determine track type from existing registration.
1488
+ const trackType = this.#videoPacketIds.has(packetId)
1489
+ ? ("video" as const)
1490
+ : this.#audioPacketIds.has(packetId)
1491
+ ? ("audio" as const)
1492
+ : null;
1493
+ if (trackType) {
1494
+ this.#parser.setTrack(packetId, { type: trackType, codec });
1495
+ }
1496
+ }
1497
+
1498
+ // ── Frame callbacks ─────────────────────────────────────────────
1499
+
1500
+ /** @deprecated Use onTrackFrame(packetId, cb) for per-track routing */
1501
+ onVideoFrame(cb: FrameCallback): void {
1502
+ this.#onVideoFrame = cb;
1503
+ this.#resolvedCallbacks.clear();
1504
+ }
1505
+
1506
+ /** @deprecated Use onTrackFrame(packetId, cb) for per-track routing */
1507
+ onAudioFrame(cb: FrameCallback): void {
1508
+ this.#onAudioFrame = cb;
1509
+ this.#resolvedCallbacks.clear();
1510
+ }
1511
+
1512
+ /** Register a frame callback for a specific MMTP packetId (track).
1513
+ * When a legacy callback (onVideoFrame/onAudioFrame) also exists for this
1514
+ * packetId's type, both fire: per-track first, then legacy. This ensures
1515
+ * element.ts wiring (confirmAudio, feedMulticast) continues working when
1516
+ * TrackPipeline registers per-track sources. */
1517
+ onTrackFrame(
1518
+ packetId: number,
1519
+ cb: FrameCallback,
1520
+ ): void {
1521
+ this.#trackCallbacks.set(packetId, cb);
1522
+ this.#resolvedCallbacks.clear();
1523
+ }
1524
+
1525
+ /** Register a raw MMTP source-packet callback for one track.
1526
+ *
1527
+ * Direct packets retain their original bytes, including a Source FEC Payload
1528
+ * ID when present. FEC-recovered packets are the recovered source-symbol
1529
+ * bytes and set `skipFecPayloadId` because that trailer is not part of the
1530
+ * protected symbol. Repair packets are never delivered here.
1531
+ */
1532
+ onTrackPacket(packetId: number, cb: TrackPacketCallback): void {
1533
+ this.#trackPacketCallbacks.set(packetId, cb);
1534
+ }
1535
+
1536
+ /** Register an init segment callback (ftyp+moov from MMTP MPU metadata). */
1537
+ onInitSegment(cb: (data: Uint8Array) => void): void {
1538
+ this.#onInitSegment = cb;
1539
+ }
1540
+
1541
+ /** Register an init segment callback for a specific MMTP packetId.
1542
+ * Called when FT=0 init arrives via direct source or FEC recovery. */
1543
+ onTrackInit(packetId: number, cb: (data: Uint8Array) => void): void {
1544
+ this.#trackInitCallbacks.set(packetId, cb);
1545
+ }
1546
+
1547
+ // ── MSE integration ─────────────────────────────────────────────
1548
+
1549
+ /** Create a CMAF block assembler for MSE mode on a specific track.
1550
+ * When active, source frames are buffered per FEC block and emitted as
1551
+ * multi-sample CMAF segments instead of per-frame callbacks. */
1552
+ enableMseAssembler(
1553
+ packetId: number,
1554
+ config: Omit<BlockAssemblerConfig, "k">,
1555
+ catalogTrackName?: string,
1556
+ ): CmafBlockAssembler {
1557
+ const k = this.#kForPacket(packetId);
1558
+ const assemblerKey = this.#mseAssemblerKey(packetId, catalogTrackName);
1559
+ const previous = this.#mseAssemblers.get(assemblerKey);
1560
+ if (previous) {
1561
+ previous.flush();
1562
+ previous.dispose();
1563
+ }
1564
+ const assembler = new CmafBlockAssembler({ ...config, k });
1565
+ this.#mseAssemblers.set(assemblerKey, assembler);
1566
+ console.log(
1567
+ `[MmtpRouter] MSE assembler enabled for packetId=${packetId}${catalogTrackName === undefined ? "" : ` track=${catalogTrackName}`} (K=${k}, ${config.trackType})`,
1568
+ );
1569
+ return assembler;
1570
+ }
1571
+
1572
+ /** Register a segment callback for a track's MSE assembler.
1573
+ * Called with complete moof+mdat CMAF segments on block completion. */
1574
+ onMseSegment(packetId: number, cb: (segment: Uint8Array) => void, catalogTrackName?: string): void {
1575
+ const assembler = this.#mseAssemblers.get(this.#mseAssemblerKey(packetId, catalogTrackName));
1576
+ if (assembler) assembler.onSegment = cb;
1577
+ }
1578
+
1579
+ /** Register a raw-sample callback for a track's MSE assembler.
1580
+ * Use this when the caller already owns the fMP4/MSE timeline and only wants
1581
+ * FEC recovery, keyframe gating, and dedup from the assembler. */
1582
+ onMseSample(packetId: number, cb: (sample: CmafBlockSample) => void, catalogTrackName?: string): void {
1583
+ const assembler = this.#mseAssemblers.get(this.#mseAssemblerKey(packetId, catalogTrackName));
1584
+ if (assembler) assembler.onSample = cb;
1585
+ }
1586
+
1587
+ /** Register an init segment callback for a track's MSE assembler. */
1588
+ onMseInitSegment(packetId: number, cb: (init: Uint8Array) => void, catalogTrackName?: string): void {
1589
+ const assembler = this.#mseAssemblers.get(this.#mseAssemblerKey(packetId, catalogTrackName));
1590
+ if (assembler) assembler.onInitSegment = cb;
1591
+ }
1592
+
1593
+ /** Build and emit init segment for a track's MSE assembler from catalog config. */
1594
+ emitMseInit(packetId: number, catalogTrackName?: string): void {
1595
+ this.#mseAssemblers.get(this.#mseAssemblerKey(packetId, catalogTrackName))?.emitInit();
1596
+ }
1597
+
1598
+ /** Mark a video track as synchronized by an external path.
1599
+ * This opens the MMTP/FEC pre-sync gates without waiting for a multicast
1600
+ * keyframe, for MSE timelines already seeded by MoQ with the same track. */
1601
+ markExternalVideoSync(packetId?: number, catalogTrackName?: string): void {
1602
+ const packetIds =
1603
+ packetId === undefined
1604
+ ? [...this.#videoPacketIds]
1605
+ : this.#videoPacketIds.has(packetId)
1606
+ ? [packetId]
1607
+ : [];
1608
+ for (const id of packetIds) {
1609
+ const streamKey = this.#mediaStreamKey(id, catalogTrackName);
1610
+ this.#trackSynced.add(streamKey);
1611
+ this.#droppedPreSyncVideo.delete(streamKey);
1612
+ this.#mseAssemblerFor(id, catalogTrackName)?.markVideoSynced();
1613
+ }
1614
+ }
1615
+
1616
+ // ── FEC event handler ───────────────────────────────────────────
1617
+
1618
+ /** Register callback for FEC blocks when the first source symbol arrives. */
1619
+ onFecBlockStarted(cb: (sbn: number, packetId: number) => void): void {
1620
+ this.#onBlockStarted = cb;
1621
+ }
1622
+
1623
+ /** Register a diagnostic callback for FEC blocks that fail. A failure is
1624
+ * not a decoder instruction: act on `RoutedFrameMeta.gapBefore`. */
1625
+ onFecBlockFailed(
1626
+ cb: (
1627
+ sbn: number,
1628
+ packetId: number,
1629
+ catalogTrackName: string | undefined,
1630
+ blockClass: BlockClass,
1631
+ ) => void,
1632
+ ): void {
1633
+ this.#onBlockFailed = cb;
1634
+ }
1635
+
1636
+ // ── Phase 14: per-role packetId dispatch (D-26, D-27, D-28) ────────
1637
+
1638
+ /**
1639
+ * Register a TrackRole for a specific packetId.
1640
+ *
1641
+ * D-26: Enables per-role block-complete fanout — delta-role packetIds fire
1642
+ * onDeltaBlockComplete when their FEC block completes or fails.
1643
+ * Composite callback preserved: legacy onVideoFrame/onAudioFrame still fire
1644
+ * (MEMORY.md "prevents callback stealing").
1645
+ *
1646
+ * Valid roles: 'base' | 'delta' | 'repair' | 'single'.
1647
+ * Throws on unknown role (error on bad init, CLAUDE.md).
1648
+ */
1649
+ setPacketIdRole(packetId: number, role: TrackRole): void {
1650
+ const VALID_ROLES: readonly TrackRole[] = [
1651
+ "base",
1652
+ "delta",
1653
+ "repair",
1654
+ "single",
1655
+ ];
1656
+ if (!VALID_ROLES.includes(role)) {
1657
+ throw new Error(
1658
+ `[MmtpRouter] setPacketIdRole: unknown role '${role}'. Valid: ${VALID_ROLES.join(", ")}`,
1659
+ );
1660
+ }
1661
+ this.#packetIdRoles.set(packetId, role);
1662
+ }
1663
+
1664
+ /** Replace catalog-derived repair ownership before a catalog reconfiguration. */
1665
+ clearRepairPacketIdOwners(): void {
1666
+ this.#repairOwnersByPacketId.clear();
1667
+ }
1668
+
1669
+ /** Bind one dedicated repair-track PID to its authoritative media PID. */
1670
+ setRepairPacketIdOwner(repairPacketId: number, ownerPacketId: number): void {
1671
+ const owners = this.#repairOwnersByPacketId.get(repairPacketId) ?? new Set<number>();
1672
+ owners.add(ownerPacketId);
1673
+ this.#repairOwnersByPacketId.set(repairPacketId, owners);
1674
+ }
1675
+
1676
+ /**
1677
+ * Register callback fired when a delta-track FEC block completes or fails.
1678
+ *
1679
+ * @param cb - (packetId, failed) — packetId identifies the delta track,
1680
+ * failed=false means clean/recovered, failed=true means unrecoverable.
1681
+ *
1682
+ * Called from both onBlockComplete (failed=false) and onBlockFailed (failed=true)
1683
+ * for every packetId registered with role='delta' via setPacketIdRole().
1684
+ */
1685
+ onDeltaBlockComplete(cb: (packetId: number, failed: boolean) => void): void {
1686
+ this.#onDeltaBlockCompleteCb = cb;
1687
+ }
1688
+
1689
+ // ── Packet processing ───────────────────────────────────────────
1690
+
1691
+ /** Update framerate — delegates to FECManager for timeout refinement. */
1692
+ setFramerate(fps: number): void {
1693
+ this.#fecManager?.setFramerate(fps);
1694
+ }
1695
+
1696
+ /** Set FEC block mapper (created when FEC config becomes known via configureFec). */
1697
+ setFecMapper(mapper: FecBlockMapper, packetId?: number): void {
1698
+ if (packetId == null) {
1699
+ this.#defaultFecMapper = mapper;
1700
+ return;
1701
+ }
1702
+ this.#fecMappersByPacketId.set(packetId, mapper);
1703
+ }
1704
+
1705
+ registerManifestTrack(context: ManifestTrackContext): void {
1706
+ if (!this.#manifestVerifier) throw new Error("manifest verifier is not configured");
1707
+ this.#manifestVerifier.registerTrack(context);
1708
+ }
1709
+
1710
+ feedManifestFrame(trackName: string, frame: Uint8Array): void {
1711
+ this.#manifestVerifier?.ingestManifestFrame(trackName, frame);
1712
+ }
1713
+
1714
+ setManifestAuthMode(mode: ManifestAuthMode): void {
1715
+ this.#manifestVerifier?.setMode(mode);
1716
+ if (mode !== "enforce") {
1717
+ for (const queue of this.#manifestRecoveryQueues.values()) {
1718
+ for (const item of queue) {
1719
+ this.#routeProcessedFecSymbols(
1720
+ item.blockId,
1721
+ item.result,
1722
+ item.ownerPacketId,
1723
+ item.catalogTrackName,
1724
+ );
1725
+ }
1726
+ }
1727
+ this.#manifestRecoveryQueues.clear();
1728
+ }
1729
+ }
1730
+
1731
+ /**
1732
+ * Main entry point: feed a raw MMTP packet for processing.
1733
+ *
1734
+ * Source packets are played immediately AND fed to FECManager for recovery.
1735
+ * Repair packets are fed to FECManager only (no direct playback).
1736
+ */
1737
+ /**
1738
+ * Feed one MMTP source packet.
1739
+ *
1740
+ * `source` is REQUIRED. It used to default to "multicast", which meant any
1741
+ * caller that forgot it silently attributed its bytes to a multicast socket
1742
+ * they may never have crossed — and that attribution is what the physical-
1743
+ * delivery evidence path is built on. Absence is not multicast; say which.
1744
+ */
1745
+ feedPacket(
1746
+ packet: Uint8Array,
1747
+ source: "multicast" | "moq",
1748
+ manifestIdentity?: ManifestObjectIdentity,
1749
+ ingress?: MmtpIngressContext,
1750
+ ): void {
1751
+ if (this.#disposed) return;
1752
+ this.#withDeferredFecPokes(() => {
1753
+ this.#processMMTP(packet, source, manifestIdentity, false, ingress);
1754
+ });
1755
+ }
1756
+
1757
+ /**
1758
+ * Feed a reliable MoQ duplicate of a source packet that the multicast leg
1759
+ * also carries, during make-before-break overlap.
1760
+ *
1761
+ * The copy is parsed for playback (attributed to "moq") so a pending
1762
+ * multicast decoder can warm. For an FEC-tagged packet the router keeps a
1763
+ * per-symbol (SBN/ESI) parse ledger, so whichever of it and its multicast
1764
+ * twin arrives first is parsed and the second is not. The MFU reassembler
1765
+ * cannot be that dedup: complete (FI=0) MFUs bypass its #emitted set.
1766
+ *
1767
+ * The ledger exists only where a symbol coordinate does: a packet with no
1768
+ * FEC payload ID, or a router with no FEC manager, has none, so it and its
1769
+ * twin are BOTH parsed. On an audio track that delivers the sample twice
1770
+ * (#emitDirectSourceAudio counts the regression and still emits). The
1771
+ * intended deployment is safe because the multicast leg is FEC-protected,
1772
+ * so its media packets carry the ID; the untagged timing packets
1773
+ * (init/moof) take this path too, where a re-parse is benign. Do not feed
1774
+ * untagged media through here.
1775
+ *
1776
+ * The copy never enters FEC
1777
+ * accounting: no `feedSymbolFromSource`, `trackBlock` or
1778
+ * `feedSourceSymbol`. Block completion and the repair deadline therefore
1779
+ * follow the multicast leg alone, and a multicast erasure still starves its
1780
+ * block and requests repair. Feeding the copy through `feedPacket` instead
1781
+ * would let it close the block, hiding exactly the loss repair exists for.
1782
+ *
1783
+ * MoQ unicast fallback is not an overlap copy: it is the only source, so it
1784
+ * uses `feedPacket(packet, "moq")` and counts toward completion.
1785
+ */
1786
+ feedOverlapCopy(packet: Uint8Array, ingress?: MmtpIngressContext): void {
1787
+ if (this.#disposed) return;
1788
+ this.#withDeferredFecPokes(() => {
1789
+ this.#processMMTP(packet, "moq", undefined, false, ingress, true);
1790
+ });
1791
+ }
1792
+
1793
+ /**
1794
+ * Feed a repair packet from MoQ (reliable QUIC) — parses MMTP repair
1795
+ * header and delegates to FECManager for FEC recovery.
1796
+ */
1797
+ feedRepairPacket(
1798
+ packet: Uint8Array,
1799
+ ownerPacketIdHint?: number,
1800
+ catalogTrackName?: string,
1801
+ ): void {
1802
+ if (this.#disposed) return;
1803
+ this.#withDeferredFecPokes(() => {
1804
+ this.#feedRepairPacket(packet, ownerPacketIdHint, catalogTrackName);
1805
+ });
1806
+ }
1807
+
1808
+ #feedRepairPacket(
1809
+ packet: Uint8Array,
1810
+ ownerPacketIdHint: number | undefined,
1811
+ catalogTrackName: string | undefined,
1812
+ ): void {
1813
+ // One header parse per packet, as on the multicast repair branch: mmt-fec's
1814
+ // parseRepairPacket() parsed it once more before this parse (BLO-36599).
1815
+ let header: MMTPHeader;
1816
+ try {
1817
+ header = parseMMTPHeader(packet);
1818
+ } catch {
1819
+ return;
1820
+ }
1821
+ const parsed = parseMoqRepairFrameWithHeader(packet, header);
1822
+ if (!parsed) return;
1823
+
1824
+ // A dedicated repair track can carry a packet ID that differs from the
1825
+ // media packet ID. The subscribing track supplies the authoritative owner.
1826
+ const repairPacketId = header.packetId;
1827
+
1828
+ const hintedOwner =
1829
+ ownerPacketIdHint != null && this.#classifyPacketId(ownerPacketIdHint) !== null
1830
+ ? ownerPacketIdHint
1831
+ : null;
1832
+ // A MoQ repair subscription owns a media track explicitly. Dedicated
1833
+ // repair PIDs must carry that hint; only same-PID repair is self-describing.
1834
+ const ownerPacketId = hintedOwner ??
1835
+ (this.#classifyPacketId(repairPacketId) !== null ? repairPacketId : null);
1836
+ if (ownerPacketId === null) {
1837
+ debugRouter(
1838
+ `[MmtpRouter] Dropping MoQ repair packet pid=${repairPacketId}: owner hint is required`,
1839
+ );
1840
+ }
1841
+
1842
+ if (ownerPacketId != null && this.#fecManager) {
1843
+ this.#moqRepairPidCounts[repairPacketId] =
1844
+ (this.#moqRepairPidCounts[repairPacketId] || 0) + 1;
1845
+ this.#moqRepairOwnerCounts[ownerPacketId] =
1846
+ (this.#moqRepairOwnerCounts[ownerPacketId] || 0) + 1;
1847
+ const pidCount = this.#moqRepairPidCounts[repairPacketId]!;
1848
+ const ownerCount = this.#moqRepairOwnerCounts[ownerPacketId]!;
1849
+ if (pidCount <= 2 || pidCount % 500 === 0) {
1850
+ console.log(
1851
+ `[MOQ-REPAIR] pid=${repairPacketId} repairOwner=${ownerPacketId} pidCount=${pidCount} ownerCount=${ownerCount} ssbLength=${parsed.ssbLength} len=${packet.length}`,
1852
+ );
1853
+ }
1854
+
1855
+ if (this.#configuredBlockProfile(ownerPacketId) === undefined) {
1856
+ this.#noteUnreadableFecPayloadId(ownerPacketId, "repair");
1857
+ return;
1858
+ }
1859
+ if (this.#isIsoPacket(ownerPacketId)) {
1860
+ this.#handleIsoRepair(ownerPacketId, catalogTrackName, parsed, "moq");
1861
+ return;
1862
+ }
1863
+ const repairSbn = this.#deriveRepairSbn(parsed.ssStart, parsed.ssbLength, ownerPacketId);
1864
+ if (repairSbn === null) return;
1865
+ const repairEsi = this.#kForPacket(ownerPacketId) + parsed.rsId;
1866
+
1867
+ if (
1868
+ !this.#feedFecTransportSymbol(
1869
+ ownerPacketId,
1870
+ repairSbn,
1871
+ repairEsi,
1872
+ parsed.symbolData,
1873
+ "moq",
1874
+ catalogTrackName,
1875
+ )
1876
+ ) return;
1877
+
1878
+ // NOTE: Do NOT call trackBlock for repair packets (same as multicast repair path).
1879
+ const recovered = catalogTrackName === undefined
1880
+ ? this.#fecManager.feedRepairSymbol(
1881
+ ownerPacketId,
1882
+ parsed.ssStart,
1883
+ parsed.ssbLength,
1884
+ parsed.rsId,
1885
+ parsed.symbolData,
1886
+ )
1887
+ : this.#fecManager.feedRepairSymbol(
1888
+ ownerPacketId,
1889
+ parsed.ssStart,
1890
+ parsed.ssbLength,
1891
+ parsed.rsId,
1892
+ parsed.symbolData,
1893
+ catalogTrackName,
1894
+ );
1895
+ if (recovered) {
1896
+ const alreadyCompleted = catalogTrackName === undefined
1897
+ ? this.#fecManager.isBlockCompleted(repairSbn, ownerPacketId)
1898
+ : this.#fecManager.isBlockCompleted(repairSbn, ownerPacketId, catalogTrackName);
1899
+ if (catalogTrackName === undefined) {
1900
+ this.#fecManager.cancelBlockTimeout(repairSbn, ownerPacketId);
1901
+ } else {
1902
+ this.#fecManager.cancelBlockTimeout(repairSbn, ownerPacketId, catalogTrackName);
1903
+ }
1904
+ if (!alreadyCompleted) {
1905
+ this.#fecManager.countRecovery(ownerPacketId, repairSbn, catalogTrackName);
1906
+ if (catalogTrackName === undefined) {
1907
+ this.#fecManager.markBlockCompleted(repairSbn, ownerPacketId);
1908
+ } else {
1909
+ this.#fecManager.markBlockCompleted(repairSbn, ownerPacketId, catalogTrackName);
1910
+ }
1911
+ this.#routeRecoveredBlock(
1912
+ repairSbn,
1913
+ recovered,
1914
+ ownerPacketId,
1915
+ catalogTrackName,
1916
+ );
1917
+ }
1918
+ }
1919
+ }
1920
+ }
1921
+
1922
+ // ── SBN/ESI derivation ──────────────────────────────────────────
1923
+
1924
+ #kForPacket(packetId?: number): number {
1925
+ // Same packet-scoped rule as #deriveSbnEsi: once the packet ID is known,
1926
+ // only its explicitly registered mapper may stand in for the FEC
1927
+ // manager's per-packet K — never the default single-track mapper.
1928
+ if (packetId != null)
1929
+ return (
1930
+ this.#fecManager?.kForPacket?.(packetId) ??
1931
+ this.#fecMappersByPacketId.get(packetId)?.k ??
1932
+ this.#fecManager?.k ??
1933
+ 4
1934
+ );
1935
+ return this.#defaultFecMapper?.k ?? this.#fecManager?.k ?? 4;
1936
+ }
1937
+
1938
+ #symbolSizeForPacket(packetId?: number): number {
1939
+ if (packetId != null) return this.#fecManager?.symbolSizeForPacket?.(packetId) ?? this.#fecManager?.symbolSize ?? 0;
1940
+ return this.#fecManager?.symbolSize ?? 0;
1941
+ }
1942
+
1943
+ /** Derive SBN/ESI from SS_ID using packet-scoped FEC geometry. */
1944
+ #deriveSbnEsi(ssId: number, packetId?: number): { sbn: number; esi: number } {
1945
+ // A default mapper describes legacy single-track geometry. Once a packet
1946
+ // ID is known, only its explicitly registered mapper may override the
1947
+ // FECManager's packet-scoped K. Falling back to the default video mapper
1948
+ // here maps audio source symbols with video K while repair SBNs correctly
1949
+ // use the audio SSB_length, splitting one block across two coordinates.
1950
+ const mapper = packetId == null
1951
+ ? this.#defaultFecMapper
1952
+ : this.#fecMappersByPacketId.get(packetId) ?? null;
1953
+ if (mapper) return mapper.fromSsId(ssId);
1954
+ const k = this.#kForPacket(packetId);
1955
+ return { sbn: Math.floor(ssId / k), esi: ssId % k };
1956
+ }
1957
+
1958
+ #deriveRepairSbn(
1959
+ ssStart: number,
1960
+ ssbLength: number,
1961
+ packetId?: number,
1962
+ ): number | null {
1963
+ const k = this.#kForPacket(packetId);
1964
+ if (ssbLength <= 0 || ssbLength > k || ssStart % k !== 0) return null;
1965
+ return Math.floor(ssStart / k);
1966
+ }
1967
+
1968
+ /**
1969
+ * The configured block profile of a packetId, or undefined while it is
1970
+ * unconfigured. With an FEC manager its per-packet configuration is the
1971
+ * only source. A router without one still recovers symbols through its
1972
+ * registered mapper, built with the catalog's explicit profile: the
1973
+ * packet's own mapper, else the default one (the #deriveSbnEsi rule).
1974
+ */
1975
+ #configuredBlockProfile(packetId: number): FecBlockProfile | undefined {
1976
+ if (this.#fecManager) return this.#fecManager.blockProfileForPacket(packetId);
1977
+ const mapper = this.#fecMappersByPacketId.get(packetId) ?? this.#defaultFecMapper;
1978
+ return mapper?.blockProfile ?? undefined;
1979
+ }
1980
+
1981
+ /**
1982
+ * The block profile an FEC coordinate of `packetId` is read under. Throws
1983
+ * while none is configured: the two profiles carry identical wire tuples
1984
+ * with different block math, so there is no default to fall back to.
1985
+ * Packet ingress leaves such a packet's FEC Payload ID unread first
1986
+ * (#noteUnreadableFecPayloadId); a throw here is a wiring fault on a path
1987
+ * that cannot hold an unconfigured packet.
1988
+ */
1989
+ #blockProfileFor(packetId: number): FecBlockProfile {
1990
+ const profile = this.#configuredBlockProfile(packetId);
1991
+ if (profile === undefined) {
1992
+ throw new Error(
1993
+ `[MmtpRouter] packetId=${packetId} carries an FEC payload ID but has no configured ` +
1994
+ "fec.blockProfile; configure the track's FEC from its catalog before routing its packets",
1995
+ );
1996
+ }
1997
+ return profile;
1998
+ }
1999
+
2000
+ #isIsoPacket(packetId: number): boolean {
2001
+ return this.#blockProfileFor(packetId) === "iso-ssbg1-v1";
2002
+ }
2003
+
2004
+ /**
2005
+ * Explicit refusal to read an FEC Payload ID: its (owner) packetId has no
2006
+ * configured block profile. Ordering is the host's to guarantee (configure
2007
+ * a track's FEC before its packets are routed). Until then a source packet
2008
+ * plays as on a track without FEC and a repair packet is dropped; neither
2009
+ * is read under a guessed profile or seeds FEC state. Counted and warned.
2010
+ */
2011
+ #noteUnreadableFecPayloadId(packetId: number, kind: "source" | "repair"): void {
2012
+ this.#fecPayloadIdsWithoutProfile++;
2013
+ const count = this.#fecPayloadIdsWithoutProfile;
2014
+ if (count <= 5 || count % 500 === 0) {
2015
+ console.warn(
2016
+ `[MmtpRouter] packetId=${packetId} has no configured fec.blockProfile: ` +
2017
+ (kind === "source"
2018
+ ? "its FEC payload ID is not read and its media is routed without FEC"
2019
+ : "dropping its repair packet") +
2020
+ ` (${count} so far)`,
2021
+ );
2022
+ }
2023
+ }
2024
+
2025
+ /**
2026
+ * The release-gate anchor of a recovered symbol: its block (SBN) under
2027
+ * k-padded-v1, its own SS_ID under iso-ssbg1-v1, where a frame is gated by
2028
+ * the SS_IDs between it and the last released frame.
2029
+ */
2030
+ #fecAnchorForRecovered(ownerPacketId: number | undefined, blockId: number, index: number): number {
2031
+ return ownerPacketId !== undefined && this.#isIsoPacket(ownerPacketId)
2032
+ ? serial32Add(blockId, index)
2033
+ : blockId;
2034
+ }
2035
+
2036
+ /**
2037
+ * One iso-ssbg1-v1 repair symbol (ISO/IEC 23008-1 C.5.3). It states the
2038
+ * block `[SS_Start, SS_Start + SSB_length)`; repair ESI = SSB_length +
2039
+ * RS_ID (K' = SSB_length). The held sources it placed (and those of a
2040
+ * repair-less span in front of it) are fed as first-arrival sources, then
2041
+ * the repair, the order they had on the wire, so a loss-free block is clean
2042
+ * on its repair exactly as under k-padded-v1. A repair contradicting a known
2043
+ * block is dropped.
2044
+ */
2045
+ #handleIsoRepair(
2046
+ ownerPacketId: number,
2047
+ catalogTrackName: string | undefined,
2048
+ repair: { ssStart: number; ssbLength: number; rsId: number; symbolData: Uint8Array },
2049
+ source: "multicast" | "moq",
2050
+ ): void {
2051
+ const fec = this.#fecManager;
2052
+ if (!fec?.placeIsoRepair) {
2053
+ throw new Error(
2054
+ `[MmtpRouter] packetId=${ownerPacketId} is iso-ssbg1-v1 but its FEC manager cannot place ISO blocks`,
2055
+ );
2056
+ }
2057
+ const placement = fec.placeIsoRepair(ownerPacketId, catalogTrackName, repair.ssStart, repair.ssbLength);
2058
+ if (placement.status !== "placed") return;
2059
+ const blockStart = placement.block.start;
2060
+ if (placement.span !== undefined) {
2061
+ this.#replayIsoSources(ownerPacketId, catalogTrackName, placement.span.block.start, placement.span.placed);
2062
+ }
2063
+ this.#replayIsoSources(ownerPacketId, catalogTrackName, blockStart, placement.placed);
2064
+ if (this.#classifyPacketId(ownerPacketId) === null) return;
2065
+ if (!this.#feedFecTransportSymbol(
2066
+ ownerPacketId,
2067
+ blockStart,
2068
+ repair.ssbLength + repair.rsId,
2069
+ repair.symbolData,
2070
+ source,
2071
+ catalogTrackName,
2072
+ )) return;
2073
+ fec.noteBlockActivity?.(blockStart, ownerPacketId, catalogTrackName);
2074
+ const alreadyCompleted = fec.isBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2075
+ const recovered = fec.feedRepairSymbol(
2076
+ ownerPacketId,
2077
+ repair.ssStart,
2078
+ repair.ssbLength,
2079
+ repair.rsId,
2080
+ repair.symbolData,
2081
+ catalogTrackName,
2082
+ );
2083
+ if (recovered && !alreadyCompleted) {
2084
+ fec.cancelBlockTimeout(blockStart, ownerPacketId, catalogTrackName);
2085
+ fec.countRecovery(ownerPacketId, blockStart, catalogTrackName);
2086
+ fec.markBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2087
+ this.#routeRecoveredBlock(blockStart, recovered, ownerPacketId, catalogTrackName);
2088
+ }
2089
+ }
2090
+
2091
+ /**
2092
+ * Feed iso-ssbg1-v1 sources a repair just placed, in SS_ID order, exactly as
2093
+ * a first-arrival source is fed once its block is known: transport
2094
+ * accounting, block tracking with its own classification evidence, then the
2095
+ * decoder. Their media was routed when they arrived.
2096
+ */
2097
+ #replayIsoSources(
2098
+ ownerPacketId: number,
2099
+ catalogTrackName: string | undefined,
2100
+ blockStart: number,
2101
+ placed: readonly IsoPlacedSource<IsoHeldSource>[],
2102
+ ): void {
2103
+ const fec = this.#fecManager;
2104
+ if (!fec) return;
2105
+ for (const { ssId, esi, payload } of placed) {
2106
+ if (this.#classifyPacketId(ownerPacketId) === null) return;
2107
+ if (!this.#feedFecTransportSymbol(
2108
+ ownerPacketId,
2109
+ blockStart,
2110
+ esi,
2111
+ payload.symbolData,
2112
+ payload.source,
2113
+ catalogTrackName,
2114
+ )) continue;
2115
+ if (fec.isTrackConfigured?.(ownerPacketId) !== false) {
2116
+ fec.trackBlock(blockStart, ownerPacketId, catalogTrackName, esi, payload.evidence);
2117
+ this.#requestFecPoke(ownerPacketId, catalogTrackName);
2118
+ }
2119
+ if (payload.oversized) continue;
2120
+ const alreadyCompleted = fec.isBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2121
+ const recovered = fec.feedSourceSymbol(ownerPacketId, ssId, payload.symbolData, undefined, catalogTrackName);
2122
+ if (!recovered || alreadyCompleted) continue;
2123
+ fec.cancelBlockTimeout(blockStart, ownerPacketId, catalogTrackName);
2124
+ fec.countRecovery(ownerPacketId, blockStart, catalogTrackName);
2125
+ fec.markBlockCompleted(blockStart, ownerPacketId, catalogTrackName);
2126
+ this.#routeRecoveredBlock(blockStart, recovered, ownerPacketId, catalogTrackName);
2127
+ }
2128
+ }
2129
+
2130
+ #resolveRepairOwnerPacketId(
2131
+ packetId: number,
2132
+ ingress?: MmtpIngressContext,
2133
+ ): number | null {
2134
+ if (this.#classifyPacketId(packetId) !== null) return packetId;
2135
+
2136
+ if (this.#packetIdRoles.get(packetId) !== "repair") {
2137
+ debugRouter(
2138
+ `[MmtpRouter] Dropping repair packet pid=${packetId}: physical PID is unregistered`,
2139
+ );
2140
+ return null;
2141
+ }
2142
+
2143
+ const registeredOwners = this.#repairOwnersByPacketId.get(packetId) ?? new Set<number>();
2144
+ const owners = ingress === undefined
2145
+ ? [...registeredOwners]
2146
+ : [...registeredOwners].filter((ownerPacketId) =>
2147
+ ingressHasPacketId(ingress, ownerPacketId));
2148
+ if (owners.length === 1) return owners[0]!;
2149
+
2150
+ debugRouter(
2151
+ `[MmtpRouter] Dropping repair packet pid=${packetId}: catalog owner is ${owners.length === 0 ? "absent" : "ambiguous"}`,
2152
+ );
2153
+ return null;
2154
+ }
2155
+
2156
+ /** Route a recovered FEC block through the parser + dedup pipeline.
2157
+ * Sync WASM decoders return K fixed-width source symbols concatenated. */
2158
+ routeRecoveredBlock(
2159
+ blockId: number,
2160
+ data: Uint8Array,
2161
+ packetId: number,
2162
+ catalogTrackName?: string,
2163
+ ): void {
2164
+ this.#routeRecoveredBlock(blockId, data, packetId, catalogTrackName);
2165
+ }
2166
+
2167
+ /** Route a single recovered source symbol through the parser + dedup pipeline.
2168
+ * Worker-backed decoders emit one padded source symbol per frame event. */
2169
+ routeRecoveredSymbol(
2170
+ blockId: number,
2171
+ data: Uint8Array,
2172
+ packetId: number,
2173
+ esi = 0,
2174
+ catalogTrackName?: string,
2175
+ ): void {
2176
+ const blockProfile = this.#blockProfileFor(packetId);
2177
+ const result = processFecSymbol(data, { ownerPacketId: packetId, index: esi, blockProfile });
2178
+ this.#routeVerifiedFecSymbols(blockId, result, packetId, catalogTrackName);
2179
+ }
2180
+
2181
+ // ── Keyframe timer cancellation ─────────────────────────────────
2182
+
2183
+ /** Cancel the active keyframe loss timer.
2184
+ * Call on failbackToMoq to prevent a stale timer from firing
2185
+ * onKeyframeLost after multicast is no longer the active source. */
2186
+ cancelKeyframeLostTimer(): void {
2187
+ if (this.#keyframeLostTimer) {
2188
+ clearTimeout(this.#keyframeLostTimer);
2189
+ this.#keyframeLostTimer = null;
2190
+ }
2191
+ }
2192
+
2193
+ // ── Dispose ─────────────────────────────────────────────────────
2194
+
2195
+ /** Emit parser frames buffered for a finite input before teardown. */
2196
+ flush(): void {
2197
+ if (this.#disposed) return;
2198
+ this.#parser.flush();
2199
+ }
2200
+
2201
+ /** Drop one rendition's parser/router/CMAF epoch without touching peers. */
2202
+ resetTrack(packetId: number, catalogTrackName: string): void {
2203
+ if (this.#disposed) return;
2204
+ const staged = this.#staged;
2205
+ if (staged !== null) {
2206
+ const stream = { packetId, catalogTrackName };
2207
+ // A new epoch of either side invalidates what the switch held about it.
2208
+ if (isStream(stream, staged.to)) {
2209
+ staged.droppedIncomingFrames += staged.held?.length ?? 0;
2210
+ staged.held = null;
2211
+ staged.heldInit = undefined;
2212
+ }
2213
+ }
2214
+ this.#parser.resetTrack(packetId, catalogTrackName);
2215
+ const streamKey = this.#mediaStreamKey(packetId, catalogTrackName);
2216
+ this.#trackSynced.delete(streamKey);
2217
+ this.#droppedPreSyncVideo.delete(streamKey);
2218
+ this.#latestVideoSeqByStream.delete(streamKey);
2219
+ this.#lastDirectSourceVideoTsByStream.delete(streamKey);
2220
+ this.#lastDirectEmittedVideoTsByStream.delete(streamKey);
2221
+ this.#pendingDirectRecoveredVideoByStream.delete(streamKey);
2222
+ this.#lastDirectSourceAudioCursorByStream.delete(streamKey);
2223
+ this.#lastDirectEmittedAudioCursorByStream.delete(streamKey);
2224
+ this.#pendingDirectRecoveredAudioByStream.delete(streamKey);
2225
+ this.#manifestRecoveryQueues.delete(streamKey);
2226
+ this.#deliveredVideoHighWaterByStream.delete(streamKey);
2227
+ this.#stream(packetId, catalogTrackName).duplicates.clear();
2228
+ const assemblersToReset = new Set<CmafBlockAssembler>();
2229
+ const exactAssembler = this.#mseAssemblers.get(streamKey);
2230
+ if (exactAssembler) assemblersToReset.add(exactAssembler);
2231
+ const selectedAssembler = this.#mseAssemblerFor(packetId, catalogTrackName);
2232
+ if (selectedAssembler) assemblersToReset.add(selectedAssembler);
2233
+ for (const assembler of assemblersToReset) assembler.reset();
2234
+ this.#trackInitDelivered.delete(streamKey);
2235
+ this.#parserDiagnostics.delete(streamKey);
2236
+ }
2237
+
2238
+ /**
2239
+ * Retire one packet ID, drop-only: remove every registration of it (media
2240
+ * kind, codec, frame/packet/init callbacks, FEC mapper, role, repair
2241
+ * ownership) and every stream state kept for it (sync gates, emission
2242
+ * cursors, duplicate ledgers, queued recovered frames, MSE assemblers,
2243
+ * parser buffers and authored timing, MFU reassembly per catalog stream).
2244
+ * Nothing buffered is delivered, as on live dispose. A dedicated repair
2245
+ * packet ID that this packet ID alone owned loses its repair role with it.
2246
+ *
2247
+ * Afterwards the packet ID is unknown to the router: its packets and
2248
+ * frames are dropped until it is registered again. FEC state lives in the
2249
+ * FEC manager and is released there (FECManager.releasePacket).
2250
+ */
2251
+ retirePacketId(packetId: number): void {
2252
+ if (this.#disposed) return;
2253
+ if (!Number.isInteger(packetId) || packetId < 0) {
2254
+ throw new Error(`[MmtpRouter] retirePacketId: invalid packetId ${packetId}`);
2255
+ }
2256
+ const staged = this.#staged;
2257
+ if (staged !== null && (staged.from.packetId === packetId || staged.to.packetId === packetId)) {
2258
+ throw new Error(
2259
+ `[MmtpRouter] retirePacketId(${packetId}): the packet ID is in a staged stream switch; ` +
2260
+ "cancel the switch first",
2261
+ );
2262
+ }
2263
+ const names = new Set<string>(this.#catalogTrackNamesByPacketId.get(packetId) ?? []);
2264
+ const streams = this.#streams.get(packetId);
2265
+ for (const [name, stream] of streams ?? []) {
2266
+ if (name !== undefined) names.add(name);
2267
+ const key = stream.key;
2268
+ this.#trackSynced.delete(key);
2269
+ this.#droppedPreSyncVideo.delete(key);
2270
+ this.#latestVideoSeqByStream.delete(key);
2271
+ this.#lastDirectSourceVideoTsByStream.delete(key);
2272
+ this.#lastDirectEmittedVideoTsByStream.delete(key);
2273
+ this.#pendingDirectRecoveredVideoByStream.delete(key);
2274
+ this.#lastDirectSourceAudioCursorByStream.delete(key);
2275
+ this.#lastDirectEmittedAudioCursorByStream.delete(key);
2276
+ this.#pendingDirectRecoveredAudioByStream.delete(key);
2277
+ this.#manifestRecoveryQueues.delete(key);
2278
+ this.#trackInitDelivered.delete(key);
2279
+ this.#parserDiagnostics.delete(key);
2280
+ this.#pendingFecPokes.delete(key);
2281
+ this.#deliveredVideoHighWaterByStream.delete(key);
2282
+ const assembler = this.#mseAssemblers.get(key);
2283
+ if (assembler) {
2284
+ assembler.dispose();
2285
+ this.#mseAssemblers.delete(key);
2286
+ }
2287
+ }
2288
+ this.#streams.delete(packetId);
2289
+
2290
+ this.#videoPacketIds.delete(packetId);
2291
+ this.#audioPacketIds.delete(packetId);
2292
+ this.#trackCodecs.delete(packetId);
2293
+ this.#trackCallbacks.delete(packetId);
2294
+ this.#trackPacketCallbacks.delete(packetId);
2295
+ this.#resolvedCallbacks.delete(packetId);
2296
+ this.#trackInitCallbacks.delete(packetId);
2297
+ this.#fecMappersByPacketId.delete(packetId);
2298
+ this.#packetIdRoles.delete(packetId);
2299
+ this.#catalogTrackNamesByPacketId.delete(packetId);
2300
+ this.#repairOwnersByPacketId.delete(packetId);
2301
+ delete this.#pidCounts[packetId];
2302
+ delete this.#repairPidCounts[packetId];
2303
+ delete this.#moqRepairPidCounts[packetId];
2304
+ delete this.#moqRepairOwnerCounts[packetId];
2305
+ for (const [repairPacketId, owners] of [...this.#repairOwnersByPacketId]) {
2306
+ if (!owners.delete(packetId) || owners.size > 0) continue;
2307
+ this.#repairOwnersByPacketId.delete(repairPacketId);
2308
+ if (this.#packetIdRoles.get(repairPacketId) === "repair") {
2309
+ this.#packetIdRoles.delete(repairPacketId);
2310
+ }
2311
+ delete this.#repairPidCounts[repairPacketId];
2312
+ delete this.#moqRepairPidCounts[repairPacketId];
2313
+ }
2314
+
2315
+ this.#parser.removeTrack(packetId, names);
2316
+ }
2317
+
2318
+ /**
2319
+ * Stage an in-place switch from one video stream to another, committed at
2320
+ * a RAP of the incoming stream without leaving the multicast group.
2321
+ *
2322
+ * Until the commit the outgoing stream (`from`) is delivered as usual and
2323
+ * the incoming stream (`to`) is not: its packets are parsed and feed FEC,
2324
+ * so its first RAP arrives FEC-protected, but none of its frames, and not
2325
+ * its init, reach any callback or MSE assembler. Incoming deltas before its
2326
+ * first RAP are dropped. When that RAP arrives:
2327
+ *
2328
+ * - if the outgoing stream already delivered a frame at or past the RAP's
2329
+ * presentation time, the switch commits on it (`outgoing-ahead`);
2330
+ * - otherwise the RAP and the incoming frames after it are held, and the
2331
+ * switch commits when the outgoing stream is about to deliver a frame at
2332
+ * or past the RAP (`outgoing-reached-rap`): that outgoing frame, and every
2333
+ * later one, is never delivered, so the splice is exact. If the incoming
2334
+ * stream reaches its next RAP first, the outgoing stream is a GOP or more
2335
+ * behind: the switch commits on the held RAP and delivers the held GOP
2336
+ * and then the newer RAP (`outgoing-overdue`), the smallest gap left once
2337
+ * the outgoing stream stops. The hold is bounded by the incoming stream's
2338
+ * own RAP cadence, so no timer is involved.
2339
+ *
2340
+ * The commit is one synchronous transition inside the frame path: the
2341
+ * outgoing stream's already-admitted frames are released (its queued FEC
2342
+ * gap fills only up to the RAP, since recovery can queue a frame ahead of
2343
+ * the source cursor; its MSE assembler's ordering queue whole, as
2344
+ * CmafBlockAssembler.flush has no bound, so on the MSE path a recovered
2345
+ * outgoing frame at or past the RAP can still be emitted), its packet ID
2346
+ * is retired
2347
+ * (retirePacketId), `onCommit` runs, and then the incoming stream's newest
2348
+ * init and its RAP are delivered, followed by any held frames. No outgoing
2349
+ * frame is delivered after the commit and no incoming frame before it. If
2350
+ * `onCommit` throws, `to` is retired too and none of it is delivered.
2351
+ *
2352
+ * Both packet IDs must be registered video streams; the commit reads RAPs.
2353
+ * Admission (the transport allow-list and ingress tracks) is the caller's:
2354
+ * MulticastManager.switchRendition drives both.
2355
+ */
2356
+ stageStreamSwitch(options: StreamSwitchOptions): StagedStreamSwitch {
2357
+ if (this.#disposed) throw new Error("[MmtpRouter] stageStreamSwitch on a disposed router");
2358
+ if (this.#staged !== null) {
2359
+ throw new Error(
2360
+ "[MmtpRouter] a stream switch is already staged; cancel it before staging another",
2361
+ );
2362
+ }
2363
+ const { from, to, onCommit } = options;
2364
+ assertStreamIdentity("from", from);
2365
+ assertStreamIdentity("to", to);
2366
+ if (typeof onCommit !== "function") {
2367
+ throw new Error("[MmtpRouter] stageStreamSwitch requires an onCommit callback");
2368
+ }
2369
+ if (from.packetId === to.packetId) {
2370
+ throw new Error(
2371
+ `[MmtpRouter] stream switch ${from.catalogTrackName} -> ${to.catalogTrackName} keeps ` +
2372
+ `packet ID ${from.packetId}: an in-place switch remaps packet IDs, so each stream needs its own`,
2373
+ );
2374
+ }
2375
+ for (const stream of [from, to]) {
2376
+ if (!this.#videoPacketIds.has(stream.packetId)) {
2377
+ throw new Error(
2378
+ `[MmtpRouter] stream switch: packet ID ${stream.packetId} (${stream.catalogTrackName}) ` +
2379
+ "is not a registered video stream (setPacketIdType): a switch commits on a video RAP",
2380
+ );
2381
+ }
2382
+ }
2383
+ const state: StreamSwitchState = {
2384
+ from: Object.freeze({ packetId: from.packetId, catalogTrackName: from.catalogTrackName }),
2385
+ to: Object.freeze({ packetId: to.packetId, catalogTrackName: to.catalogTrackName }),
2386
+ onCommit,
2387
+ held: null,
2388
+ heldInit: undefined,
2389
+ droppedIncomingFrames: 0,
2390
+ };
2391
+ this.#staged = state;
2392
+ return {
2393
+ cancel: () => {
2394
+ if (this.#staged !== state) return false;
2395
+ this.#staged = null;
2396
+ return true;
2397
+ },
2398
+ };
2399
+ }
2400
+
2401
+ /**
2402
+ * Apply the staged switch to one parser frame of either side. Returns true
2403
+ * when the frame was consumed (held, dropped, or past the splice).
2404
+ */
2405
+ #interceptSwitchFrame(data: Uint8Array, meta: ParserFrameMeta): boolean {
2406
+ const state = this.#staged!;
2407
+ if (isStream(meta, state.to)) {
2408
+ if (state.held === null) {
2409
+ if (!meta.keyframe) {
2410
+ state.droppedIncomingFrames++;
2411
+ return true;
2412
+ }
2413
+ const high = this.#deliveredVideoHighWaterByStream.get(
2414
+ this.#mediaStreamKey(state.from.packetId, state.from.catalogTrackName),
2415
+ );
2416
+ if (high !== undefined && comparePresentation(high, meta) >= 0) {
2417
+ if (this.#commitStreamSwitch(state, meta, "outgoing-ahead")) this.#routeParsedFrame(data, meta);
2418
+ return true;
2419
+ }
2420
+ state.held = [this.#holdSwitchFrame(data, meta)];
2421
+ return true;
2422
+ }
2423
+ if (meta.keyframe) {
2424
+ // The outgoing stream never reached the held RAP, so everything it
2425
+ // delivered is before it: the held GOP is complete, overlaps nothing,
2426
+ // and dropping it would only widen the gap by a whole incoming GOP.
2427
+ const held = state.held;
2428
+ if (this.#commitStreamSwitch(state, held[0]!.meta, "outgoing-overdue")) {
2429
+ for (const frame of held) this.#replayHeldSwitchFrame(frame);
2430
+ this.#routeParsedFrame(data, meta);
2431
+ }
2432
+ return true;
2433
+ }
2434
+ state.held.push(this.#holdSwitchFrame(data, meta));
2435
+ return true;
2436
+ }
2437
+ if (!isStream(meta, state.from)) return false;
2438
+ const rap = state.held?.[0];
2439
+ if (rap !== undefined && comparePresentation(meta, rap.meta) >= 0) {
2440
+ const held = state.held!;
2441
+ if (this.#commitStreamSwitch(state, rap.meta, "outgoing-reached-rap")) {
2442
+ for (const frame of held) this.#replayHeldSwitchFrame(frame);
2443
+ }
2444
+ return true;
2445
+ }
2446
+ return false;
2447
+ }
2448
+
2449
+ #holdSwitchFrame(data: Uint8Array, meta: ParserFrameMeta): HeldSwitchFrame {
2450
+ const fecInfo = this.#currentFecInfo;
2451
+ return {
2452
+ data,
2453
+ meta: { ...meta },
2454
+ fecInfo: fecInfo === undefined ? undefined : { ...fecInfo },
2455
+ };
2456
+ }
2457
+
2458
+ /**
2459
+ * Replay a held incoming frame as if it completed now, under its own FEC
2460
+ * coordinates, through the same gate as a parser frame, so a switch staged
2461
+ * from inside the commit's onCommit sees it.
2462
+ */
2463
+ #replayHeldSwitchFrame(frame: HeldSwitchFrame): void {
2464
+ if (this.#disposed) return;
2465
+ const current = this.#currentFecInfo;
2466
+ this.#currentFecInfo = frame.fecInfo;
2467
+ try {
2468
+ this.#routeParsedFrame(frame.data, frame.meta);
2469
+ } finally {
2470
+ this.#currentFecInfo = current;
2471
+ }
2472
+ }
2473
+
2474
+ /**
2475
+ * The one path from a completed frame into delivery: parser frames, held
2476
+ * frames a commit replays, and the frame that triggered a commit all pass
2477
+ * a staged switch's interception here.
2478
+ */
2479
+ #routeParsedFrame(data: Uint8Array, meta: ParserFrameMeta): void {
2480
+ // A frame of a packet ID retired while it was in flight has no route
2481
+ // left: an in-place switch commits from inside the parser's emission,
2482
+ // and the retired stream's remaining frames can still drain from the
2483
+ // same call. Nothing registers a packet ID mid-frame, so this drops
2484
+ // only frames that lost their registration.
2485
+ if (this.#classifyPacketId(meta.packetId) === null) return;
2486
+ if (this.#staged !== null && this.#interceptSwitchFrame(data, meta)) return;
2487
+ this.#deliverParsedFrame(data, meta);
2488
+ }
2489
+
2490
+ #commitStreamSwitch(
2491
+ state: StreamSwitchState,
2492
+ rap: ParserFrameMeta,
2493
+ reason: StreamSwitchCommitReason,
2494
+ ): boolean {
2495
+ this.#staged = null;
2496
+ // Release the outgoing stream's queued FEC gap fills that precede the
2497
+ // splice before the packet ID is retired, which discards everything else.
2498
+ // The queue can hold recovered frames ahead of the source cursor, so it is
2499
+ // bounded at the RAP by the same comparison that decides the commit: a
2500
+ // queued outgoing frame at or past the RAP is never delivered.
2501
+ const outgoingKey = this.#mediaStreamKey(state.from.packetId, state.from.catalogTrackName);
2502
+ this.#drainDirectRecoveredVideo(
2503
+ outgoingKey,
2504
+ state.from.packetId,
2505
+ frame => comparePresentation(frame, rap) < 0,
2506
+ this.#getFrameCallback(state.from.packetId),
2507
+ );
2508
+ this.#mseAssemblerFor(state.from.packetId, state.from.catalogTrackName)?.flush();
2509
+ const outgoingHighWater = this.#deliveredVideoHighWaterByStream.get(outgoingKey);
2510
+ this.retirePacketId(state.from.packetId);
2511
+ const commit: StreamSwitchCommit = Object.freeze({
2512
+ from: state.from,
2513
+ to: state.to,
2514
+ rap: Object.freeze({
2515
+ ...switchPosition(rap),
2516
+ ...(rap.mpuSequenceNumber === undefined ? {} : { mpuSequenceNumber: rap.mpuSequenceNumber }),
2517
+ }),
2518
+ outgoingHighWater,
2519
+ reason,
2520
+ droppedIncomingFrames: state.droppedIncomingFrames,
2521
+ });
2522
+ try {
2523
+ state.onCommit(commit);
2524
+ } catch (error) {
2525
+ // The caller could not take the incoming stream (typically a decoder
2526
+ // reconfiguration that threw). `from` is already retired, so there is
2527
+ // nothing to fall back to, and delivering `to` would feed a decoder
2528
+ // still configured for `from`. Fail closed: retire `to` too, so neither
2529
+ // its init nor its held GOP nor anything after reaches a callback.
2530
+ console.warn("[MmtpRouter] stream switch commit callback failed; retiring the incoming stream", error);
2531
+ if (this.#disposed) return false;
2532
+ // onCommit may have staged a new switch; one on `to` cannot outlive it
2533
+ // (retirePacketId refuses a staged packet ID).
2534
+ const restaged = this.#staged as StreamSwitchState | null;
2535
+ if (restaged !== null && (restaged.from.packetId === state.to.packetId || restaged.to.packetId === state.to.packetId)) {
2536
+ this.#staged = null;
2537
+ }
2538
+ this.retirePacketId(state.to.packetId);
2539
+ return false;
2540
+ }
2541
+ if (this.#disposed) return false;
2542
+ const init = state.heldInit;
2543
+ if (init !== undefined) this.#deliverInit(state.to.packetId, init, state.to.catalogTrackName);
2544
+ return true;
2545
+ }
2546
+
2547
+ /** Live teardown is drop-only. Finite inputs must opt into a final flush. */
2548
+ dispose(options: { flush?: boolean } = {}): void {
2549
+ if (options.flush) this.flush();
2550
+ this.#disposed = true;
2551
+ this.#staged = null;
2552
+ this.cancelKeyframeLostTimer();
2553
+ this.#parser.dispose();
2554
+ this.#streams.clear();
2555
+ this.#trackPacketCallbacks.clear();
2556
+ this.#latestVideoSeqByStream.clear();
2557
+ this.#lastDirectSourceVideoTsByStream.clear();
2558
+ this.#lastDirectEmittedVideoTsByStream.clear();
2559
+ this.#pendingDirectRecoveredVideoByStream.clear();
2560
+ this.#lastDirectSourceAudioCursorByStream.clear();
2561
+ this.#lastDirectEmittedAudioCursorByStream.clear();
2562
+ this.#pendingDirectRecoveredAudioByStream.clear();
2563
+ this.#trackSynced.clear();
2564
+ this.#droppedPreSyncVideo.clear();
2565
+ this.#trackInitDelivered.clear();
2566
+ this.#deliveredVideoHighWaterByStream.clear();
2567
+ this.#repairPidCounts = {};
2568
+ this.#moqRepairPidCounts = {};
2569
+ this.#moqRepairOwnerCounts = {};
2570
+ this.#parserDiagnostics.clear();
2571
+ this.#catalogTrackNamesByPacketId.clear();
2572
+ this.#repairOwnersByPacketId.clear();
2573
+ if (this.#ownsManifestVerifier) this.#manifestVerifier?.dispose();
2574
+ this.#manifestRecoveryQueues.clear();
2575
+ for (const assembler of this.#mseAssemblers.values()) assembler.dispose();
2576
+ this.#mseAssemblers.clear();
2577
+ }
2578
+
2579
+ // ── Private: keyframe loss detection ────────────────────────────
2580
+
2581
+ /** Arm (or re-arm) the keyframe loss timer.
2582
+ * Clears any existing timer and sets a new one for keyframeLostTimeoutMs.
2583
+ * On timeout, fires onKeyframeLost with the next expected groupId.
2584
+ * A reader without a valid value leaves the timer cleared and unarmed. */
2585
+ #armKeyframeLostTimer(): void {
2586
+ if (this.#keyframeLostTimer) {
2587
+ clearTimeout(this.#keyframeLostTimer);
2588
+ this.#keyframeLostTimer = null;
2589
+ }
2590
+ const timeoutMs = this.#readKeyframeLostTimeoutMs();
2591
+ if (timeoutMs === null) return;
2592
+ const expectedNext = this.#lastKeyframeGroupId + 1;
2593
+ this.#keyframeLostTimer = setTimeout(() => {
2594
+ this.#keyframeLostTimer = null;
2595
+ this.#onKeyframeLost?.(expectedNext);
2596
+ }, timeoutMs);
2597
+ }
2598
+
2599
+ /** The configured number, or the reader's current value; null when the
2600
+ * reader throws or either form yields a value no timer can honour. Warns
2601
+ * on entering that state or when its cause changes (a different exception,
2602
+ * or a different class of bad value), not once per keyframe. */
2603
+ #readKeyframeLostTimeoutMs(): number | null {
2604
+ const timeout = this.#keyframeLostTimeoutMs!;
2605
+ let reason: string;
2606
+ let detail: unknown;
2607
+ try {
2608
+ const ms = typeof timeout === "function" ? timeout() : timeout;
2609
+ if (Number.isFinite(ms) && ms > 0 && ms <= MAX_TIMER_DELAY_MS) {
2610
+ this.#keyframeLostUnarmedReason = null;
2611
+ return ms;
2612
+ }
2613
+ reason =
2614
+ typeof ms !== "number" ? "yielded a non-number"
2615
+ : Number.isNaN(ms) ? "yielded NaN"
2616
+ : !Number.isFinite(ms) ? "yielded an infinite value"
2617
+ : ms <= 0 ? "yielded a non-positive value"
2618
+ : `exceeds the ${MAX_TIMER_DELAY_MS} ms timer maximum`;
2619
+ detail = ms;
2620
+ } catch (error) {
2621
+ // Keyed on the cause, so a real reader bug (say a TypeError) is still
2622
+ // reported after the expected "unmeasured" throw.
2623
+ reason = `threw ${error instanceof Error ? `${error.name}: ${error.message}` : String(error)}`;
2624
+ detail = error;
2625
+ }
2626
+ if (reason !== this.#keyframeLostUnarmedReason) {
2627
+ this.#keyframeLostUnarmedReason = reason;
2628
+ console.warn(
2629
+ `[MmtpRouter] keyframeLostTimeoutMs ${reason} — keyframe loss timer ` +
2630
+ "not armed until it yields a finite positive value:",
2631
+ detail,
2632
+ );
2633
+ }
2634
+ return null;
2635
+ }
2636
+
2637
+ #noteEmittedKeyframe(packetId: number, keyframe: boolean): void {
2638
+ if (!keyframe) return;
2639
+ if (!this.#videoPacketIds.has(packetId)) return;
2640
+ if (this.#keyframeLostTimeoutMs === null) return;
2641
+ // FEC tracks resolve lost keyframes through block deadlines and the
2642
+ // release gate's stall decision, never through this timer.
2643
+ if (this.#isFecTrack(packetId)) return;
2644
+ this.#lastKeyframeGroupId++;
2645
+ this.#armKeyframeLostTimer();
2646
+ }
2647
+
2648
+ // ── Private: FEC release gate ───────────────────────────────────
2649
+
2650
+ /** A packetId is an FEC track once the FEC manager configured it. */
2651
+ #isFecTrack(packetId: number): boolean {
2652
+ return this.#fecManager?.isTrackConfigured?.(packetId) === true;
2653
+ }
2654
+
2655
+ #withDeferredFecPokes(work: () => void): void {
2656
+ this.#fecPokeDepth++;
2657
+ try {
2658
+ work();
2659
+ } finally {
2660
+ this.#fecPokeDepth--;
2661
+ if (this.#fecPokeDepth === 0 && this.#pendingFecPokes.size > 0 && !this.#disposed) {
2662
+ const pokes = [...this.#pendingFecPokes.values()];
2663
+ this.#pendingFecPokes.clear();
2664
+ for (const poke of pokes) {
2665
+ this.#parser.pokeFecRelease(poke.packetId, poke.catalogTrackName);
2666
+ }
2667
+ }
2668
+ }
2669
+ }
2670
+
2671
+ #requestFecPoke(packetId: number, catalogTrackName: string | undefined): void {
2672
+ if (this.#disposed) return;
2673
+ if (this.#fecPokeDepth > 0) {
2674
+ this.#pendingFecPokes.set(this.#mediaStreamKey(packetId, catalogTrackName), {
2675
+ packetId,
2676
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
2677
+ });
2678
+ return;
2679
+ }
2680
+ this.#parser.pokeFecRelease(packetId, catalogTrackName);
2681
+ }
2682
+
2683
+ /**
2684
+ * The release policy the parser applies to FEC tracks: a frame after a gap
2685
+ * is held until every block that could hold the missing frames resolved,
2686
+ * then released with the stall/conceal decision when media was lost.
2687
+ */
2688
+ #createFecReleaseGate(): FecReleaseGate {
2689
+ if (this.#fecManager?.blockResolution && !this.#fecManager.terminalRetentionMsForPacket) {
2690
+ throw new Error(
2691
+ "[MmtpRouter] an FEC manager with blockResolution must provide terminalRetentionMsForPacket: " +
2692
+ "it bounds how long the release gate holds a frame",
2693
+ );
2694
+ }
2695
+ return {
2696
+ gates: (packetId) => this.#isFecTrack(packetId),
2697
+ resolvedLoss: (packetId, catalogTrackName, fromSbn, toSbn, releasedSbn) => {
2698
+ const fec = this.#fecManager;
2699
+ if (!fec?.blockResolution) return [];
2700
+ if (this.#isIsoPacket(packetId)) {
2701
+ // iso-ssbg1-v1 frames are anchored by their packet's SS_ID: wait
2702
+ // until every block that could hold the SS_IDs between the last
2703
+ // released frame and this one is stated and resolved.
2704
+ const head = fromSbn === releasedSbn ? toSbn : fromSbn;
2705
+ return fec.isoSpanResolution?.(packetId, catalogTrackName, releasedSbn, head) ?? null;
2706
+ }
2707
+ const highest = fec.highestSbn?.(packetId, catalogTrackName);
2708
+ const lost: number[] = [];
2709
+ for (let sbn = fromSbn; sbn <= toSbn; sbn++) {
2710
+ const resolution = fec.blockResolution(sbn, packetId, catalogTrackName);
2711
+ if (resolution === "pending") return null;
2712
+ if (resolution === "failed") {
2713
+ lost.push(sbn);
2714
+ } else if (resolution === "untracked") {
2715
+ // releasedSbn is the block of the frame the gate last released, so
2716
+ // its media arrived: untracked there means it resolved and retired,
2717
+ // not that it vanished. Counting it would conceal a delivered block.
2718
+ if (sbn === releasedSbn) continue;
2719
+ // Not seen yet: a block at or past the stream's frontier may
2720
+ // still start. Behind the frontier it vanished without trace.
2721
+ if (highest === undefined || sbn > highest) return null;
2722
+ lost.push(sbn);
2723
+ }
2724
+ }
2725
+ return lost;
2726
+ },
2727
+ // The retention window itself: a frame is never still held when a block
2728
+ // it waits on retires. Only consulted while resolvedLoss holds a gap,
2729
+ // which needs blockResolution, so the check above guarantees the method.
2730
+ holdLimitMs: (packetId) => this.#fecManager?.terminalRetentionMsForPacket?.(packetId) ?? Infinity,
2731
+ decide: (before, after, lostBlocks, discontinuity) => {
2732
+ const decision = this.#decideGap(before, after, lostBlocks, discontinuity);
2733
+ this.#onFrameGap?.(decision);
2734
+ return decision;
2735
+ },
2736
+ onReleased: (data, meta) => {
2737
+ if (meta.trackType !== "video" || meta.mpuSequenceNumber === undefined) return;
2738
+ const timing = meta.authoredTiming;
2739
+ if (!timing || !this.#isRapForGap(data, meta)) return;
2740
+ this.#fecManager?.noteReleasedRap?.(
2741
+ meta.packetId,
2742
+ meta.catalogTrackName,
2743
+ meta.mpuSequenceNumber,
2744
+ timing.presentationTime,
2745
+ timing.mediaTimescale,
2746
+ );
2747
+ },
2748
+ };
2749
+ }
2750
+
2751
+ /**
2752
+ * Codec-aware RAP check for gap decisions: the bitstream, with the MMTP R
2753
+ * bit only as the fallback for source frames. Recovered frames are
2754
+ * bitstream-only, and the MFU priority hint never counts.
2755
+ */
2756
+ #isRapForGap(data: Uint8Array, meta: ParserFrameMeta): boolean {
2757
+ const codec = this.#trackCodecs.get(meta.packetId) ??
2758
+ this.#fecManager?.codecFor?.(meta.packetId) ?? "";
2759
+ const rapFlag = meta.recovered ? false : meta.rapFlag === true;
2760
+ return MmtpContainerParser.isKeyframe(data, rapFlag, codec);
2761
+ }
2762
+
2763
+ #decideGap(
2764
+ before: { data: Uint8Array; meta: ParserFrameMeta },
2765
+ after: { data: Uint8Array; meta: ParserFrameMeta },
2766
+ lostBlocks: readonly number[],
2767
+ discontinuity: boolean,
2768
+ ): FrameGapDecision {
2769
+ const { packetId, catalogTrackName } = after.meta;
2770
+ const trackKind = this.#classifyPacketId(packetId) === false ? "audio" : "video";
2771
+ // Sample positions are MPU positions only while the classifier verified
2772
+ // the geometry (one movie fragment per MPU, RAP at index 0).
2773
+ const positionsTrusted =
2774
+ this.#fecManager?.classifierModeFor?.(packetId, catalogTrackName).mode === "classifying";
2775
+ // "Same MPU on both sides" proves a delta-only gap only while an MPU
2776
+ // boundary bounds the stream's RAPs. Once a RAP was seen inside an MPU
2777
+ // (or an MPU outran its GOP), a lost RAP can hide inside one MPU, so the
2778
+ // MPU is withheld and a non-RAP after the gap stalls as rap-unknown. A
2779
+ // manager that cannot answer is treated as unable to prove it.
2780
+ const mpuTrusted =
2781
+ this.#fecManager?.mpuBoundsRapsFor?.(packetId, catalogTrackName) === true;
2782
+ const evidence = (frame: { data: Uint8Array; meta: ParserFrameMeta }): GapFrameEvidence => {
2783
+ const timing = frame.meta.authoredTiming;
2784
+ const sampleNumber = frame.meta.sampleNumber;
2785
+ return {
2786
+ rap: trackKind === "video" && this.#isRapForGap(frame.data, frame.meta),
2787
+ ...(!mpuTrusted || frame.meta.mpuSequenceNumber === undefined
2788
+ ? {}
2789
+ : { mpu: frame.meta.mpuSequenceNumber }),
2790
+ ...(positionsTrusted && sampleNumber !== undefined && sampleNumber >= 1
2791
+ ? { idx: sampleNumber - 1 }
2792
+ : {}),
2793
+ ...(timing === undefined
2794
+ ? {}
2795
+ : {
2796
+ pts: timing.presentationTime,
2797
+ durationTicks: timing.durationTicks,
2798
+ timescale: timing.mediaTimescale,
2799
+ }),
2800
+ };
2801
+ };
2802
+ return decideGap({
2803
+ stream: { packetId, catalogTrackName: catalogTrackName ?? null },
2804
+ trackKind,
2805
+ before: evidence(before),
2806
+ after: evidence(after),
2807
+ discontinuity,
2808
+ blocks: lostBlocks,
2809
+ maxMissingUnits: this.#maxMissingUnits(before.meta, after.meta),
2810
+ epoch: this.#fecManager?.epochFor?.(packetId) ?? 0,
2811
+ });
2812
+ }
2813
+
2814
+ /**
2815
+ * The most media units a gap between two released frames can hold: the
2816
+ * FEC blocks from `before`'s SBN to `after`'s, times D, the media units one
2817
+ * block touches (ceil(interleaveDepthMs / unit duration), §5.1). Null when
2818
+ * either SBN, the unit duration or the catalog interleave is unknown.
2819
+ */
2820
+ #maxMissingUnits(before: ParserFrameMeta, after: ParserFrameMeta): number | null {
2821
+ const from = before.fecSbn;
2822
+ const to = after.fecSbn;
2823
+ const timing = before.authoredTiming;
2824
+ const interleaveMs = this.#fecManager?.interleaveDepthMsForPacket?.(after.packetId);
2825
+ if (from === undefined || to === undefined || !timing || interleaveMs === undefined) return null;
2826
+ if (!(timing.durationTicks > 0) || !(timing.mediaTimescale > 0) || !(interleaveMs > 0)) return null;
2827
+ // D = ceil(interleaveMs * timescale / (durationTicks * 1000)), in integers.
2828
+ const numerator = BigInt(Math.ceil(interleaveMs)) * BigInt(timing.mediaTimescale);
2829
+ const denominator = BigInt(timing.durationTicks) * 1000n;
2830
+ const unitsPerBlock = (numerator + denominator - 1n) / denominator;
2831
+ // iso-ssbg1-v1 anchors are SS_IDs: every block holds at least one, so the
2832
+ // serial SS_ID distance bounds the blocks between them.
2833
+ const blocks = BigInt(
2834
+ (this.#isIsoPacket(after.packetId)
2835
+ ? (serial32Precedes(from, to) ? serial32Distance(from, to) : serial32Distance(to, from))
2836
+ : Math.abs(to - from)) + 1,
2837
+ );
2838
+ const bound = unitsPerBlock * blocks;
2839
+ return bound > BigInt(Number.MAX_SAFE_INTEGER) ? null : Number(bound);
2840
+ }
2841
+
2842
+ // ── Private: MMTP processing ────────────────────────────────────
2843
+
2844
+ /** Classify a packetId from the catalog. Returns true=video, false=audio, null=unknown (drop). */
2845
+ #classifyPacketId(packetId: number): boolean | null {
2846
+ if (this.#videoPacketIds.has(packetId)) return true;
2847
+ if (this.#audioPacketIds.has(packetId)) return false;
2848
+ return null;
2849
+ }
2850
+
2851
+ /** Resolve callback for a packetId (cached per-packetId, invalidated on registration).
2852
+ * When a per-track callback is registered AND a legacy callback exists,
2853
+ * fires BOTH: per-track first, then legacy. This ensures element.ts wiring
2854
+ * (confirmAudio, feedMulticast) continues to fire even when TrackPipeline
2855
+ * registers per-track sources.
2856
+ * Returns null for unknown packetIds — callers must drop the packet. */
2857
+ #getFrameCallback(packetId: number): FrameCallback | null {
2858
+ const cached = this.#resolvedCallbacks.get(packetId);
2859
+ if (cached) return cached;
2860
+
2861
+ const perTrack = this.#trackCallbacks.get(packetId);
2862
+ const classification = this.#classifyPacketId(packetId);
2863
+ const legacy =
2864
+ classification === true
2865
+ ? this.#onVideoFrame
2866
+ : classification === false
2867
+ ? this.#onAudioFrame
2868
+ : null;
2869
+
2870
+ let resolved: FrameCallback | null;
2871
+ if (perTrack && legacy) {
2872
+ // Composite: fire both so per-track routing AND legacy confirm/feedMulticast work.
2873
+ resolved = (data, meta) => {
2874
+ perTrack(data, meta);
2875
+ legacy(data, meta);
2876
+ };
2877
+ } else {
2878
+ resolved = perTrack ?? legacy ?? null;
2879
+ }
2880
+
2881
+ if (resolved) this.#resolvedCallbacks.set(packetId, resolved);
2882
+ return resolved;
2883
+ }
2884
+
2885
+ /** Deliver init segment for a track (deduped per physical stream). */
2886
+ #deliverInit(packetId: number, data: Uint8Array, catalogTrackName?: string): void {
2887
+ const staged = this.#staged;
2888
+ if (staged !== null && isStream({ packetId, catalogTrackName }, staged.to)) {
2889
+ // The incoming stream's init describes media delivered after the
2890
+ // commit. Handing it over before would reconfigure a decoder that is
2891
+ // still fed the outgoing stream, so it travels with the RAP instead.
2892
+ if (data.byteLength > 8) staged.heldInit = data;
2893
+ return;
2894
+ }
2895
+ const streamKey = this.#mediaStreamKey(packetId, catalogTrackName);
2896
+ if (this.#trackInitDelivered.has(streamKey)) return;
2897
+ if (data.byteLength <= 8) return;
2898
+ this.#trackInitDelivered.add(streamKey);
2899
+ const cb = this.#trackInitCallbacks.get(packetId);
2900
+ if (cb) {
2901
+ cb(data);
2902
+ } else if (this.#onInitSegment) {
2903
+ this.#onInitSegment(data);
2904
+ }
2905
+ console.log(
2906
+ `[MmtpRouter] Init delivered for packetId=${packetId}${catalogTrackName === undefined ? "" : ` track=${catalogTrackName}`} (${data.byteLength}B)`,
2907
+ );
2908
+ }
2909
+
2910
+ /**
2911
+ * Core MMTP packet processing — source packets → play + FEC, repair → FEC only.
2912
+ * Both source and repair feed FECManager so RaptorQ has enough symbols (source + repair >= K).
2913
+ *
2914
+ * Two-phase parsing via MmtpContainerParser:
2915
+ * 1. parseHeader() → extract packetId + fecPayloadId for FEC feeding
2916
+ * 2. feed() → MFU extraction, reassembly, keyframe detection, emit via callbacks
2917
+ */
2918
+ #processMMTP(
2919
+ packet: Uint8Array,
2920
+ source: "multicast" | "moq",
2921
+ manifestIdentity?: ManifestObjectIdentity,
2922
+ manifestAdmitted = false,
2923
+ ingress?: MmtpIngressContext,
2924
+ overlapCopy = false,
2925
+ releasedFromQuarantine = false,
2926
+ ): void {
2927
+ // One header parse per packet: the parser reuses it in feedParsed()
2928
+ // below instead of parsing the same header again (BLO-36599).
2929
+ const parsed = this.#parser.parsePacket(packet);
2930
+ if (!parsed) return;
2931
+ const info = parsed.info;
2932
+ // Every multicast packet is a delivery-gap sample, including one dropped
2933
+ // below: the gap is a property of the link the FEC deadlines run on, not
2934
+ // of which packets this router routes. The re-entry after a manifest
2935
+ // admission is the same arrival, not a new one.
2936
+ if (source === "multicast" && !manifestAdmitted) {
2937
+ this.#fecManager?.noteMulticastArrival?.(info.packetId, info.sequenceNumber);
2938
+ }
2939
+ if (source === "multicast" && !ingressHasPacketId(ingress, info.packetId)) {
2940
+ debugRouter(
2941
+ `[MmtpRouter] Dropping multicast packet pid=${info.packetId}: absent from ingress tracks`,
2942
+ );
2943
+ return;
2944
+ }
2945
+ const packetRole = this.#packetIdRoles.get(info.packetId);
2946
+ if (
2947
+ source === "multicast" &&
2948
+ this.#classifyPacketId(info.packetId) === null &&
2949
+ (!info.isRepair || packetRole !== "repair")
2950
+ ) {
2951
+ debugRouter(
2952
+ `[MmtpRouter] Dropping multicast packet pid=${info.packetId}: physical PID is unregistered`,
2953
+ );
2954
+ return;
2955
+ }
2956
+ if (!info.isRepair && hasAmbiguousCatalogTrackName(ingress, info.packetId)) {
2957
+ return;
2958
+ }
2959
+ // A source FEC Payload ID is read under the packet's block profile. Until
2960
+ // the track's FEC is configured it has none: nothing (manifest identity,
2961
+ // ledgers, FEC state) reads it as a block coordinate, and the media is
2962
+ // routed as on a track without FEC.
2963
+ const fecUnreadable =
2964
+ !info.isRepair &&
2965
+ info.fecPayloadId != null &&
2966
+ this.#fecManager !== null &&
2967
+ this.#classifyPacketId(info.packetId) !== null &&
2968
+ this.#configuredBlockProfile(info.packetId) === undefined;
2969
+ if (fecUnreadable && !manifestAdmitted) this.#noteUnreadableFecPayloadId(info.packetId, "source");
2970
+
2971
+ if (!info.isRepair && this.#manifestVerifier && !manifestAdmitted) {
2972
+ let identity = manifestIdentity;
2973
+ if (!identity && info.fecPayloadId && !fecUnreadable && (this.#resolveManifestIdentityForRoute || this.#resolveManifestIdentity)) {
2974
+ const coordinates = this.#deriveSbnEsi(info.fecPayloadId.ssId, info.packetId);
2975
+ const catalogTrackName = catalogTrackNameForPacket(ingress, info.packetId);
2976
+ identity = (this.#resolveManifestIdentityForRoute
2977
+ ? this.#resolveManifestIdentityForRoute({
2978
+ packetId: info.packetId,
2979
+ sbn: coordinates.sbn,
2980
+ esi: coordinates.esi,
2981
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
2982
+ recovered: false,
2983
+ sourceSymbolFormat: "full-mmtp-packet",
2984
+ })
2985
+ : this.#resolveManifestIdentity!(
2986
+ info.packetId,
2987
+ coordinates.sbn,
2988
+ coordinates.esi,
2989
+ catalogTrackName,
2990
+ )) ?? undefined;
2991
+ }
2992
+ if (identity) {
2993
+ identity = {
2994
+ ...identity,
2995
+ packetId: info.packetId,
2996
+ ...(info.fecPayloadId ? { ssId: info.fecPayloadId.ssId } : {}),
2997
+ };
2998
+ const authenticated = info.fecPayloadId ? packet.subarray(0, packet.length - 4) : packet;
2999
+ // A `stall` admission quarantines the packet and releases it later,
3000
+ // after the stream has moved on; a synchronous release happens inside
3001
+ // admitObject, before this flag is set.
3002
+ let quarantined = false;
3003
+ const admission = this.#manifestVerifier.admitObject(identity, authenticated, () => {
3004
+ if (!this.#disposed) {
3005
+ this.#processMMTP(packet, source, identity, true, ingress, overlapCopy, quarantined);
3006
+ }
3007
+ });
3008
+ quarantined = admission.action === "stall";
3009
+ return;
3010
+ }
3011
+ if (
3012
+ this.#manifestVerifier.config.mode === "enforce" &&
3013
+ this.#manifestVerifier.config.onMissingManifest === "stall"
3014
+ ) {
3015
+ console.warn(`[MmtpRouter] manifest identity absent; stalling packetId=${info.packetId}`);
3016
+ return;
3017
+ }
3018
+ }
3019
+
3020
+ if (info.isRepair) {
3021
+ // Repair packet — per ISO 23008-1 §C.5.3, carries (SS_Start, SSB_length, RS_ID).
3022
+ // Read from the header parsed above rather than parsing it again
3023
+ // (BLO-36599).
3024
+ const repair = parseMoqRepairFrameWithHeader(packet, parsed.header);
3025
+ if (!repair) return;
3026
+
3027
+ const ownerPacketId = this.#resolveRepairOwnerPacketId(info.packetId, ingress);
3028
+ if (ownerPacketId != null && this.#fecManager) {
3029
+ if (hasAmbiguousCatalogTrackName(ingress, ownerPacketId)) return;
3030
+ this.#repairPidCounts[info.packetId] = (this.#repairPidCounts[info.packetId] || 0) + 1;
3031
+ const count = this.#repairPidCounts[info.packetId]!;
3032
+ if (count <= 2 || count % 500 === 0) {
3033
+ console.log(
3034
+ `[MC-PKT] pid=${info.packetId} repairOwner=${ownerPacketId} type=${info.packetType} count=${count} ssbLength=${repair.ssbLength} len=${packet.length}`,
3035
+ );
3036
+ }
3037
+
3038
+ if (this.#configuredBlockProfile(ownerPacketId) === undefined) {
3039
+ this.#noteUnreadableFecPayloadId(ownerPacketId, "repair");
3040
+ return;
3041
+ }
3042
+ // NOTE: Do NOT call trackBlock for repair packets. Only source symbols
3043
+ // should count toward block completion — repair inflates blocksClean stats
3044
+ // and masks blocks that actually needed FEC recovery.
3045
+ if (this.#isIsoPacket(ownerPacketId)) {
3046
+ this.#handleIsoRepair(ownerPacketId, catalogTrackNameForPacket(ingress, ownerPacketId), repair, source);
3047
+ return;
3048
+ }
3049
+ const repairSbn = this.#deriveRepairSbn(repair.ssStart, repair.ssbLength, ownerPacketId);
3050
+ if (repairSbn === null) return;
3051
+ const repairEsi = this.#kForPacket(ownerPacketId) + repair.rsId;
3052
+
3053
+ if (
3054
+ !this.#feedFecTransportSymbol(
3055
+ ownerPacketId,
3056
+ repairSbn,
3057
+ repairEsi,
3058
+ repair.symbolData,
3059
+ source,
3060
+ catalogTrackNameForPacket(ingress, ownerPacketId),
3061
+ )
3062
+ ) return;
3063
+
3064
+ // Repair symbols are block progress too. The deadline measures
3065
+ // inactivity, not total block lifetime.
3066
+ const repairCatalogTrackName = catalogTrackNameForPacket(ingress, ownerPacketId);
3067
+ if (repairCatalogTrackName === undefined) {
3068
+ this.#fecManager.noteBlockActivity?.(repairSbn, ownerPacketId);
3069
+ } else {
3070
+ this.#fecManager.noteBlockActivity?.(
3071
+ repairSbn,
3072
+ ownerPacketId,
3073
+ repairCatalogTrackName,
3074
+ );
3075
+ }
3076
+
3077
+ const recovered = repairCatalogTrackName === undefined
3078
+ ? this.#fecManager.feedRepairSymbol(
3079
+ ownerPacketId,
3080
+ repair.ssStart,
3081
+ repair.ssbLength,
3082
+ repair.rsId,
3083
+ repair.symbolData,
3084
+ )
3085
+ : this.#fecManager.feedRepairSymbol(
3086
+ ownerPacketId,
3087
+ repair.ssStart,
3088
+ repair.ssbLength,
3089
+ repair.rsId,
3090
+ repair.symbolData,
3091
+ repairCatalogTrackName,
3092
+ );
3093
+ if (recovered) {
3094
+ const alreadyCompleted = repairCatalogTrackName === undefined
3095
+ ? this.#fecManager.isBlockCompleted(repairSbn, ownerPacketId)
3096
+ : this.#fecManager.isBlockCompleted(
3097
+ repairSbn,
3098
+ ownerPacketId,
3099
+ repairCatalogTrackName,
3100
+ );
3101
+ if (repairCatalogTrackName === undefined) {
3102
+ this.#fecManager.cancelBlockTimeout(repairSbn, ownerPacketId);
3103
+ } else {
3104
+ this.#fecManager.cancelBlockTimeout(
3105
+ repairSbn,
3106
+ ownerPacketId,
3107
+ repairCatalogTrackName,
3108
+ );
3109
+ }
3110
+ // Skip routing if block was already completed via source path (all K sources
3111
+ // arrived). The parser's feed() already played every frame.
3112
+ if (!alreadyCompleted) {
3113
+ this.#fecManager.countRecovery(ownerPacketId, repairSbn, repairCatalogTrackName);
3114
+ if (repairCatalogTrackName === undefined) {
3115
+ this.#fecManager.markBlockCompleted(repairSbn, ownerPacketId);
3116
+ } else {
3117
+ this.#fecManager.markBlockCompleted(
3118
+ repairSbn,
3119
+ ownerPacketId,
3120
+ repairCatalogTrackName,
3121
+ );
3122
+ }
3123
+ this.#routeRecoveredBlock(
3124
+ repairSbn,
3125
+ recovered,
3126
+ ownerPacketId,
3127
+ catalogTrackNameForPacket(ingress, ownerPacketId),
3128
+ );
3129
+ }
3130
+ }
3131
+ }
3132
+ } else {
3133
+ // Drop source packets with unknown packetId — not in catalog.
3134
+ if (this.#classifyPacketId(info.packetId) === null) return;
3135
+
3136
+ // Debug: count packets per packetId
3137
+ this.#pidCounts[info.packetId] = (this.#pidCounts[info.packetId] || 0) + 1;
3138
+ const count = this.#pidCounts[info.packetId]!;
3139
+ if (count <= 2 || count % 500 === 0) {
3140
+ console.log(
3141
+ `[MC-PKT] pid=${info.packetId} type=${info.packetType} count=${count} len=${packet.length}`,
3142
+ );
3143
+ }
3144
+
3145
+ // Source packet — parse for playback first, then feed FEC only if the
3146
+ // source block did not complete cleanly. Feeding FEC before parser/feed
3147
+ // lets a source-triggered recovery race ahead of the source timeline.
3148
+ let fecInfo:
3149
+ | { sbn: number; esi: number; oversized?: boolean }
3150
+ | undefined;
3151
+ let sourceFec:
3152
+ | {
3153
+ ssId: number;
3154
+ sbn: number;
3155
+ symbolData: Uint8Array;
3156
+ }
3157
+ | null = null;
3158
+ let sourceSymbolAccepted = false;
3159
+ let sourceSymbolTrackable = false;
3160
+ let parseSourceSymbol = false;
3161
+ const catalogTrackName = catalogTrackNameForPacket(ingress, info.packetId);
3162
+ if (catalogTrackName !== undefined) {
3163
+ const names = this.#catalogTrackNamesByPacketId.get(info.packetId) ?? new Set();
3164
+ names.add(catalogTrackName);
3165
+ this.#catalogTrackNamesByPacketId.set(info.packetId, names);
3166
+ }
3167
+
3168
+ const duplicates = this.#stream(info.packetId, catalogTrackName).duplicates;
3169
+ // The FEC Payload ID FEC may read: none while the packetId has no
3170
+ // configured block profile (fecUnreadable above).
3171
+ const fecPayloadId = fecUnreadable ? null : info.fecPayloadId;
3172
+ // iso-ssbg1-v1 locates a source by range membership in a block a repair
3173
+ // already stated; until then it is held, unplaced (BLO-35597 iso:3).
3174
+ const isoSource = this.#fecManager !== null && fecPayloadId != null &&
3175
+ this.#isIsoPacket(info.packetId);
3176
+ const coords = this.#fecManager && fecPayloadId
3177
+ ? (isoSource
3178
+ ? this.#fecManager.locateIsoSource?.(info.packetId, catalogTrackName, fecPayloadId.ssId) ?? undefined
3179
+ : this.#deriveSbnEsi(fecPayloadId.ssId, info.packetId))
3180
+ : undefined;
3181
+ // Note the packet's FEC block (none without an FEC coordinate) before
3182
+ // any ledger lookup, the restart check below included.
3183
+ duplicates.advance(coords?.sbn);
3184
+ // A multicast packet that predates everything the stream has kept for a
3185
+ // whole FEC horizon is a publisher that restarted its
3186
+ // packet_sequence_number / SS_ID counters (BLO-36599,
3187
+ // StreamDuplicateLedgers.predatesHorizon). Its first packets would
3188
+ // otherwise read as duplicates or as frames behind the reorder cursor.
3189
+ // Start a fresh epoch for the stream before this packet is looked up.
3190
+ // The reset needs the catalog identity, so a stream without one is not
3191
+ // checked; an overlap copy is MoQ bytes and says nothing about the
3192
+ // multicast publisher, and neither does a packet the manifest verifier
3193
+ // quarantined: it predates the horizon because it waited, not because the
3194
+ // publisher restarted.
3195
+ if (
3196
+ source === "multicast" &&
3197
+ !overlapCopy &&
3198
+ !releasedFromQuarantine &&
3199
+ catalogTrackName !== undefined &&
3200
+ duplicates.predatesHorizon(info.sequenceNumber, fecPayloadId?.ssId)
3201
+ ) {
3202
+ this.#publisherRestarts++;
3203
+ console.warn(
3204
+ `[MmtpRouter] publisher restart on pid=${info.packetId} track=${catalogTrackName}: ` +
3205
+ `packet_sequence_number=${info.sequenceNumber} predates the stream's FEC horizon; ` +
3206
+ "starting a new epoch",
3207
+ );
3208
+ // Frames the release gate holds belong to the old epoch: release them
3209
+ // as a discontinuity stall, as FECManager's source-discontinuity path
3210
+ // does, before the reset discards the stream.
3211
+ this.#parser.markFecDiscontinuity(info.packetId, catalogTrackName);
3212
+ this.#parser.pokeFecRelease(info.packetId, catalogTrackName);
3213
+ this.resetTrack(info.packetId, catalogTrackName);
3214
+ this.#onPublisherRestart?.(info.packetId, catalogTrackName);
3215
+ // The reset cleared the ledgers: note this packet's block in the new epoch.
3216
+ duplicates.advance(coords?.sbn);
3217
+ }
3218
+ if (this.#fecManager && fecPayloadId && coords) {
3219
+ const fecId = fecPayloadId;
3220
+ const symbolSize = this.#symbolSizeForPacket(info.packetId);
3221
+
3222
+ // The mmtp-packet source symbol protects the complete MMTP packet
3223
+ // before SS_ID is appended, self-delimiting and zero-padded to T.
3224
+ const rawSymbol = packet.subarray(0, packet.length - 4);
3225
+ // iso-ssbg1-v1 symbols carry a two-octet length (C.3.3): admission
3226
+ // refuses length + 2 > T.
3227
+ const oversized = isoSource
3228
+ ? !(this.#fecManager.isoSourceFits?.(info.packetId, rawSymbol.length) ?? false)
3229
+ : symbolSize > 0 && rawSymbol.length > symbolSize;
3230
+
3231
+ // NOTE: trackBlock() is deferred to AFTER #feedViaParser (below).
3232
+ // Reason: trackBlock synchronously fires onBlockComplete when the
3233
+ // K-th source symbol arrives, which synchronously calls
3234
+ // CmafBlockAssembler.completeBlock → #flushBlock → emit. If
3235
+ // trackBlock fires BEFORE parser.feed, the K-th packet's
3236
+ // parser.onFrame → assembler.addFrame happens AFTER flushBlock
3237
+ // already drained the block — the K-th frame is stranded in a
3238
+ // newly-allocated empty block that never gets a completeBlock
3239
+ // event again, because that SBN is already in fecManager's
3240
+ // #completedBlocks set (BLO-3035 layer 4).
3241
+ //
3242
+ // Complete-packet symbols larger than T cannot be decoded but still
3243
+ // represent a source-symbol arrival, so trackBlock counts them below.
3244
+
3245
+ let overlapBlockCompleted = false;
3246
+ if (overlapCopy) {
3247
+ // Playback only. The multicast twin still feeds FEC when it
3248
+ // arrives. The per-symbol parse ledger below parses whichever copy
3249
+ // arrives first and drops the second; the MFU reassembler cannot,
3250
+ // because complete (FI=0) MFUs bypass its #emitted set. A completed
3251
+ // block stays authoritative, as it does for FEC input.
3252
+ overlapBlockCompleted = catalogTrackName === undefined
3253
+ ? this.#fecManager.isBlockCompleted(coords.sbn, info.packetId)
3254
+ : this.#fecManager.isBlockCompleted(
3255
+ coords.sbn,
3256
+ info.packetId,
3257
+ catalogTrackName,
3258
+ );
3259
+ } else if (oversized) {
3260
+ // Oversized packet — not FEC-encoded by FFmpeg, skip FEC feed.
3261
+ // Still emitted via parser.feed() below (direct path).
3262
+ sourceSymbolAccepted = this.#feedFecTransportSymbol(
3263
+ info.packetId,
3264
+ coords.sbn,
3265
+ coords.esi,
3266
+ rawSymbol,
3267
+ source,
3268
+ catalogTrackName,
3269
+ );
3270
+ sourceSymbolTrackable = sourceSymbolAccepted;
3271
+ } else {
3272
+ // FECManager zero-pads a short symbol to T itself and then owns that
3273
+ // buffer outright, so it can transfer it to the FEC Worker without
3274
+ // copying it again. Padding here as well cost a second T-byte
3275
+ // allocation and copy per short packet (BLO-36599).
3276
+ const symbolData = rawSymbol;
3277
+
3278
+ sourceSymbolAccepted = this.#feedFecTransportSymbol(
3279
+ info.packetId,
3280
+ coords.sbn,
3281
+ coords.esi,
3282
+ symbolData,
3283
+ source,
3284
+ catalogTrackName,
3285
+ );
3286
+ sourceSymbolTrackable = sourceSymbolAccepted;
3287
+ if (sourceSymbolAccepted) {
3288
+ sourceFec = { ssId: fecId.ssId, sbn: coords.sbn, symbolData };
3289
+ }
3290
+ }
3291
+
3292
+ // First copy wins per symbol, in either arrival order.
3293
+ parseSourceSymbol =
3294
+ (overlapCopy ? !overlapBlockCompleted : sourceSymbolAccepted) &&
3295
+ !duplicates.parsedSymbols.has(fecId.ssId);
3296
+
3297
+ // Compute FEC block info for the MSE assembler.
3298
+ fecInfo = { sbn: coords.sbn, esi: coords.esi, oversized };
3299
+ } else if (isoSource && this.#fecManager && fecPayloadId) {
3300
+ // iso-ssbg1-v1 source whose block no repair has stated yet: route its
3301
+ // media now and hold its FEC symbol until a repair places it.
3302
+ const ssId = fecPayloadId.ssId;
3303
+ if (overlapCopy) {
3304
+ parseSourceSymbol = !duplicates.parsedSymbols.has(ssId);
3305
+ } else {
3306
+ const rawSymbol = packet.subarray(0, packet.length - 4);
3307
+ const held = this.#fecManager.holdIsoSource?.(info.packetId, catalogTrackName, ssId, {
3308
+ symbolData: rawSymbol,
3309
+ source,
3310
+ evidence: sourceSymbolEvidence(packet, parsed.header, info.rapFlag, info.sequenceNumber),
3311
+ oversized: !(this.#fecManager.isoSourceFits?.(info.packetId, rawSymbol.length) ?? false),
3312
+ }) ?? false;
3313
+ parseSourceSymbol = held && !duplicates.parsedSymbols.has(ssId);
3314
+ }
3315
+ } else if (fecUnreadable && info.fecPayloadId) {
3316
+ // FEC is unavailable for this packetId: its media is routed as on a
3317
+ // track without FEC, first copy per SS_ID. The parse ledger reads
3318
+ // SS_ID only as a symbol identity, never as a block coordinate.
3319
+ parseSourceSymbol = !duplicates.parsedSymbols.has(info.fecPayloadId.ssId);
3320
+ }
3321
+
3322
+ // Parse only the first copy of an FEC source symbol. A late direct copy
3323
+ // of a worker-recovered fragment has the same SBN/ESI and fragment
3324
+ // counter; feeding it again can make the MFU buffer non-contiguous even
3325
+ // when FI=1..3 were already complete. Non-FEC packets still always flow.
3326
+ if (!this.#fecManager || !info.fecPayloadId || parseSourceSymbol) {
3327
+ if (this.#fecManager && info.fecPayloadId) {
3328
+ duplicates.parsedSymbols.add(info.fecPayloadId.ssId);
3329
+ }
3330
+ this.#noteSourcePacketSeq(
3331
+ info.packetId,
3332
+ info.sequenceNumber,
3333
+ catalogTrackName,
3334
+ );
3335
+ this.#routeTrackPacket(packet, info.packetId, info.sequenceNumber, {
3336
+ recovered: false,
3337
+ skipFecPayloadId: false,
3338
+ catalogTrackName,
3339
+ });
3340
+ this.#feedViaParser(packet, info, fecInfo, {
3341
+ source,
3342
+ catalogTrackName,
3343
+ ...(isoSource && info.fecPayloadId
3344
+ ? { fecSbn: info.fecPayloadId.ssId }
3345
+ : fecInfo === undefined ? {} : { fecSbn: fecInfo.sbn }),
3346
+ }, parsed);
3347
+ }
3348
+ // The frame this packet completed can commit an in-place switch that
3349
+ // retires this very packet ID. Its FEC state is gone then, and feeding
3350
+ // the rest of this packet would only rebuild it for a stream that ended.
3351
+ if (this.#classifyPacketId(info.packetId) === null) return;
3352
+
3353
+ // Deferred trackBlock (BLO-3035 layer 4 race fix). Fire AFTER
3354
+ // #feedViaParser → parser.feed → addFrame, so the K-th source
3355
+ // packet's frame is buffered in CmafBlockAssembler.#blocks BEFORE
3356
+ // trackBlock synchronously triggers onBlockComplete → completeBlock
3357
+ // → #flushBlock. Without this ordering, the K-th frame was lost on
3358
+ // every block — only K-1 frames per block were ever emitted to MSE.
3359
+ // Only first-arriving symbols count toward completion or playback.
3360
+ if (
3361
+ this.#fecManager &&
3362
+ fecPayloadId &&
3363
+ sourceSymbolAccepted &&
3364
+ sourceSymbolTrackable
3365
+ ) {
3366
+ const { sbn, esi } = isoSource && coords
3367
+ ? coords
3368
+ : this.#deriveSbnEsi(fecPayloadId.ssId, info.packetId);
3369
+ if (
3370
+ this.#classifyPacketId(info.packetId) !== null &&
3371
+ this.#fecManager.isTrackConfigured?.(info.packetId) !== false
3372
+ ) {
3373
+ // Only this first-arrival network source symbol is classification
3374
+ // evidence: the R bit and the MPU / DU identity it carries.
3375
+ // Recovered and repair symbols never reach this call.
3376
+ this.#fecManager.trackBlock(
3377
+ sbn,
3378
+ info.packetId,
3379
+ catalogTrackName,
3380
+ esi,
3381
+ sourceSymbolEvidence(packet, parsed.header, info.rapFlag, info.sequenceNumber),
3382
+ );
3383
+ this.#requestFecPoke(info.packetId, catalogTrackName);
3384
+ }
3385
+ }
3386
+
3387
+ // Only now feed source symbols into FEC. The decoder must still receive
3388
+ // the K-th source symbol after trackBlock() marks a clean block complete:
3389
+ // Worker-backed decoders use that symbol for their own block lifecycle
3390
+ // and stats. Suppress any synchronous recovered-block return when the
3391
+ // manager already completed the block, because every source frame was
3392
+ // parsed and emitted above.
3393
+ if (this.#fecManager && sourceFec) {
3394
+ const alreadyCompleted = catalogTrackName === undefined
3395
+ ? this.#fecManager.isBlockCompleted(sourceFec.sbn, info.packetId)
3396
+ : this.#fecManager.isBlockCompleted(
3397
+ sourceFec.sbn,
3398
+ info.packetId,
3399
+ catalogTrackName,
3400
+ );
3401
+ const recovered = catalogTrackName === undefined
3402
+ ? this.#fecManager.feedSourceSymbol(
3403
+ info.packetId,
3404
+ sourceFec.ssId,
3405
+ sourceFec.symbolData,
3406
+ undefined,
3407
+ )
3408
+ : this.#fecManager.feedSourceSymbol(
3409
+ info.packetId,
3410
+ sourceFec.ssId,
3411
+ sourceFec.symbolData,
3412
+ undefined,
3413
+ catalogTrackName,
3414
+ );
3415
+ if (recovered) {
3416
+ if (!alreadyCompleted && this.#classifyPacketId(info.packetId) !== null) {
3417
+ if (catalogTrackName === undefined) {
3418
+ this.#fecManager.cancelBlockTimeout(sourceFec.sbn, info.packetId);
3419
+ } else {
3420
+ this.#fecManager.cancelBlockTimeout(
3421
+ sourceFec.sbn,
3422
+ info.packetId,
3423
+ catalogTrackName,
3424
+ );
3425
+ }
3426
+ }
3427
+ // Only route recovered blocks that needed actual repair.
3428
+ if (!alreadyCompleted) {
3429
+ this.#fecManager.countRecovery(info.packetId, sourceFec.sbn, catalogTrackName);
3430
+ if (catalogTrackName === undefined) {
3431
+ this.#fecManager.markBlockCompleted(sourceFec.sbn, info.packetId);
3432
+ } else {
3433
+ this.#fecManager.markBlockCompleted(
3434
+ sourceFec.sbn,
3435
+ info.packetId,
3436
+ catalogTrackName,
3437
+ );
3438
+ }
3439
+ this.#routeRecoveredBlock(
3440
+ sourceFec.sbn,
3441
+ recovered,
3442
+ info.packetId,
3443
+ catalogTrackName,
3444
+ );
3445
+ }
3446
+ }
3447
+ }
3448
+ }
3449
+ }
3450
+
3451
+ /**
3452
+ * Set temp context and delegate to parser.feed() for MFU extraction,
3453
+ * reassembly, keyframe detection, and frame emission via callbacks.
3454
+ */
3455
+ #feedViaParser(
3456
+ packet: Uint8Array,
3457
+ info: {
3458
+ packetId: number;
3459
+ sequenceNumber: number;
3460
+ fecPayloadId?: { ssId: number } | null;
3461
+ },
3462
+ fecInfo?: { sbn: number; esi: number; oversized?: boolean },
3463
+ parserOptions?: {
3464
+ skipFecPayloadId?: boolean;
3465
+ recovered?: boolean;
3466
+ source?: MmtpSourcePath;
3467
+ deliveryProvenance?: MmtpDeliveryProvenance;
3468
+ catalogTrackName?: string;
3469
+ fecSbn?: number;
3470
+ },
3471
+ parsed?: ParsedMmtpPacket,
3472
+ ): void {
3473
+ const diag = this.#diagFor(info.packetId, parserOptions?.catalogTrackName);
3474
+ // Plain parser counters, not reassembler.getStatus(): on the wasm backend
3475
+ // each status read is a stats() crossing, and this runs for every packet
3476
+ // (BLO-36599). Buffer occupancy is read once, in getDiagnostics().
3477
+ const fragmentsBefore = this.#parser.reassemblerFragmentsFed;
3478
+ const reassembledBefore = this.#parser.reassemblerDataUnitsReturned;
3479
+ diag.packets++;
3480
+ diag.lastPacketLength = packet.byteLength;
3481
+ diag.lastSequenceNumber = info.sequenceNumber;
3482
+ if (info.fecPayloadId) diag.lastFecSsId = info.fecPayloadId.ssId;
3483
+ this.#currentFecInfo = fecInfo;
3484
+ this.#currentSeqNum = info.sequenceNumber;
3485
+ try {
3486
+ const feedOptions = {
3487
+ skipFecPayloadId: parserOptions?.skipFecPayloadId,
3488
+ recovered: parserOptions?.recovered,
3489
+ source: parserOptions?.source,
3490
+ deliveryProvenance: parserOptions?.deliveryProvenance,
3491
+ catalogTrackName: parserOptions?.catalogTrackName,
3492
+ ...(parserOptions?.fecSbn === undefined ? {} : { fecSbn: parserOptions.fecSbn }),
3493
+ };
3494
+ if (parsed) this.#parser.feedParsed(packet, parsed, feedOptions);
3495
+ else this.#parser.feed(packet, feedOptions);
3496
+ } finally {
3497
+ this.#currentFecInfo = undefined;
3498
+ }
3499
+ const fragmentDelta = this.#parser.reassemblerFragmentsFed - fragmentsBefore;
3500
+ const reassembledDelta =
3501
+ this.#parser.reassemblerDataUnitsReturned - reassembledBefore;
3502
+ if (fragmentDelta > 0) diag.fragments += fragmentDelta;
3503
+ else diag.noFragment++;
3504
+ if (reassembledDelta > 0) diag.reassembled += reassembledDelta;
3505
+ }
3506
+
3507
+ #feedFecTransportSymbol(
3508
+ packetId: number,
3509
+ sbn: number,
3510
+ esi: number,
3511
+ data: Uint8Array,
3512
+ source: MmtpSourcePath,
3513
+ catalogTrackName?: string,
3514
+ ): boolean {
3515
+ const fecManager = this.#fecManager;
3516
+ if (!fecManager) return false;
3517
+ return catalogTrackName === undefined
3518
+ ? fecManager.feedSymbolFromSource(packetId, sbn, esi, data, source)
3519
+ : fecManager.feedSymbolFromSource(
3520
+ packetId,
3521
+ sbn,
3522
+ esi,
3523
+ data,
3524
+ source,
3525
+ catalogTrackName,
3526
+ );
3527
+ }
3528
+
3529
+ #diagFor(packetId: number, catalogTrackName?: string): ParserDiagnostics {
3530
+ const streamKey = this.#mediaStreamKey(packetId, catalogTrackName);
3531
+ let diag = this.#parserDiagnostics.get(streamKey);
3532
+ if (!diag) {
3533
+ diag = {
3534
+ packets: 0,
3535
+ frames: 0,
3536
+ initSegments: 0,
3537
+ noFragment: 0,
3538
+ fragments: 0,
3539
+ reassembled: 0,
3540
+ pendingBuffers: 0,
3541
+ pendingFragments: 0,
3542
+ lastPacketLength: 0,
3543
+ lastSequenceNumber: 0,
3544
+ directAudioSourceEmitted: 0,
3545
+ directAudioSourceRegressions: 0,
3546
+ directAudioRecoveredQueued: 0,
3547
+ directAudioRecoveredDrained: 0,
3548
+ directAudioRecoveredStaleDropped: 0,
3549
+ };
3550
+ this.#parserDiagnostics.set(streamKey, diag);
3551
+ }
3552
+ return diag;
3553
+ }
3554
+
3555
+ #shouldEmitFrame(
3556
+ packetId: number,
3557
+ keyframe: boolean,
3558
+ catalogTrackName?: string,
3559
+ ): boolean {
3560
+ if (!this.#fecManager) return true;
3561
+ if (!this.#videoPacketIds.has(packetId)) return true;
3562
+ const streamKey = this.#mediaStreamKey(packetId, catalogTrackName);
3563
+ if (this.#trackSynced.has(streamKey)) return true;
3564
+ if (!keyframe) {
3565
+ this.#droppedPreSyncVideo.add(streamKey);
3566
+ return false;
3567
+ }
3568
+ this.#trackSynced.add(streamKey);
3569
+ this.#droppedPreSyncVideo.delete(streamKey);
3570
+ return true;
3571
+ }
3572
+
3573
+ #opensFecSync(
3574
+ packetId: number,
3575
+ keyframe: boolean,
3576
+ catalogTrackName?: string,
3577
+ ): boolean {
3578
+ const streamKey = this.#mediaStreamKey(packetId, catalogTrackName);
3579
+ return !!this.#fecManager &&
3580
+ this.#videoPacketIds.has(packetId) &&
3581
+ !this.#trackSynced.has(streamKey) &&
3582
+ this.#droppedPreSyncVideo.has(streamKey) &&
3583
+ keyframe;
3584
+ }
3585
+
3586
+ #noteEmittedVideoSeq(streamKey: string, packetSequenceNumber: number | undefined): void {
3587
+ if (packetSequenceNumber === undefined || !Number.isFinite(packetSequenceNumber)) return;
3588
+ const latest = this.#latestVideoSeqByStream.get(streamKey);
3589
+ if (latest === undefined || packetSequenceNumber > latest) {
3590
+ this.#latestVideoSeqByStream.set(streamKey, packetSequenceNumber);
3591
+ }
3592
+ }
3593
+
3594
+ #noteSourcePacketSeq(
3595
+ packetId: number,
3596
+ packetSequenceNumber: number | undefined,
3597
+ catalogTrackName?: string,
3598
+ ): void {
3599
+ if (packetSequenceNumber === undefined || !Number.isFinite(packetSequenceNumber)) return;
3600
+ this.#stream(packetId, catalogTrackName).duplicates.sourceSeqs.add(packetSequenceNumber);
3601
+ }
3602
+
3603
+ #sourceSeqReceived(
3604
+ packetId: number,
3605
+ packetSequenceNumber: number,
3606
+ catalogTrackName?: string,
3607
+ ): boolean {
3608
+ return this.#stream(packetId, catalogTrackName).duplicates.sourceSeqs.has(packetSequenceNumber);
3609
+ }
3610
+
3611
+ #routeTrackPacket(
3612
+ packet: Uint8Array,
3613
+ packetId: number,
3614
+ packetSequenceNumber: number,
3615
+ meta: Pick<
3616
+ RoutedPacketMeta,
3617
+ "recovered" | "skipFecPayloadId" | "catalogTrackName" | "deliveryProvenance"
3618
+ >,
3619
+ ): void {
3620
+ if (!Number.isFinite(packetSequenceNumber)) return;
3621
+ const callback = this.#trackPacketCallbacks.get(packetId);
3622
+ if (!callback) return;
3623
+ const routedSeqs = this.#stream(packetId, meta.catalogTrackName).duplicates.routedSeqs;
3624
+ if (routedSeqs.has(packetSequenceNumber)) return;
3625
+ routedSeqs.add(packetSequenceNumber);
3626
+ callback(packet, {
3627
+ packetId,
3628
+ packetSequenceNumber,
3629
+ recovered: meta.recovered,
3630
+ skipFecPayloadId: meta.skipFecPayloadId,
3631
+ ...(meta.catalogTrackName === undefined
3632
+ ? {}
3633
+ : { catalogTrackName: meta.catalogTrackName }),
3634
+ ...(meta.deliveryProvenance === undefined
3635
+ ? {}
3636
+ : { deliveryProvenance: meta.deliveryProvenance }),
3637
+ });
3638
+ }
3639
+
3640
+ #isStaleRecoveredVideo(
3641
+ packetId: number,
3642
+ packetSequenceNumber: number,
3643
+ catalogTrackName?: string,
3644
+ ): boolean {
3645
+ const latest = this.#latestVideoSeqByStream.get(
3646
+ this.#mediaStreamKey(packetId, catalogTrackName),
3647
+ );
3648
+ return latest !== undefined && packetSequenceNumber <= latest;
3649
+ }
3650
+
3651
+ /**
3652
+ * JSON tuple naming one physical stream. Built several times per packet, so
3653
+ * each (packetId, catalog name) pair is serialized once and reused; the set
3654
+ * of pairs is bounded by the catalog (BLO-36599).
3655
+ */
3656
+ #mediaStreamKey(packetId: number, catalogTrackName?: string): string {
3657
+ return this.#stream(packetId, catalogTrackName).key;
3658
+ }
3659
+
3660
+ /** The (packetId, catalog name) stream's key and duplicate ledgers. */
3661
+ #stream(packetId: number, catalogTrackName?: string): StreamLedgers {
3662
+ let byName = this.#streams.get(packetId);
3663
+ if (!byName) {
3664
+ byName = new Map();
3665
+ this.#streams.set(packetId, byName);
3666
+ }
3667
+ let stream = byName.get(catalogTrackName);
3668
+ if (stream === undefined) {
3669
+ stream = {
3670
+ key: JSON.stringify([packetId, catalogTrackName ?? null]),
3671
+ duplicates: new StreamDuplicateLedgers(() => this.duplicateHorizonFor(packetId)),
3672
+ };
3673
+ byName.set(catalogTrackName, stream);
3674
+ }
3675
+ return stream;
3676
+ }
3677
+
3678
+ #packetIdFromStreamKey(key: string): number {
3679
+ try {
3680
+ const decoded = JSON.parse(key);
3681
+ if (Array.isArray(decoded) && typeof decoded[0] === "number") return decoded[0];
3682
+ } catch {
3683
+ // Internal keys are JSON tuples; malformed legacy keys should not happen.
3684
+ }
3685
+ return 0;
3686
+ }
3687
+
3688
+ #mseAssemblerKey(packetId: number, catalogTrackName?: string): string {
3689
+ return this.#mediaStreamKey(packetId, catalogTrackName);
3690
+ }
3691
+
3692
+ #mseAssemblerFor(
3693
+ packetId: number,
3694
+ catalogTrackName?: string,
3695
+ ): CmafBlockAssembler | undefined {
3696
+ const exact = this.#mseAssemblers.get(
3697
+ this.#mseAssemblerKey(packetId, catalogTrackName),
3698
+ );
3699
+ if (exact || catalogTrackName === undefined) return exact;
3700
+ if (!this.#isCatalogTrackUnambiguous(packetId, catalogTrackName)) return undefined;
3701
+ return this.#mseAssemblers.get(this.#mseAssemblerKey(packetId));
3702
+ }
3703
+
3704
+ #isCatalogTrackUnambiguous(
3705
+ packetId: number,
3706
+ catalogTrackName?: string,
3707
+ ): boolean {
3708
+ if (catalogTrackName === undefined) return true;
3709
+ const names = this.#catalogTrackNamesByPacketId.get(packetId);
3710
+ return names?.size === 1 && names.has(catalogTrackName);
3711
+ }
3712
+
3713
+ #resolveRecoveredTiming(
3714
+ packetId: number,
3715
+ mpuSequenceNumber: number,
3716
+ movieFragmentSequenceNumber: number | undefined,
3717
+ sampleNumber: number | undefined,
3718
+ mmtpTimestamp: bigint,
3719
+ catalogTrackName?: string,
3720
+ ): { authoredTiming?: AuthoredTiming; timestamp: number; mmtpTimestamp: bigint } | null {
3721
+ // Non-timed MFUs do not carry a movie-fragment/sample identity. They are
3722
+ // outside the ISO-BMFF presentation timeline, so retain the raw MMTP
3723
+ // timestamp path used by the untimed router contract.
3724
+ if (movieFragmentSequenceNumber === undefined || sampleNumber === undefined) {
3725
+ return {
3726
+ timestamp: mmtpTimestampToMicros(mmtpTimestamp),
3727
+ mmtpTimestamp,
3728
+ };
3729
+ }
3730
+
3731
+ // Claim the resolver entry before scaling it. The claim is the single
3732
+ // consume operation for a recovered sample; doing lookup → scale → claim
3733
+ // would leave an invalid entry live forever when scaling rejects it.
3734
+ const authoredTiming = this.#parser.consumeAuthoredTimingFor(
3735
+ packetId,
3736
+ mpuSequenceNumber,
3737
+ movieFragmentSequenceNumber,
3738
+ sampleNumber,
3739
+ catalogTrackName,
3740
+ );
3741
+ // Recovery is accepted only when the timed stream's FT=1 metadata names
3742
+ // this exact sample. MMTP send timestamps describe transport arrival, not
3743
+ // the authored presentation timeline, so they are never a fallback.
3744
+ if (authoredTiming === null) return null;
3745
+ const scaled = scaleAuthoredTiming(authoredTiming, 1_000_000);
3746
+ if (scaled === null) return null;
3747
+ return { authoredTiming, timestamp: scaled.timestamp, mmtpTimestamp };
3748
+ }
3749
+
3750
+ #pendingRecoveredKey(meta: RoutedFrameMeta): string {
3751
+ return `${meta.timestamp}:${meta.packetSequenceNumber ?? ""}`;
3752
+ }
3753
+
3754
+ #pendingRecoveredQueue(streamKey: string): Map<string, PendingRecoveredVideoFrame> {
3755
+ let queue = this.#pendingDirectRecoveredVideoByStream.get(streamKey);
3756
+ if (!queue) {
3757
+ queue = new Map();
3758
+ this.#pendingDirectRecoveredVideoByStream.set(streamKey, queue);
3759
+ }
3760
+ return queue;
3761
+ }
3762
+
3763
+ #comparePendingRecoveredVideo(
3764
+ a: PendingRecoveredVideoFrame,
3765
+ b: PendingRecoveredVideoFrame,
3766
+ ): number {
3767
+ if (a.meta.timestamp !== b.meta.timestamp) return a.meta.timestamp - b.meta.timestamp;
3768
+ const aSeq = a.meta.packetSequenceNumber ?? Number.MAX_SAFE_INTEGER;
3769
+ const bSeq = b.meta.packetSequenceNumber ?? Number.MAX_SAFE_INTEGER;
3770
+ return aSeq - bSeq;
3771
+ }
3772
+
3773
+ #noteDeliveredVideo(streamKey: string, meta: StreamSwitchPosition): void {
3774
+ const high = this.#deliveredVideoHighWaterByStream.get(streamKey);
3775
+ if (high === undefined || comparePresentation(meta, high) > 0) {
3776
+ this.#deliveredVideoHighWaterByStream.set(streamKey, switchPosition(meta));
3777
+ }
3778
+ }
3779
+
3780
+ #emitDirectVideo(
3781
+ packetId: number,
3782
+ data: Uint8Array,
3783
+ meta: RoutedFrameMeta,
3784
+ cb: FrameCallback | null,
3785
+ ): boolean {
3786
+ const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
3787
+ const lastEmitted = this.#lastDirectEmittedVideoTsByStream.get(streamKey);
3788
+ if (lastEmitted !== undefined && meta.timestamp <= lastEmitted) return false;
3789
+ this.#lastDirectEmittedVideoTsByStream.set(streamKey, meta.timestamp);
3790
+ this.#noteDeliveredVideo(streamKey, meta);
3791
+ // Record the emitted sequence number HERE — at the real emit — not when a
3792
+ // recovered frame is queued. #routeDirectRecoveredVideo may enqueue a frame
3793
+ // that is ahead of the source cursor; noting its seq before it drains would
3794
+ // make #isStaleRecoveredVideo drop an earlier gap frame that arrives later
3795
+ // (it would look <= the queued-but-unemitted seq). The high-water mark must
3796
+ // reflect what actually reached the decoder callback.
3797
+ this.#noteEmittedVideoSeq(streamKey, meta.packetSequenceNumber);
3798
+ cb?.(data, meta);
3799
+ return true;
3800
+ }
3801
+
3802
+ #drainDirectRecoveredVideo(
3803
+ streamKey: string,
3804
+ packetId: number,
3805
+ releases: (meta: RoutedFrameMeta) => boolean,
3806
+ cb: FrameCallback | null,
3807
+ ): void {
3808
+ const queue = this.#pendingDirectRecoveredVideoByStream.get(streamKey);
3809
+ if (!queue || queue.size === 0) return;
3810
+
3811
+ const ready = [...queue.values()]
3812
+ .filter(frame => releases(frame.meta))
3813
+ .sort((a, b) => this.#comparePendingRecoveredVideo(a, b));
3814
+
3815
+ for (const frame of ready) {
3816
+ queue.delete(this.#pendingRecoveredKey(frame.meta));
3817
+ this.#emitDirectVideo(packetId, frame.data, frame.meta, cb);
3818
+ }
3819
+
3820
+ if (queue.size === 0) {
3821
+ this.#pendingDirectRecoveredVideoByStream.delete(streamKey);
3822
+ }
3823
+ }
3824
+
3825
+ #enqueueDirectRecoveredVideo(
3826
+ packetId: number,
3827
+ data: Uint8Array,
3828
+ meta: RoutedFrameMeta,
3829
+ ): void {
3830
+ const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
3831
+ const lastEmitted = this.#lastDirectEmittedVideoTsByStream.get(streamKey);
3832
+ if (lastEmitted !== undefined && meta.timestamp <= lastEmitted) return;
3833
+
3834
+ const queue = this.#pendingRecoveredQueue(streamKey);
3835
+ queue.set(this.#pendingRecoveredKey(meta), { data, meta: { ...meta } });
3836
+
3837
+ if (queue.size > DIRECT_RECOVERED_VIDEO_QUEUE_LIMIT) {
3838
+ const keep = [...queue.entries()]
3839
+ .sort((a, b) => this.#comparePendingRecoveredVideo(a[1], b[1]))
3840
+ .slice(-Math.floor(DIRECT_RECOVERED_VIDEO_QUEUE_LIMIT / 2));
3841
+ queue.clear();
3842
+ for (const [key, frame] of keep) queue.set(key, frame);
3843
+ }
3844
+ }
3845
+
3846
+ #routeDirectSourceVideo(
3847
+ packetId: number,
3848
+ data: Uint8Array,
3849
+ meta: RoutedFrameMeta,
3850
+ cb: FrameCallback | null,
3851
+ ): void {
3852
+ const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
3853
+ const lastSourceTs = this.#lastDirectSourceVideoTsByStream.get(streamKey);
3854
+ if (lastSourceTs === undefined || meta.timestamp > lastSourceTs) {
3855
+ this.#lastDirectSourceVideoTsByStream.set(streamKey, meta.timestamp);
3856
+ }
3857
+
3858
+ // Recovered FEC may arrive before the source stream reaches the same media
3859
+ // time. Drain those gap fills immediately before the source frame that proves
3860
+ // the live cursor caught up, preserving the decoder dependency chain.
3861
+ this.#drainDirectRecoveredVideo(streamKey, packetId, frame => frame.timestamp <= meta.timestamp, cb);
3862
+ this.#emitDirectVideo(packetId, data, meta, cb);
3863
+ }
3864
+
3865
+ #routeDirectRecoveredVideo(
3866
+ packetId: number,
3867
+ data: Uint8Array,
3868
+ meta: RoutedFrameMeta,
3869
+ cb: FrameCallback | null,
3870
+ ): void {
3871
+ const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
3872
+ this.#enqueueDirectRecoveredVideo(packetId, data, meta);
3873
+ const lastSourceTs = this.#lastDirectSourceVideoTsByStream.get(streamKey);
3874
+ this.#drainDirectRecoveredVideo(
3875
+ streamKey,
3876
+ packetId,
3877
+ frame => lastSourceTs === undefined || frame.timestamp <= lastSourceTs,
3878
+ cb,
3879
+ );
3880
+ }
3881
+
3882
+ #pendingRecoveredAudioQueue(streamKey: string): Map<string, PendingRecoveredAudioFrame> {
3883
+ let queue = this.#pendingDirectRecoveredAudioByStream.get(streamKey);
3884
+ if (!queue) {
3885
+ queue = new Map();
3886
+ this.#pendingDirectRecoveredAudioByStream.set(streamKey, queue);
3887
+ }
3888
+ return queue;
3889
+ }
3890
+
3891
+ #directAudioCursor(meta: RoutedFrameMeta): DirectAudioCursor {
3892
+ return {
3893
+ timestamp: meta.timestamp,
3894
+ // A recovered frame without transport sequence metadata must drain before
3895
+ // an equal-timestamp source horizon instead of remaining queued forever.
3896
+ sequenceNumber: meta.packetSequenceNumber ?? Number.MIN_SAFE_INTEGER,
3897
+ };
3898
+ }
3899
+
3900
+ #compareDirectAudioCursor(a: DirectAudioCursor, b: DirectAudioCursor): number {
3901
+ if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp;
3902
+ return a.sequenceNumber - b.sequenceNumber;
3903
+ }
3904
+
3905
+ #emitDirectAudio(
3906
+ packetId: number,
3907
+ data: Uint8Array,
3908
+ meta: RoutedFrameMeta,
3909
+ cb: FrameCallback | null,
3910
+ ): boolean {
3911
+ const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
3912
+ const cursor = this.#directAudioCursor(meta);
3913
+ const lastEmitted = this.#lastDirectEmittedAudioCursorByStream.get(streamKey);
3914
+ if (
3915
+ lastEmitted !== undefined &&
3916
+ this.#compareDirectAudioCursor(cursor, lastEmitted) <= 0
3917
+ ) return false;
3918
+ this.#lastDirectEmittedAudioCursorByStream.set(streamKey, cursor);
3919
+ cb?.(data, meta);
3920
+ return true;
3921
+ }
3922
+
3923
+ #emitDirectSourceAudio(
3924
+ packetId: number,
3925
+ data: Uint8Array,
3926
+ meta: RoutedFrameMeta,
3927
+ cb: FrameCallback | null,
3928
+ ): void {
3929
+ const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
3930
+ const cursor = this.#directAudioCursor(meta);
3931
+ const lastEmitted = this.#lastDirectEmittedAudioCursorByStream.get(streamKey);
3932
+ const diag = this.#diagFor(packetId, meta.catalogTrackName);
3933
+ diag.directAudioSourceEmitted++;
3934
+ if (
3935
+ lastEmitted !== undefined &&
3936
+ this.#compareDirectAudioCursor(cursor, lastEmitted) <= 0
3937
+ ) {
3938
+ // Source packets already passed transport/parser validation. Preserve
3939
+ // them even when unordered multicast completion moves the callback
3940
+ // cursor backward; only recovered frames are optional stale gap fills.
3941
+ diag.directAudioSourceRegressions++;
3942
+ } else {
3943
+ this.#lastDirectEmittedAudioCursorByStream.set(streamKey, cursor);
3944
+ }
3945
+ cb?.(data, meta);
3946
+ }
3947
+
3948
+ #drainDirectRecoveredAudioUpTo(
3949
+ streamKey: string,
3950
+ packetId: number,
3951
+ horizon: DirectAudioCursor,
3952
+ cb: FrameCallback | null,
3953
+ ): void {
3954
+ const queue = this.#pendingDirectRecoveredAudioByStream.get(streamKey);
3955
+ if (!queue || queue.size === 0) return;
3956
+
3957
+ const ready = [...queue.values()]
3958
+ .filter(frame => this.#compareDirectAudioCursor(
3959
+ this.#directAudioCursor(frame.meta),
3960
+ horizon,
3961
+ ) <= 0)
3962
+ .sort((a, b) => this.#compareDirectAudioCursor(
3963
+ this.#directAudioCursor(a.meta),
3964
+ this.#directAudioCursor(b.meta),
3965
+ ));
3966
+
3967
+ for (const frame of ready) {
3968
+ queue.delete(this.#pendingRecoveredKey(frame.meta));
3969
+ if (this.#emitDirectAudio(packetId, frame.data, frame.meta, cb)) {
3970
+ this.#diagFor(packetId, frame.meta.catalogTrackName)
3971
+ .directAudioRecoveredDrained++;
3972
+ } else {
3973
+ this.#diagFor(packetId, frame.meta.catalogTrackName)
3974
+ .directAudioRecoveredStaleDropped++;
3975
+ }
3976
+ }
3977
+
3978
+ if (queue.size === 0) {
3979
+ this.#pendingDirectRecoveredAudioByStream.delete(streamKey);
3980
+ }
3981
+ }
3982
+
3983
+ #routeDirectSourceAudio(
3984
+ packetId: number,
3985
+ data: Uint8Array,
3986
+ meta: RoutedFrameMeta,
3987
+ cb: FrameCallback | null,
3988
+ ): void {
3989
+ const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
3990
+ const cursor = this.#directAudioCursor(meta);
3991
+ const lastSource = this.#lastDirectSourceAudioCursorByStream.get(streamKey);
3992
+ if (
3993
+ lastSource === undefined ||
3994
+ this.#compareDirectAudioCursor(cursor, lastSource) > 0
3995
+ ) {
3996
+ this.#lastDirectSourceAudioCursorByStream.set(streamKey, cursor);
3997
+ }
3998
+ this.#drainDirectRecoveredAudioUpTo(streamKey, packetId, cursor, cb);
3999
+ this.#emitDirectSourceAudio(packetId, data, meta, cb);
4000
+ }
4001
+
4002
+ #routeDirectRecoveredAudio(
4003
+ packetId: number,
4004
+ data: Uint8Array,
4005
+ meta: RoutedFrameMeta,
4006
+ cb: FrameCallback | null,
4007
+ ): void {
4008
+ const streamKey = this.#mediaStreamKey(packetId, meta.catalogTrackName);
4009
+ const cursor = this.#directAudioCursor(meta);
4010
+ const lastEmitted = this.#lastDirectEmittedAudioCursorByStream.get(streamKey);
4011
+ if (
4012
+ lastEmitted !== undefined &&
4013
+ this.#compareDirectAudioCursor(cursor, lastEmitted) <= 0
4014
+ ) {
4015
+ this.#diagFor(packetId, meta.catalogTrackName)
4016
+ .directAudioRecoveredStaleDropped++;
4017
+ return;
4018
+ }
4019
+
4020
+ const queue = this.#pendingRecoveredAudioQueue(streamKey);
4021
+ queue.set(this.#pendingRecoveredKey(meta), { data, meta: { ...meta } });
4022
+ this.#diagFor(packetId, meta.catalogTrackName).directAudioRecoveredQueued++;
4023
+ if (queue.size > DIRECT_RECOVERED_AUDIO_QUEUE_LIMIT) {
4024
+ const keep = [...queue.entries()]
4025
+ .sort((a, b) => this.#compareDirectAudioCursor(
4026
+ this.#directAudioCursor(a[1].meta),
4027
+ this.#directAudioCursor(b[1].meta),
4028
+ ))
4029
+ .slice(-Math.floor(DIRECT_RECOVERED_AUDIO_QUEUE_LIMIT / 2));
4030
+ queue.clear();
4031
+ for (const [key, frame] of keep) queue.set(key, frame);
4032
+ }
4033
+
4034
+ const lastSource = this.#lastDirectSourceAudioCursorByStream.get(streamKey);
4035
+ if (lastSource !== undefined) {
4036
+ this.#drainDirectRecoveredAudioUpTo(streamKey, packetId, lastSource, cb);
4037
+ }
4038
+ }
4039
+
4040
+ // ── Private: FEC recovery routing ───────────────────────────────
4041
+
4042
+ /** Route WASM-recovered block through appropriate path based on container kind. */
4043
+ #routeRecoveredBlock(
4044
+ blockId: number,
4045
+ recovered: Uint8Array,
4046
+ ownerPacketId?: number,
4047
+ catalogTrackName?: string,
4048
+ ): void {
4049
+ // MMTP path: use processFecBlock for MMTP-wrapped recovery.
4050
+ // Recovered symbols are individual MMTP packets that may be fragments
4051
+ // (FI=1/2/3) of a larger MFU. Route through the reassembler so
4052
+ // multi-packet MFUs (e.g., keyframes spanning 15+ packets) are
4053
+ // properly assembled before reaching the decoder.
4054
+ const k = this.#kForPacket(ownerPacketId);
4055
+ const packetSymbolSize = this.#symbolSizeForPacket(ownerPacketId);
4056
+ if (ownerPacketId === undefined) {
4057
+ throw new Error("[MmtpRouter] a recovered FEC block needs its owner packetId to read its fec.blockProfile");
4058
+ }
4059
+ const blockProfile = this.#blockProfileFor(ownerPacketId);
4060
+ if (!(packetSymbolSize > 0)) {
4061
+ throw new Error(
4062
+ `[MmtpRouter] recovered FEC block for packetId=${ownerPacketId} needs a configured ` +
4063
+ `symbol size (got T=${packetSymbolSize})`,
4064
+ );
4065
+ }
4066
+ const symbolSize = packetSymbolSize;
4067
+ // iso-ssbg1-v1 returns K' = SSB_length symbols; k-padded-v1 the authored
4068
+ // prefix of a K-symbol block, walked up to K.
4069
+ const result = processFecBlock(recovered, {
4070
+ ...(blockProfile === "k-padded-v1" ? { k } : {}),
4071
+ symbolSize,
4072
+ ownerPacketId,
4073
+ blockProfile,
4074
+ });
4075
+ this.#routeVerifiedFecSymbols(blockId, result, ownerPacketId, catalogTrackName);
4076
+ }
4077
+
4078
+ #routeVerifiedFecSymbols(
4079
+ blockId: number,
4080
+ result: ProcessFecBlockResult,
4081
+ ownerPacketId?: number,
4082
+ catalogTrackName?: string,
4083
+ ): void {
4084
+ if (!this.#manifestVerifier || ownerPacketId == null) {
4085
+ this.#routeProcessedFecSymbols(blockId, result, ownerPacketId, catalogTrackName);
4086
+ return;
4087
+ }
4088
+ if (!this.#resolveManifestIdentity && !this.#resolveManifestIdentityForRoute) {
4089
+ console.warn(`[MmtpRouter] manifest identity resolver unavailable; recovered media is fail-open for packetId=${ownerPacketId} block=${blockId}`);
4090
+ this.#routeProcessedFecSymbols(blockId, result, ownerPacketId, catalogTrackName);
4091
+ return;
4092
+ }
4093
+ const queueKey = this.#mediaStreamKey(ownerPacketId, catalogTrackName);
4094
+ const queue = this.#manifestRecoveryQueues.get(queueKey) ?? [];
4095
+ queue.push({ blockId, result, ownerPacketId, catalogTrackName });
4096
+ this.#manifestRecoveryQueues.set(queueKey, queue);
4097
+ if (queue.length > 1) return;
4098
+ const drain = (): void => {
4099
+ const next = queue[0];
4100
+ if (!next) {
4101
+ this.#manifestRecoveryQueues.delete(queueKey);
4102
+ return;
4103
+ }
4104
+ this.#admitRecoveredResult(
4105
+ next.blockId,
4106
+ next.result,
4107
+ next.ownerPacketId,
4108
+ next.catalogTrackName,
4109
+ () => {
4110
+ queue.shift();
4111
+ drain();
4112
+ },
4113
+ );
4114
+ };
4115
+ drain();
4116
+ }
4117
+
4118
+ #admitRecoveredResult(
4119
+ blockId: number,
4120
+ result: ProcessFecBlockResult,
4121
+ ownerPacketId: number,
4122
+ catalogTrackName: string | undefined,
4123
+ done: () => void,
4124
+ ): void {
4125
+ const verifier = this.#manifestVerifier;
4126
+ const resolveIdentity = this.#resolveManifestIdentity;
4127
+ const resolveIdentityForRoute = this.#resolveManifestIdentityForRoute;
4128
+ if (!verifier) {
4129
+ this.#routeProcessedFecSymbols(blockId, result, ownerPacketId, catalogTrackName);
4130
+ done();
4131
+ return;
4132
+ }
4133
+ if (!resolveIdentity && !resolveIdentityForRoute) throw new Error("manifest identity resolver disappeared during admission");
4134
+ const symbols = result.symbols;
4135
+ const admitNext = (index: number): void => {
4136
+ const symbol = symbols[index];
4137
+ if (!symbol) {
4138
+ done();
4139
+ return;
4140
+ }
4141
+ const identity = resolveIdentityForRoute
4142
+ ? resolveIdentityForRoute({
4143
+ packetId: ownerPacketId,
4144
+ sbn: blockId,
4145
+ esi: symbol.index,
4146
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
4147
+ recovered: true,
4148
+ sourceSymbolFormat: "fec-padded-recovery",
4149
+ })
4150
+ : resolveIdentity!(ownerPacketId, blockId, symbol.index, catalogTrackName);
4151
+ if (!identity) {
4152
+ console.warn(`[MmtpRouter] manifest identity unresolved; recovered symbol is fail-open for packetId=${ownerPacketId} block=${blockId} esi=${symbol.index}`);
4153
+ this.#routeProcessedFecSymbols(
4154
+ blockId,
4155
+ { ...result, symbols: [symbol] },
4156
+ ownerPacketId,
4157
+ catalogTrackName,
4158
+ );
4159
+ admitNext(index + 1);
4160
+ return;
4161
+ }
4162
+ const admission = verifier.admitObject(
4163
+ {
4164
+ ...identity,
4165
+ packetId: ownerPacketId,
4166
+ ...(ownerPacketId !== undefined && this.#isIsoPacket(ownerPacketId)
4167
+ ? {}
4168
+ : { ssId: blockId * this.#kForPacket(ownerPacketId) + symbol.index }),
4169
+ },
4170
+ symbol.rawSymbol,
4171
+ () => {
4172
+ if (!this.#disposed) {
4173
+ this.#routeProcessedFecSymbols(
4174
+ blockId,
4175
+ { ...result, symbols: [symbol] },
4176
+ ownerPacketId,
4177
+ catalogTrackName,
4178
+ );
4179
+ }
4180
+ admitNext(index + 1);
4181
+ },
4182
+ // A stalled symbol that is later discarded instead of played must still
4183
+ // advance this loop; enforce mode never calls the release callback for
4184
+ // it (BLO-30095).
4185
+ () => admitNext(index + 1),
4186
+ );
4187
+ if (admission.action === "drop") admitNext(index + 1);
4188
+ };
4189
+ admitNext(0);
4190
+ }
4191
+
4192
+ #routeProcessedFecSymbols(
4193
+ blockId: number,
4194
+ result: ProcessFecBlockResult,
4195
+ ownerPacketId?: number,
4196
+ catalogTrackName?: string,
4197
+ ): void {
4198
+ // Route recovered symbols to the track's callback. The ownerPacketId tells us
4199
+ // which track this block belongs to — resolves per-track, then legacy fallback.
4200
+ if (ownerPacketId == null) return; // no track association
4201
+ if (this.#classifyPacketId(ownerPacketId) === null) return; // unknown track
4202
+ this.#stream(ownerPacketId, catalogTrackName).duplicates.advance(blockId);
4203
+
4204
+ // A recovered symbol is a function of EVERY symbol that entered its block,
4205
+ // so its provenance is the block's, not the media path's. Repair symbols
4206
+ // routinely arrive over reliable QUIC (`feedRepairPacket`), so a block that
4207
+ // recovered with MoQ help produced bytes that never crossed a multicast
4208
+ // socket. Without this, recovered frames reached the decoder carrying no
4209
+ // attribution at all and were counted as multicast by default.
4210
+ // A FEC manager that cannot report block provenance yields 'unknown', not
4211
+ // a guess: these bytes are then never counted as physical-delivery
4212
+ // evidence. `FECManagerLike` is a pluggable structural interface, so an
4213
+ // implementation without this method must degrade, not throw.
4214
+ const deliveryProvenance: MmtpDeliveryProvenance =
4215
+ this.#fecManager?.blockDeliveryProvenance?.(
4216
+ blockId,
4217
+ ownerPacketId,
4218
+ catalogTrackName,
4219
+ ) ?? 'unknown';
4220
+
4221
+ for (const sym of result.symbols) {
4222
+ this.#routeTrackPacket(sym.rawSymbol, ownerPacketId, sym.packetSequenceNumber, {
4223
+ recovered: true,
4224
+ skipFecPayloadId: true,
4225
+ catalogTrackName,
4226
+ deliveryProvenance,
4227
+ });
4228
+ }
4229
+
4230
+ // Feed recovered metadata before routing data. FT=0 initializes the timing
4231
+ // resolver and FT=1 publishes the sample table that recovered FT=2 data
4232
+ // must resolve against. The parser's callbacks dedupe repeated init
4233
+ // delivery, while keeping this path in lockstep with direct source input.
4234
+ for (const sym of result.symbols) {
4235
+ if (sym.fragmentType !== 0 && sym.fragmentType !== 1) continue;
4236
+ this.#feedViaParser(
4237
+ sym.rawSymbol,
4238
+ { packetId: ownerPacketId, sequenceNumber: sym.packetSequenceNumber },
4239
+ undefined,
4240
+ {
4241
+ skipFecPayloadId: true,
4242
+ recovered: true,
4243
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
4244
+ },
4245
+ );
4246
+ }
4247
+
4248
+ // MSE assembler path: route recovered block as pre-assembled frames (skip FT=0)
4249
+ // BLO-3035 layer 3: recovered symbols are RAW MFU fragments, not complete
4250
+ // frames. A keyframe can span multiple packets (FI=1/2/3); pushing each
4251
+ // fragment as a "frame" produces garbage moof+mdat that fails decode.
4252
+ // Mirror the non-MSE path's logic: FI=0 → emit single-packet MFU directly
4253
+ // (after dedup against source path), FI=1/2/3 → feed reassembler, emit when
4254
+ // the complete frame is available. Take the keyframe bit from the assembled
4255
+ // frame's rapFlag (with `#detectKeyframe` as a bitstream fallback for
4256
+ // publishers that don't set rapFlag) — `sym.isKeyframe` is just rapFlag
4257
+ // from a single fragment's header (see `block-processor.ts` where
4258
+ // `isKeyframe = hdr.rapFlag`) and isn't meaningful for the reassembled-
4259
+ // frame keyframe decision when FI≠0.
4260
+ // FEC tracks release recovered frames through the parser's FEC release
4261
+ // gate, in authored order with the source frames they fill in for; the
4262
+ // frame callback then applies sync gating and MSE/direct dispatch once.
4263
+ const gated = this.#isFecTrack(ownerPacketId);
4264
+ const assembler = this.#mseAssemblerFor(ownerPacketId, catalogTrackName);
4265
+ if (assembler) {
4266
+ // A staged switch holds and splices its two streams at the parser's
4267
+ // frame callback, and the ungated batch below would bypass it: the
4268
+ // incoming stream's recovered frames would be lost from the GOP it
4269
+ // replays at the commit, and the outgoing stream's could reach MSE past
4270
+ // the splice. Their recovered frames go through the parser instead, as
4271
+ // an FEC-gated track's always do.
4272
+ const staged = this.#staged;
4273
+ const owner = { packetId: ownerPacketId, catalogTrackName };
4274
+ const throughParser = gated ||
4275
+ (staged !== null && (isStream(owner, staged.from) || isStream(owner, staged.to)));
4276
+ const isVideoTrack = this.#classifyPacketId(ownerPacketId) === true;
4277
+ const frames: Array<{
4278
+ data: Uint8Array;
4279
+ timestamp: number;
4280
+ keyframe: boolean;
4281
+ authoredTiming?: AuthoredTiming;
4282
+ packetSequenceNumber?: number;
4283
+ packetSequenceNumbers?: number[];
4284
+ repairedPacketSequenceNumbers?: number[];
4285
+ mmtpTimestamp?: bigint;
4286
+ mpuSequenceNumber?: number;
4287
+ movieFragmentSequenceNumber?: number;
4288
+ sampleNumber?: number;
4289
+ catalogTrackName?: string;
4290
+ deliveryProvenance?: MmtpDeliveryProvenance;
4291
+ sourceDiscontinuity?: boolean;
4292
+ }> = [];
4293
+ let dedupedAgainstSource = 0;
4294
+ let droppedBeforeSync = 0;
4295
+ for (const sym of result.symbols) {
4296
+ // An in-place switch can retire this owner from inside this loop (a
4297
+ // recovered frame that reaches the splice); the rest have no route.
4298
+ if (this.#classifyPacketId(ownerPacketId) === null) return;
4299
+ if (sym.fragmentType === 0 || sym.fragmentType === 1) continue;
4300
+ // FT=0/FT=1 are MPU metadata (initialization/movie fragment), already
4301
+ // consumed above to seed the authored timing resolver. Only FT=2 MFU
4302
+ // payloads can become media samples here.
4303
+ const fi = sym.fragment.fragmentationIndicator;
4304
+ if (fi === 0) {
4305
+ // Single-packet MFU. Dedup against source path so frames aren't
4306
+ // emitted twice. The direct source frame may still be buffered by the
4307
+ // parser reorder window, so use source-received sequence numbers.
4308
+ if (this.#sourceSeqReceived(ownerPacketId, sym.packetSequenceNumber, catalogTrackName)) {
4309
+ dedupedAgainstSource++;
4310
+ continue;
4311
+ }
4312
+ const keyframe = isVideoTrack
4313
+ ? this.#detectKeyframe(
4314
+ ownerPacketId,
4315
+ sym.fragment.data,
4316
+ isMfuKeyframeMetadata(sym.fragment.rapFlag, sym.fragment.priority),
4317
+ )
4318
+ : sym.fragment.rapFlag;
4319
+ if (throughParser) {
4320
+ const gatedTiming = this.#resolveRecoveredTiming(
4321
+ ownerPacketId,
4322
+ sym.fragment.mpuSequenceNumber,
4323
+ sym.fragment.movieFragmentSequenceNumber,
4324
+ sym.fragment.sampleNumber,
4325
+ sym.fragment.timestamp,
4326
+ catalogTrackName,
4327
+ );
4328
+ if (!gatedTiming) continue;
4329
+ this.#parser.routeFrame(sym.fragment.data, {
4330
+ timestamp: gatedTiming.timestamp,
4331
+ mmtpTimestamp: gatedTiming.mmtpTimestamp,
4332
+ ...(gatedTiming.authoredTiming === undefined
4333
+ ? {}
4334
+ : { authoredTiming: gatedTiming.authoredTiming }),
4335
+ keyframe: isVideoTrack ? keyframe : true,
4336
+ packetId: ownerPacketId,
4337
+ trackType: isVideoTrack ? "video" : "audio",
4338
+ packetSequenceNumber: sym.packetSequenceNumber,
4339
+ packetSequenceNumbers: [sym.packetSequenceNumber],
4340
+ repairedPacketSequenceNumbers: [sym.packetSequenceNumber],
4341
+ mpuSequenceNumber: sym.fragment.mpuSequenceNumber,
4342
+ ...(sym.fragment.movieFragmentSequenceNumber === undefined
4343
+ ? {}
4344
+ : { movieFragmentSequenceNumber: sym.fragment.movieFragmentSequenceNumber }),
4345
+ ...(sym.fragment.sampleNumber === undefined
4346
+ ? {}
4347
+ : { sampleNumber: sym.fragment.sampleNumber }),
4348
+ rapFlag: sym.fragment.rapFlag,
4349
+ recovered: true,
4350
+ deliveryProvenance,
4351
+ fecSbn: this.#fecAnchorForRecovered(ownerPacketId, blockId, sym.index),
4352
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
4353
+ });
4354
+ continue;
4355
+ }
4356
+ const sourceDiscontinuity = this.#opensFecSync(
4357
+ ownerPacketId,
4358
+ keyframe,
4359
+ catalogTrackName,
4360
+ );
4361
+ if (!this.#shouldEmitFrame(ownerPacketId, keyframe, catalogTrackName)) {
4362
+ droppedBeforeSync++;
4363
+ continue;
4364
+ }
4365
+ const recoveredTiming = this.#resolveRecoveredTiming(
4366
+ ownerPacketId,
4367
+ sym.fragment.mpuSequenceNumber,
4368
+ sym.fragment.movieFragmentSequenceNumber,
4369
+ sym.fragment.sampleNumber,
4370
+ sym.fragment.timestamp,
4371
+ catalogTrackName,
4372
+ );
4373
+ if (!recoveredTiming) continue;
4374
+ frames.push({
4375
+ data: sym.fragment.data,
4376
+ timestamp: recoveredTiming.timestamp,
4377
+ mmtpTimestamp: recoveredTiming.mmtpTimestamp,
4378
+ keyframe,
4379
+ authoredTiming: recoveredTiming.authoredTiming,
4380
+ packetSequenceNumber: sym.packetSequenceNumber,
4381
+ packetSequenceNumbers: [sym.packetSequenceNumber],
4382
+ repairedPacketSequenceNumbers: [sym.packetSequenceNumber],
4383
+ mpuSequenceNumber: sym.fragment.mpuSequenceNumber,
4384
+ ...(sym.fragment.movieFragmentSequenceNumber === undefined
4385
+ ? {}
4386
+ : { movieFragmentSequenceNumber: sym.fragment.movieFragmentSequenceNumber }),
4387
+ ...(sym.fragment.sampleNumber === undefined
4388
+ ? {}
4389
+ : { sampleNumber: sym.fragment.sampleNumber }),
4390
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
4391
+ // A single-packet MFU needs the block verdict just as much as a
4392
+ // reassembled one. The multi-fragment branch below gets it via the
4393
+ // reassembler fold (`assembled.deliveryProvenance`); this branch
4394
+ // never touches the reassembler, so it must carry the block-level
4395
+ // verdict directly or the sample reaches MSE unattributed.
4396
+ deliveryProvenance,
4397
+ ...(sourceDiscontinuity ? { sourceDiscontinuity: true } : {}),
4398
+ });
4399
+ this.#noteEmittedKeyframe(ownerPacketId, keyframe);
4400
+ } else {
4401
+ // Multi-packet MFU. Feed to reassembler; emit only when complete.
4402
+ // A decoded block contains every source symbol, including fragments
4403
+ // already accepted on the direct path. Re-feeding those fragments as
4404
+ // recovered would replace their original attribution in the shared
4405
+ // reassembler and overstate repairedPacketSequenceNumbers.
4406
+ if (this.#sourceSeqReceived(ownerPacketId, sym.packetSequenceNumber, catalogTrackName)) {
4407
+ dedupedAgainstSource++;
4408
+ continue;
4409
+ }
4410
+ let assembled;
4411
+ try {
4412
+ assembled = this.#parser.reassembler.addFragment({
4413
+ fragmentationIndicator: fi,
4414
+ mpuSequenceNumber: sym.fragment.mpuSequenceNumber,
4415
+ fragmentCounter: sym.fragment.fragmentCounter,
4416
+ data: sym.fragment.data,
4417
+ timestamp: sym.fragment.timestamp,
4418
+ rapFlag: sym.fragment.rapFlag,
4419
+ sampleNumber: sym.fragment.sampleNumber,
4420
+ movieFragmentSequenceNumber: sym.fragment.movieFragmentSequenceNumber,
4421
+ sampleOffset: sym.fragment.sampleOffset,
4422
+ priority: sym.fragment.priority,
4423
+ packetSequenceNumber: sym.packetSequenceNumber,
4424
+ recovered: true,
4425
+ // Without this the fold sees a fragment with neither `source`
4426
+ // nor a verdict, returns 'unknown', and the block-level finding
4427
+ // computed above is lost before the frame reaches any consumer.
4428
+ deliveryProvenance,
4429
+ packetId: ownerPacketId,
4430
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
4431
+ });
4432
+ } catch (error) {
4433
+ if (!isMfuReassemblyError(error)) throw error;
4434
+ console.warn(
4435
+ `[MmtpRouter] dropped rejected recovered MFU fragment: block=${blockId} ` +
4436
+ `packetId=${ownerPacketId} code=${error.code}`,
4437
+ );
4438
+ continue;
4439
+ }
4440
+ if (assembled) {
4441
+ const keyframe = isVideoTrack
4442
+ ? this.#detectKeyframe(
4443
+ ownerPacketId,
4444
+ assembled.data,
4445
+ isMfuKeyframeMetadata(assembled.rapFlag, assembled.priority),
4446
+ )
4447
+ : assembled.rapFlag;
4448
+ const recoveredCatalogTrackName = assembled.catalogTrackName ?? catalogTrackName;
4449
+ if (throughParser) {
4450
+ const gatedTiming = this.#resolveRecoveredTiming(
4451
+ ownerPacketId,
4452
+ assembled.mpuSequenceNumber,
4453
+ assembled.movieFragmentSequenceNumber,
4454
+ assembled.sampleNumber,
4455
+ assembled.timestamp,
4456
+ recoveredCatalogTrackName,
4457
+ );
4458
+ if (!gatedTiming) continue;
4459
+ this.#parser.routeFrame(assembled.data, {
4460
+ timestamp: gatedTiming.timestamp,
4461
+ mmtpTimestamp: gatedTiming.mmtpTimestamp,
4462
+ ...(gatedTiming.authoredTiming === undefined
4463
+ ? {}
4464
+ : { authoredTiming: gatedTiming.authoredTiming }),
4465
+ keyframe: isVideoTrack ? keyframe : true,
4466
+ packetId: ownerPacketId,
4467
+ trackType: isVideoTrack ? "video" : "audio",
4468
+ packetSequenceNumber: sym.packetSequenceNumber,
4469
+ ...(assembled.packetSequenceNumbers === undefined
4470
+ ? {}
4471
+ : { packetSequenceNumbers: assembled.packetSequenceNumbers }),
4472
+ ...(assembled.repairedPacketSequenceNumbers === undefined
4473
+ ? {}
4474
+ : { repairedPacketSequenceNumbers: assembled.repairedPacketSequenceNumbers }),
4475
+ mpuSequenceNumber: assembled.mpuSequenceNumber,
4476
+ ...(assembled.movieFragmentSequenceNumber === undefined
4477
+ ? {}
4478
+ : { movieFragmentSequenceNumber: assembled.movieFragmentSequenceNumber }),
4479
+ ...(assembled.sampleNumber === undefined
4480
+ ? {}
4481
+ : { sampleNumber: assembled.sampleNumber }),
4482
+ rapFlag: assembled.rapFlag,
4483
+ recovered: true,
4484
+ ...(assembled.deliveryProvenance === undefined
4485
+ ? {}
4486
+ : { deliveryProvenance: assembled.deliveryProvenance }),
4487
+ fecSbn: this.#fecAnchorForRecovered(ownerPacketId, blockId, sym.index),
4488
+ ...(recoveredCatalogTrackName === undefined
4489
+ ? {}
4490
+ : { catalogTrackName: recoveredCatalogTrackName }),
4491
+ });
4492
+ continue;
4493
+ }
4494
+ const sourceDiscontinuity = this.#opensFecSync(
4495
+ ownerPacketId,
4496
+ keyframe,
4497
+ recoveredCatalogTrackName,
4498
+ );
4499
+ if (!this.#shouldEmitFrame(
4500
+ ownerPacketId,
4501
+ keyframe,
4502
+ recoveredCatalogTrackName,
4503
+ )) {
4504
+ droppedBeforeSync++;
4505
+ continue;
4506
+ }
4507
+ const recoveredTiming = this.#resolveRecoveredTiming(
4508
+ ownerPacketId,
4509
+ assembled.mpuSequenceNumber,
4510
+ assembled.movieFragmentSequenceNumber,
4511
+ assembled.sampleNumber,
4512
+ assembled.timestamp,
4513
+ recoveredCatalogTrackName,
4514
+ );
4515
+ if (!recoveredTiming) continue;
4516
+ frames.push({
4517
+ data: assembled.data,
4518
+ timestamp: recoveredTiming.timestamp,
4519
+ mmtpTimestamp: recoveredTiming.mmtpTimestamp,
4520
+ keyframe,
4521
+ authoredTiming: recoveredTiming.authoredTiming,
4522
+ // The singular delivery identity is the fragment that completed
4523
+ // and delivered the MFU, matching the callback/parser path.
4524
+ packetSequenceNumber: sym.packetSequenceNumber,
4525
+ ...(assembled.packetSequenceNumbers === undefined
4526
+ ? {}
4527
+ : { packetSequenceNumbers: assembled.packetSequenceNumbers }),
4528
+ ...(assembled.repairedPacketSequenceNumbers === undefined
4529
+ ? {}
4530
+ : {
4531
+ repairedPacketSequenceNumbers:
4532
+ assembled.repairedPacketSequenceNumbers,
4533
+ }),
4534
+ mpuSequenceNumber: assembled.mpuSequenceNumber,
4535
+ ...(assembled.movieFragmentSequenceNumber === undefined
4536
+ ? {}
4537
+ : { movieFragmentSequenceNumber: assembled.movieFragmentSequenceNumber }),
4538
+ ...(assembled.sampleNumber === undefined
4539
+ ? {}
4540
+ : { sampleNumber: assembled.sampleNumber }),
4541
+ ...(assembled.catalogTrackName === undefined
4542
+ ? {}
4543
+ : { catalogTrackName: assembled.catalogTrackName }),
4544
+ deliveryProvenance: assembled.deliveryProvenance,
4545
+ ...(sourceDiscontinuity ? { sourceDiscontinuity: true } : {}),
4546
+ });
4547
+ this.#noteEmittedKeyframe(ownerPacketId, keyframe);
4548
+ }
4549
+ }
4550
+ }
4551
+ if (frames.length > 0) {
4552
+ assembler.addRecoveredBlock(blockId, frames);
4553
+ // The assembler owns MSE writes, but the normal frame callback still
4554
+ // carries transport admission: MulticastManager uses it to confirm
4555
+ // video/audio and MediaRouter uses that confirmation to complete the
4556
+ // takeover. Recovered frames bypass parser.onFrame, so without this
4557
+ // callback MSE can receive samples forever while takeover never fires.
4558
+ const cb = this.#getFrameCallback(ownerPacketId);
4559
+ for (const frame of frames) {
4560
+ cb?.(frame.data, {
4561
+ timestamp: frame.timestamp,
4562
+ ...(frame.mmtpTimestamp === undefined ? {} : { mmtpTimestamp: frame.mmtpTimestamp }),
4563
+ keyframe: frame.keyframe,
4564
+ ...(frame.authoredTiming === undefined ? {} : { authoredTiming: frame.authoredTiming }),
4565
+ packetId: ownerPacketId,
4566
+ ...(frame.packetSequenceNumber === undefined
4567
+ ? {}
4568
+ : { packetSequenceNumber: frame.packetSequenceNumber }),
4569
+ ...(frame.packetSequenceNumbers === undefined
4570
+ ? {}
4571
+ : { packetSequenceNumbers: frame.packetSequenceNumbers }),
4572
+ ...(frame.repairedPacketSequenceNumbers === undefined
4573
+ ? {}
4574
+ : { repairedPacketSequenceNumbers: frame.repairedPacketSequenceNumbers }),
4575
+ ...(frame.mpuSequenceNumber === undefined ? {} : { mpuSequenceNumber: frame.mpuSequenceNumber }),
4576
+ ...(frame.movieFragmentSequenceNumber === undefined
4577
+ ? {}
4578
+ : { movieFragmentSequenceNumber: frame.movieFragmentSequenceNumber }),
4579
+ ...(frame.sampleNumber === undefined ? {} : { sampleNumber: frame.sampleNumber }),
4580
+ ...(frame.catalogTrackName === undefined ? {} : { catalogTrackName: frame.catalogTrackName }),
4581
+ recovered: true,
4582
+ deliveryProvenance: frame.deliveryProvenance,
4583
+ ...(frame.sourceDiscontinuity ? { sourceDiscontinuity: true } : {}),
4584
+ });
4585
+ }
4586
+ debugRouter(
4587
+ `[MmtpRouter] Block ${blockId}: ${result.symbols.length} symbols → ${frames.length} reassembled frames → MSE assembler (packetId=${ownerPacketId}, dedupedAgainstSource=${dedupedAgainstSource}, droppedBeforeSync=${droppedBeforeSync})`,
4588
+ );
4589
+ } else if (result.symbols.length > 0) {
4590
+ // Recovery yielded symbols but no complete frames emerged — either all
4591
+ // FI=0 were source-path duplicates, or all FI=1/2/3 are still mid-
4592
+ // reassembly. Surface this so an operator debugging "FEC recovered N
4593
+ // symbols but no new frames rendered" has a signal.
4594
+ debugRouter(
4595
+ `[MmtpRouter] Block ${blockId}: ${result.symbols.length} symbols recovered → 0 emitted (dedupedAgainstSource=${dedupedAgainstSource}, droppedBeforeSync=${droppedBeforeSync}, pending-reassembly=${result.symbols.length - dedupedAgainstSource - droppedBeforeSync}, packetId=${ownerPacketId})`,
4596
+ );
4597
+ }
4598
+ return;
4599
+ }
4600
+
4601
+ const cb = this.#getFrameCallback(ownerPacketId);
4602
+ const isVideo = this.#classifyPacketId(ownerPacketId) === true;
4603
+ let droppedBeforeSync = 0;
4604
+ let droppedStale = 0;
4605
+ let dedupedAgainstSource = 0;
4606
+
4607
+ for (const sym of result.symbols) {
4608
+ // An in-place switch can retire this owner from inside this loop (a
4609
+ // recovered frame that reaches the splice); the rest have no route.
4610
+ if (this.#classifyPacketId(ownerPacketId) === null) return;
4611
+ // FT=0 init already delivered above — skip data routing
4612
+ if (sym.fragmentType === 0 || sym.fragmentType === 1) continue;
4613
+ // FT=0/FT=1 are metadata, not decoder samples. The metadata pass above
4614
+ // has already used them to establish the timed-MFU contract.
4615
+
4616
+ if (isVideo) {
4617
+ const fi = sym.fragment.fragmentationIndicator;
4618
+ if (fi === 0) {
4619
+ // Dedup: skip FI=0 frames already received via direct source path.
4620
+ // The reassembler deduplicates FI=1/2/3 fragments via #emitted, but
4621
+ // FI=0 (single-packet MFU) bypasses it. Without this check, recovery
4622
+ // can re-emit a source frame that is only held in the reorder buffer,
4623
+ // producing duplicate timestamps when the source path later flushes.
4624
+ if (this.#sourceSeqReceived(ownerPacketId, sym.packetSequenceNumber, catalogTrackName)) {
4625
+ dedupedAgainstSource++;
4626
+ continue;
4627
+ }
4628
+ if (this.#isStaleRecoveredVideo(
4629
+ ownerPacketId,
4630
+ sym.packetSequenceNumber,
4631
+ catalogTrackName,
4632
+ )) {
4633
+ droppedStale++;
4634
+ continue;
4635
+ }
4636
+
4637
+ const d = sym.fragment.data;
4638
+ const keyframe = this.#detectKeyframe(
4639
+ ownerPacketId,
4640
+ d,
4641
+ isMfuKeyframeMetadata(sym.fragment.rapFlag, sym.fragment.priority),
4642
+ );
4643
+ const recoveredTiming = this.#resolveRecoveredTiming(
4644
+ ownerPacketId,
4645
+ sym.fragment.mpuSequenceNumber,
4646
+ sym.fragment.movieFragmentSequenceNumber,
4647
+ sym.fragment.sampleNumber,
4648
+ sym.fragment.timestamp,
4649
+ catalogTrackName,
4650
+ );
4651
+ if (!recoveredTiming) continue;
4652
+ this.#parser.routeFrame(d, {
4653
+ timestamp: recoveredTiming.timestamp,
4654
+ mmtpTimestamp: recoveredTiming.mmtpTimestamp,
4655
+ authoredTiming: recoveredTiming.authoredTiming,
4656
+ keyframe,
4657
+ packetId: ownerPacketId,
4658
+ trackType: "video",
4659
+ packetSequenceNumber: sym.packetSequenceNumber,
4660
+ packetSequenceNumbers: [sym.packetSequenceNumber],
4661
+ repairedPacketSequenceNumbers: [sym.packetSequenceNumber],
4662
+ mpuSequenceNumber: sym.fragment.mpuSequenceNumber,
4663
+ ...(sym.fragment.movieFragmentSequenceNumber === undefined
4664
+ ? {}
4665
+ : { movieFragmentSequenceNumber: sym.fragment.movieFragmentSequenceNumber }),
4666
+ ...(sym.fragment.sampleNumber === undefined
4667
+ ? {}
4668
+ : { sampleNumber: sym.fragment.sampleNumber }),
4669
+ rapFlag: sym.fragment.rapFlag,
4670
+ recovered: true,
4671
+ deliveryProvenance,
4672
+ fecSbn: this.#fecAnchorForRecovered(ownerPacketId, blockId, sym.index),
4673
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
4674
+ });
4675
+ } else {
4676
+ // Fragment of a multi-packet MFU — must go through reassembler
4677
+ if (this.#sourceSeqReceived(ownerPacketId, sym.packetSequenceNumber, catalogTrackName)) {
4678
+ dedupedAgainstSource++;
4679
+ continue;
4680
+ }
4681
+ let assembled;
4682
+ try {
4683
+ assembled = this.#parser.reassembler.addFragment({
4684
+ fragmentationIndicator: fi,
4685
+ mpuSequenceNumber: sym.fragment.mpuSequenceNumber,
4686
+ fragmentCounter: sym.fragment.fragmentCounter,
4687
+ data: sym.fragment.data,
4688
+ timestamp: sym.fragment.timestamp,
4689
+ rapFlag: sym.fragment.rapFlag,
4690
+ sampleNumber: sym.fragment.sampleNumber,
4691
+ movieFragmentSequenceNumber: sym.fragment.movieFragmentSequenceNumber,
4692
+ sampleOffset: sym.fragment.sampleOffset,
4693
+ priority: sym.fragment.priority,
4694
+ packetSequenceNumber: sym.packetSequenceNumber,
4695
+ recovered: true,
4696
+ // Without this the fold sees a fragment with neither `source`
4697
+ // nor a verdict, returns 'unknown', and the block-level finding
4698
+ // computed above is lost before the frame reaches any consumer.
4699
+ deliveryProvenance,
4700
+ packetId: ownerPacketId,
4701
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
4702
+ });
4703
+ } catch (error) {
4704
+ if (!isMfuReassemblyError(error)) throw error;
4705
+ console.warn(
4706
+ `[MmtpRouter] dropped rejected recovered MFU fragment: block=${blockId} ` +
4707
+ `packetId=${ownerPacketId} code=${error.code}`,
4708
+ );
4709
+ continue;
4710
+ }
4711
+ if (assembled) {
4712
+ if (this.#isStaleRecoveredVideo(
4713
+ ownerPacketId,
4714
+ sym.packetSequenceNumber,
4715
+ assembled.catalogTrackName,
4716
+ )) {
4717
+ droppedStale++;
4718
+ continue;
4719
+ }
4720
+ const keyframe = this.#detectKeyframe(
4721
+ ownerPacketId,
4722
+ assembled.data,
4723
+ isMfuKeyframeMetadata(assembled.rapFlag, assembled.priority),
4724
+ );
4725
+ const recoveredCatalogTrackName = assembled.catalogTrackName ?? catalogTrackName;
4726
+ const recoveredTiming = this.#resolveRecoveredTiming(
4727
+ ownerPacketId,
4728
+ assembled.mpuSequenceNumber,
4729
+ assembled.movieFragmentSequenceNumber,
4730
+ assembled.sampleNumber,
4731
+ assembled.timestamp,
4732
+ recoveredCatalogTrackName,
4733
+ );
4734
+ if (!recoveredTiming) continue;
4735
+ this.#parser.routeFrame(assembled.data, {
4736
+ timestamp: recoveredTiming.timestamp,
4737
+ mmtpTimestamp: recoveredTiming.mmtpTimestamp,
4738
+ authoredTiming: recoveredTiming.authoredTiming,
4739
+ keyframe,
4740
+ packetId: ownerPacketId,
4741
+ trackType: "video",
4742
+ packetSequenceNumber: sym.packetSequenceNumber,
4743
+ ...(assembled.packetSequenceNumbers === undefined
4744
+ ? {}
4745
+ : { packetSequenceNumbers: assembled.packetSequenceNumbers }),
4746
+ ...(assembled.repairedPacketSequenceNumbers === undefined
4747
+ ? {}
4748
+ : {
4749
+ repairedPacketSequenceNumbers:
4750
+ assembled.repairedPacketSequenceNumbers,
4751
+ }),
4752
+ mpuSequenceNumber: assembled.mpuSequenceNumber,
4753
+ ...(assembled.movieFragmentSequenceNumber === undefined
4754
+ ? {}
4755
+ : { movieFragmentSequenceNumber: assembled.movieFragmentSequenceNumber }),
4756
+ ...(assembled.sampleNumber === undefined
4757
+ ? {}
4758
+ : { sampleNumber: assembled.sampleNumber }),
4759
+ rapFlag: assembled.rapFlag,
4760
+ recovered: true,
4761
+ deliveryProvenance,
4762
+ fecSbn: this.#fecAnchorForRecovered(ownerPacketId, blockId, sym.index),
4763
+ ...(assembled.catalogTrackName === undefined
4764
+ ? {}
4765
+ : { catalogTrackName: assembled.catalogTrackName }),
4766
+ });
4767
+ }
4768
+ }
4769
+ } else {
4770
+ // Audio recovery: dedup single-packet MFUs already emitted via direct source path
4771
+ // (parity with video FI=0 path at line 993). Prevents re-delivering audio frames
4772
+ // with duplicate timestamps — doubled samples in the FIFO ring buffer corrupt the
4773
+ // 1kHz test tone (pitch/energy instability in FFT spectrum).
4774
+ if (this.#sourceSeqReceived(ownerPacketId, sym.packetSequenceNumber, catalogTrackName)) {
4775
+ dedupedAgainstSource++;
4776
+ continue;
4777
+ }
4778
+ const recoveredTiming = this.#resolveRecoveredTiming(
4779
+ ownerPacketId,
4780
+ sym.fragment.mpuSequenceNumber,
4781
+ sym.fragment.movieFragmentSequenceNumber,
4782
+ sym.fragment.sampleNumber,
4783
+ sym.fragment.timestamp,
4784
+ catalogTrackName,
4785
+ );
4786
+ if (recoveredTiming === null) continue;
4787
+ if (gated) {
4788
+ this.#parser.routeFrame(sym.fragment.data, {
4789
+ timestamp: recoveredTiming.timestamp,
4790
+ mmtpTimestamp: recoveredTiming.mmtpTimestamp,
4791
+ ...(recoveredTiming.authoredTiming === undefined
4792
+ ? {}
4793
+ : { authoredTiming: recoveredTiming.authoredTiming }),
4794
+ keyframe: true,
4795
+ packetId: ownerPacketId,
4796
+ trackType: "audio",
4797
+ packetSequenceNumber: sym.packetSequenceNumber,
4798
+ packetSequenceNumbers: [sym.packetSequenceNumber],
4799
+ repairedPacketSequenceNumbers: [sym.packetSequenceNumber],
4800
+ mpuSequenceNumber: sym.fragment.mpuSequenceNumber,
4801
+ ...(sym.fragment.movieFragmentSequenceNumber === undefined
4802
+ ? {}
4803
+ : { movieFragmentSequenceNumber: sym.fragment.movieFragmentSequenceNumber }),
4804
+ ...(sym.fragment.sampleNumber === undefined
4805
+ ? {}
4806
+ : { sampleNumber: sym.fragment.sampleNumber }),
4807
+ recovered: true,
4808
+ deliveryProvenance,
4809
+ fecSbn: this.#fecAnchorForRecovered(ownerPacketId, blockId, sym.index),
4810
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
4811
+ });
4812
+ continue;
4813
+ }
4814
+ const meta = {
4815
+ timestamp: recoveredTiming.timestamp,
4816
+ mmtpTimestamp: recoveredTiming.mmtpTimestamp,
4817
+ authoredTiming: recoveredTiming.authoredTiming,
4818
+ keyframe: true,
4819
+ packetId: ownerPacketId,
4820
+ packetSequenceNumber: sym.packetSequenceNumber,
4821
+ packetSequenceNumbers: [sym.packetSequenceNumber],
4822
+ repairedPacketSequenceNumbers: [sym.packetSequenceNumber],
4823
+ mpuSequenceNumber: sym.fragment.mpuSequenceNumber,
4824
+ ...(sym.fragment.movieFragmentSequenceNumber === undefined
4825
+ ? {}
4826
+ : { movieFragmentSequenceNumber: sym.fragment.movieFragmentSequenceNumber }),
4827
+ ...(sym.fragment.sampleNumber === undefined
4828
+ ? {}
4829
+ : { sampleNumber: sym.fragment.sampleNumber }),
4830
+ recovered: true,
4831
+ ...(catalogTrackName === undefined ? {} : { catalogTrackName }),
4832
+ };
4833
+ this.#routeDirectRecoveredAudio(
4834
+ ownerPacketId,
4835
+ sym.fragment.data,
4836
+ meta,
4837
+ cb,
4838
+ );
4839
+ }
4840
+ }
4841
+ if (result.symbols.length > 0) {
4842
+ debugRouter(
4843
+ `[MmtpRouter] Block ${blockId}: ${result.symbols.length} symbols recovered (packetId=${ownerPacketId}, dedupedAgainstSource=${dedupedAgainstSource}, droppedBeforeSync=${droppedBeforeSync}, droppedStale=${droppedStale})`,
4844
+ );
4845
+ }
4846
+ }
4847
+
4848
+ // ── Private: keyframe detection ─────────────────────────────────
4849
+
4850
+ /** Codec-aware keyframe detection from bitstream data.
4851
+ * H.264 IDR (NAL type 5/7), AV1 OBU_SEQUENCE_HEADER (type 1), VP9 frame_type bit. */
4852
+ #detectKeyframe(
4853
+ packetId: number,
4854
+ data: Uint8Array,
4855
+ rapFlag = false,
4856
+ ): boolean {
4857
+ const codec = this.#trackCodecs.get(packetId) ?? "";
4858
+ return MmtpContainerParser.isKeyframe(data, rapFlag, codec);
4859
+ }
4860
+ }