@blockcast/mmt-container 0.1.0-main.0399efef1f07

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 (694) hide show
  1. package/README.md +432 -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 +93 -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/browser-runtime.js +20087 -0
  22. package/dist/browser-runtime.js.map +7 -0
  23. package/dist/carp-catalog.d.ts +318 -0
  24. package/dist/carp-catalog.d.ts.map +1 -0
  25. package/dist/carp-catalog.js +873 -0
  26. package/dist/carp-catalog.js.map +7 -0
  27. package/dist/catalog-fec-params.d.ts +202 -0
  28. package/dist/catalog-fec-params.d.ts.map +1 -0
  29. package/dist/catalog-fec-params.js +568 -0
  30. package/dist/catalog-fec-params.js.map +7 -0
  31. package/dist/catalog-network-source.d.ts +19 -0
  32. package/dist/catalog-network-source.d.ts.map +1 -0
  33. package/dist/catalog-network-source.js +22 -0
  34. package/dist/catalog-network-source.js.map +7 -0
  35. package/dist/catalog-validator.d.ts +18 -0
  36. package/dist/catalog-validator.d.ts.map +1 -0
  37. package/dist/catalog-validator.js +7747 -0
  38. package/dist/catalog-validator.js.map +7 -0
  39. package/dist/cmaf-block-assembler.d.ts +217 -0
  40. package/dist/cmaf-block-assembler.d.ts.map +1 -0
  41. package/dist/cmaf-block-assembler.js +1769 -0
  42. package/dist/cmaf-block-assembler.js.map +7 -0
  43. package/dist/cmaf-group-batcher.d.ts +38 -0
  44. package/dist/cmaf-group-batcher.d.ts.map +1 -0
  45. package/dist/cmaf-group-batcher.js +195 -0
  46. package/dist/cmaf-group-batcher.js.map +7 -0
  47. package/dist/cmcd-producer.d.ts +104 -0
  48. package/dist/cmcd-producer.d.ts.map +1 -0
  49. package/dist/cmcd-producer.js +207 -0
  50. package/dist/cmcd-producer.js.map +7 -0
  51. package/dist/codec-utils.d.ts +248 -0
  52. package/dist/codec-utils.d.ts.map +1 -0
  53. package/dist/codec-utils.js +1268 -0
  54. package/dist/codec-utils.js.map +7 -0
  55. package/dist/fec-codec.d.ts +32 -0
  56. package/dist/fec-codec.d.ts.map +1 -0
  57. package/dist/fec-codec.js +47 -0
  58. package/dist/fec-codec.js.map +7 -0
  59. package/dist/fec-manager.d.ts +307 -0
  60. package/dist/fec-manager.d.ts.map +1 -0
  61. package/dist/fec-manager.js +1530 -0
  62. package/dist/fec-manager.js.map +7 -0
  63. package/dist/fec-starvation-detector.d.ts +59 -0
  64. package/dist/fec-starvation-detector.d.ts.map +1 -0
  65. package/dist/fec-starvation-detector.js +75 -0
  66. package/dist/fec-starvation-detector.js.map +7 -0
  67. package/dist/fec-worker-setup.d.ts +88 -0
  68. package/dist/fec-worker-setup.d.ts.map +1 -0
  69. package/dist/fec-worker-setup.js +614 -0
  70. package/dist/fec-worker-setup.js.map +7 -0
  71. package/dist/fmp4-completion.d.ts +30 -0
  72. package/dist/fmp4-completion.d.ts.map +1 -0
  73. package/dist/fmp4-completion.js +21 -0
  74. package/dist/fmp4-completion.js.map +7 -0
  75. package/dist/fmp4-reader.d.ts +37 -0
  76. package/dist/fmp4-reader.d.ts.map +1 -0
  77. package/dist/fmp4-reader.js +100 -0
  78. package/dist/fmp4-reader.js.map +7 -0
  79. package/dist/index.d.ts +47 -0
  80. package/dist/index.d.ts.map +1 -0
  81. package/dist/index.js +380 -0
  82. package/dist/index.js.map +7 -0
  83. package/dist/iso-bmff-timing.d.ts +78 -0
  84. package/dist/iso-bmff-timing.d.ts.map +1 -0
  85. package/dist/iso-bmff-timing.js +618 -0
  86. package/dist/iso-bmff-timing.js.map +7 -0
  87. package/dist/loc.d.ts +68 -0
  88. package/dist/loc.d.ts.map +1 -0
  89. package/dist/loc.js +143 -0
  90. package/dist/loc.js.map +7 -0
  91. package/dist/manifest-auth-plan.d.ts +30 -0
  92. package/dist/manifest-auth-plan.d.ts.map +1 -0
  93. package/dist/manifest-auth-plan.js +1285 -0
  94. package/dist/manifest-auth-plan.js.map +7 -0
  95. package/dist/media-router.d.ts +251 -0
  96. package/dist/media-router.d.ts.map +1 -0
  97. package/dist/media-router.js +624 -0
  98. package/dist/media-router.js.map +7 -0
  99. package/dist/mfu-reassembler.d.ts +396 -0
  100. package/dist/mfu-reassembler.d.ts.map +1 -0
  101. package/dist/mfu-reassembler.js +3085 -0
  102. package/dist/mfu-reassembler.js.map +7 -0
  103. package/dist/mfu-reassembly-policy.d.ts +24 -0
  104. package/dist/mfu-reassembly-policy.d.ts.map +1 -0
  105. package/dist/mfu-reassembly-policy.js +99 -0
  106. package/dist/mfu-reassembly-policy.js.map +7 -0
  107. package/dist/mmtp-container-parser.d.ts +434 -0
  108. package/dist/mmtp-container-parser.d.ts.map +1 -0
  109. package/dist/mmtp-container-parser.js +6164 -0
  110. package/dist/mmtp-container-parser.js.map +7 -0
  111. package/dist/mmtp-container-parser.no-modules.js +8999 -0
  112. package/dist/mmtp-container-parser.no-modules.js.map +7 -0
  113. package/dist/mmtp-decode-pipeline.d.ts +132 -0
  114. package/dist/mmtp-decode-pipeline.d.ts.map +1 -0
  115. package/dist/mmtp-decode-pipeline.js +6413 -0
  116. package/dist/mmtp-decode-pipeline.js.map +7 -0
  117. package/dist/mmtp-router.d.ts +390 -0
  118. package/dist/mmtp-router.d.ts.map +1 -0
  119. package/dist/mmtp-router.js +9242 -0
  120. package/dist/mmtp-router.js.map +7 -0
  121. package/dist/mmtp-to-cmaf.d.ts +181 -0
  122. package/dist/mmtp-to-cmaf.d.ts.map +1 -0
  123. package/dist/mmtp-to-cmaf.js +802 -0
  124. package/dist/mmtp-to-cmaf.js.map +7 -0
  125. package/dist/multicast-auth-catalog.d.ts +115 -0
  126. package/dist/multicast-auth-catalog.d.ts.map +1 -0
  127. package/dist/multicast-auth-catalog.js +202 -0
  128. package/dist/multicast-auth-catalog.js.map +7 -0
  129. package/dist/multicast-input.d.ts +107 -0
  130. package/dist/multicast-input.d.ts.map +1 -0
  131. package/dist/multicast-input.js +256 -0
  132. package/dist/multicast-input.js.map +7 -0
  133. package/dist/multicast-manager.d.ts +369 -0
  134. package/dist/multicast-manager.d.ts.map +1 -0
  135. package/dist/multicast-manager.js +13400 -0
  136. package/dist/multicast-manager.js.map +7 -0
  137. package/dist/multicast-transport.d.ts +61 -0
  138. package/dist/multicast-transport.d.ts.map +1 -0
  139. package/dist/multicast-transport.js +1665 -0
  140. package/dist/multicast-transport.js.map +7 -0
  141. package/dist/multicast-unwrap.d.ts +57 -0
  142. package/dist/multicast-unwrap.d.ts.map +1 -0
  143. package/dist/multicast-unwrap.js +384 -0
  144. package/dist/multicast-unwrap.js.map +7 -0
  145. package/dist/no-modules.d.ts +9 -0
  146. package/dist/no-modules.d.ts.map +1 -0
  147. package/dist/no-modules.js +8965 -0
  148. package/dist/no-modules.js.map +7 -0
  149. package/dist/phase14-catalog-override.d.ts +126 -0
  150. package/dist/phase14-catalog-override.d.ts.map +1 -0
  151. package/dist/phase14-catalog-override.js +155 -0
  152. package/dist/phase14-catalog-override.js.map +7 -0
  153. package/dist/repair-fetch.d.ts +58 -0
  154. package/dist/repair-fetch.d.ts.map +1 -0
  155. package/dist/repair-fetch.js +127 -0
  156. package/dist/repair-fetch.js.map +7 -0
  157. package/dist/repair-layers.d.ts +141 -0
  158. package/dist/repair-layers.d.ts.map +1 -0
  159. package/dist/repair-layers.js +303 -0
  160. package/dist/repair-layers.js.map +7 -0
  161. package/dist/sample-combiner.d.ts +212 -0
  162. package/dist/sample-combiner.d.ts.map +1 -0
  163. package/dist/sample-combiner.js +616 -0
  164. package/dist/sample-combiner.js.map +7 -0
  165. package/dist/signaling.d.ts +29 -0
  166. package/dist/signaling.d.ts.map +1 -0
  167. package/dist/signaling.js +232 -0
  168. package/dist/signaling.js.map +7 -0
  169. package/dist/source-auth.d.ts +2 -0
  170. package/dist/source-auth.d.ts.map +1 -0
  171. package/dist/source-auth.js +1238 -0
  172. package/dist/source-auth.js.map +7 -0
  173. package/dist/switching-set-alignment.d.ts +61 -0
  174. package/dist/switching-set-alignment.d.ts.map +1 -0
  175. package/dist/switching-set-alignment.js +85 -0
  176. package/dist/switching-set-alignment.js.map +7 -0
  177. package/dist/timestamp-codec.d.ts +34 -0
  178. package/dist/timestamp-codec.d.ts.map +1 -0
  179. package/dist/timestamp-codec.js +456 -0
  180. package/dist/timestamp-codec.js.map +7 -0
  181. package/dist/track-pipeline-setup.d.ts +72 -0
  182. package/dist/track-pipeline-setup.d.ts.map +1 -0
  183. package/dist/track-pipeline-setup.js +306 -0
  184. package/dist/track-pipeline-setup.js.map +7 -0
  185. package/dist/track-pipeline.d.ts +121 -0
  186. package/dist/track-pipeline.d.ts.map +1 -0
  187. package/dist/track-pipeline.js +229 -0
  188. package/dist/track-pipeline.js.map +7 -0
  189. package/dist/track-session.d.ts +398 -0
  190. package/dist/track-session.d.ts.map +1 -0
  191. package/dist/track-session.js +767 -0
  192. package/dist/track-session.js.map +7 -0
  193. package/dist/transport-pool.d.ts +69 -0
  194. package/dist/transport-pool.d.ts.map +1 -0
  195. package/dist/transport-pool.js +88 -0
  196. package/dist/transport-pool.js.map +7 -0
  197. package/dist/wasm-mfu-reassembler.d.ts +92 -0
  198. package/dist/wasm-mfu-reassembler.d.ts.map +1 -0
  199. package/dist/wasm-mfu-reassembler.js +2139 -0
  200. package/dist/wasm-mfu-reassembler.js.map +7 -0
  201. package/dist/wasm-mmtp-container-parser.d.ts +10 -0
  202. package/dist/wasm-mmtp-container-parser.d.ts.map +1 -0
  203. package/dist/wasm-mmtp-container-parser.js +6418 -0
  204. package/dist/wasm-mmtp-container-parser.js.map +7 -0
  205. package/node_modules/@blockcast/fec-worker/README.md +213 -0
  206. package/node_modules/@blockcast/fec-worker/dist/.build-stamp +0 -0
  207. package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.d.ts +87 -0
  208. package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.d.ts.map +1 -0
  209. package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.js +279 -0
  210. package/node_modules/@blockcast/fec-worker/dist/fec-worker-client.js.map +1 -0
  211. package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.d.ts +252 -0
  212. package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.d.ts.map +1 -0
  213. package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.js +11 -0
  214. package/node_modules/@blockcast/fec-worker/dist/fec-worker-types.js.map +1 -0
  215. package/node_modules/@blockcast/fec-worker/dist/fec-worker.d.ts +14 -0
  216. package/node_modules/@blockcast/fec-worker/dist/fec-worker.d.ts.map +1 -0
  217. package/node_modules/@blockcast/fec-worker/dist/fec-worker.js +565 -0
  218. package/node_modules/@blockcast/fec-worker/dist/fec-worker.js.map +7 -0
  219. package/node_modules/@blockcast/fec-worker/dist/index.d.ts +10 -0
  220. package/node_modules/@blockcast/fec-worker/dist/index.d.ts.map +1 -0
  221. package/node_modules/@blockcast/fec-worker/dist/index.js +8 -0
  222. package/node_modules/@blockcast/fec-worker/dist/index.js.map +1 -0
  223. package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.d.ts +20 -0
  224. package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.d.ts.map +1 -0
  225. package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.js +349 -0
  226. package/node_modules/@blockcast/fec-worker/dist/shred-fec-worker.js.map +7 -0
  227. package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.d.ts +104 -0
  228. package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.d.ts.map +1 -0
  229. package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.js +181 -0
  230. package/node_modules/@blockcast/fec-worker/dist/shred-worker-client.js.map +1 -0
  231. package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.d.ts +179 -0
  232. package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.d.ts.map +1 -0
  233. package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.js +47 -0
  234. package/node_modules/@blockcast/fec-worker/dist/shred-worker-types.js.map +1 -0
  235. package/node_modules/@blockcast/fec-worker/dist/transfer.d.ts +9 -0
  236. package/node_modules/@blockcast/fec-worker/dist/transfer.d.ts.map +1 -0
  237. package/node_modules/@blockcast/fec-worker/dist/transfer.js +13 -0
  238. package/node_modules/@blockcast/fec-worker/dist/transfer.js.map +1 -0
  239. package/node_modules/@blockcast/fec-worker/package.json +59 -0
  240. package/node_modules/@blockcast/fec-worker/src/fec-worker-client.ts +319 -0
  241. package/node_modules/@blockcast/fec-worker/src/fec-worker-types.ts +268 -0
  242. package/node_modules/@blockcast/fec-worker/src/fec-worker.ts +1048 -0
  243. package/node_modules/@blockcast/fec-worker/src/index.ts +32 -0
  244. package/node_modules/@blockcast/fec-worker/src/shred-fec-worker.ts +558 -0
  245. package/node_modules/@blockcast/fec-worker/src/shred-worker-client.ts +222 -0
  246. package/node_modules/@blockcast/fec-worker/src/shred-worker-types.ts +194 -0
  247. package/node_modules/@blockcast/fec-worker/src/transfer.ts +12 -0
  248. package/node_modules/@blockcast/mmt-alta-parse/README.md +181 -0
  249. package/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
  250. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +57 -0
  251. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +1 -0
  252. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js +115 -0
  253. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +1 -0
  254. package/node_modules/@blockcast/mmt-alta-parse/package.json +50 -0
  255. package/node_modules/@blockcast/mmt-alta-parse/src/index.ts +136 -0
  256. package/node_modules/@blockcast/mmt-base/README.md +120 -0
  257. package/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
  258. package/node_modules/@blockcast/mmt-base/dist/index.d.ts +2 -0
  259. package/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +1 -0
  260. package/node_modules/@blockcast/mmt-base/dist/index.js +2 -0
  261. package/node_modules/@blockcast/mmt-base/dist/index.js.map +1 -0
  262. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +276 -0
  263. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +1 -0
  264. package/node_modules/@blockcast/mmt-base/dist/mmtp.js +645 -0
  265. package/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +1 -0
  266. package/node_modules/@blockcast/mmt-base/package.json +49 -0
  267. package/node_modules/@blockcast/mmt-base/src/index.ts +1 -0
  268. package/node_modules/@blockcast/mmt-base/src/mmtp.ts +899 -0
  269. package/node_modules/@blockcast/mmt-fec/README.md +270 -0
  270. package/node_modules/@blockcast/mmt-fec/dist/.build-stamp +0 -0
  271. package/node_modules/@blockcast/mmt-fec/dist/block-manager.d.ts +82 -0
  272. package/node_modules/@blockcast/mmt-fec/dist/block-manager.d.ts.map +1 -0
  273. package/node_modules/@blockcast/mmt-fec/dist/block-manager.js +296 -0
  274. package/node_modules/@blockcast/mmt-fec/dist/block-manager.js.map +1 -0
  275. package/node_modules/@blockcast/mmt-fec/dist/block-processor.d.ts +72 -0
  276. package/node_modules/@blockcast/mmt-fec/dist/block-processor.d.ts.map +1 -0
  277. package/node_modules/@blockcast/mmt-fec/dist/block-processor.js +192 -0
  278. package/node_modules/@blockcast/mmt-fec/dist/block-processor.js.map +1 -0
  279. package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.d.ts +46 -0
  280. package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.d.ts.map +1 -0
  281. package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.js +96 -0
  282. package/node_modules/@blockcast/mmt-fec/dist/cmaf-recovery.js.map +1 -0
  283. package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.d.ts +33 -0
  284. package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.d.ts.map +1 -0
  285. package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.js +57 -0
  286. package/node_modules/@blockcast/mmt-fec/dist/cmaf-symbols.js.map +1 -0
  287. package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.d.ts +185 -0
  288. package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.d.ts.map +1 -0
  289. package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.js +273 -0
  290. package/node_modules/@blockcast/mmt-fec/dist/fec-block-mapper.js.map +1 -0
  291. package/node_modules/@blockcast/mmt-fec/dist/index.d.ts +22 -0
  292. package/node_modules/@blockcast/mmt-fec/dist/index.d.ts.map +1 -0
  293. package/node_modules/@blockcast/mmt-fec/dist/index.js +22 -0
  294. package/node_modules/@blockcast/mmt-fec/dist/index.js.map +1 -0
  295. package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.d.ts +20 -0
  296. package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.d.ts.map +1 -0
  297. package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.js +28 -0
  298. package/node_modules/@blockcast/mmt-fec/dist/loc-symbols.js.map +1 -0
  299. package/node_modules/@blockcast/mmt-fec/dist/oti.d.ts +172 -0
  300. package/node_modules/@blockcast/mmt-fec/dist/oti.d.ts.map +1 -0
  301. package/node_modules/@blockcast/mmt-fec/dist/oti.js +254 -0
  302. package/node_modules/@blockcast/mmt-fec/dist/oti.js.map +1 -0
  303. package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.d.ts +41 -0
  304. package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.d.ts.map +1 -0
  305. package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.js +81 -0
  306. package/node_modules/@blockcast/mmt-fec/dist/reorder-buffer.js.map +1 -0
  307. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/README.md +181 -0
  308. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
  309. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +57 -0
  310. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +1 -0
  311. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.js +115 -0
  312. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +1 -0
  313. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/package.json +50 -0
  314. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-alta-parse/src/index.ts +136 -0
  315. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/README.md +120 -0
  316. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
  317. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.d.ts +2 -0
  318. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +1 -0
  319. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.js +2 -0
  320. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/index.js.map +1 -0
  321. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +276 -0
  322. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +1 -0
  323. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.js +645 -0
  324. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +1 -0
  325. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/package.json +49 -0
  326. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/index.ts +1 -0
  327. package/node_modules/@blockcast/mmt-fec/node_modules/@blockcast/mmt-base/src/mmtp.ts +899 -0
  328. package/node_modules/@blockcast/mmt-fec/package.json +79 -0
  329. package/node_modules/@blockcast/mmt-fec/src/block-manager.ts +371 -0
  330. package/node_modules/@blockcast/mmt-fec/src/block-processor.ts +277 -0
  331. package/node_modules/@blockcast/mmt-fec/src/cmaf-recovery.ts +127 -0
  332. package/node_modules/@blockcast/mmt-fec/src/cmaf-symbols.ts +68 -0
  333. package/node_modules/@blockcast/mmt-fec/src/fec-block-mapper.ts +332 -0
  334. package/node_modules/@blockcast/mmt-fec/src/index.ts +77 -0
  335. package/node_modules/@blockcast/mmt-fec/src/loc-symbols.ts +34 -0
  336. package/node_modules/@blockcast/mmt-fec/src/oti.ts +317 -0
  337. package/node_modules/@blockcast/mmt-fec/src/reorder-buffer.ts +101 -0
  338. package/node_modules/@blockcast/mmt-manifest-verify/dist/.build-stamp +0 -0
  339. package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.d.ts +28 -0
  340. package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.d.ts.map +1 -0
  341. package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.js +128 -0
  342. package/node_modules/@blockcast/mmt-manifest-verify/dist/admission.js.map +1 -0
  343. package/node_modules/@blockcast/mmt-manifest-verify/dist/index.d.ts +4 -0
  344. package/node_modules/@blockcast/mmt-manifest-verify/dist/index.d.ts.map +1 -0
  345. package/node_modules/@blockcast/mmt-manifest-verify/dist/index.js +4 -0
  346. package/node_modules/@blockcast/mmt-manifest-verify/dist/index.js.map +1 -0
  347. package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.d.ts +37 -0
  348. package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.d.ts.map +1 -0
  349. package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.js +179 -0
  350. package/node_modules/@blockcast/mmt-manifest-verify/dist/source-auth.js.map +1 -0
  351. package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.d.ts +99 -0
  352. package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.d.ts.map +1 -0
  353. package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.js +492 -0
  354. package/node_modules/@blockcast/mmt-manifest-verify/dist/verifier.js.map +1 -0
  355. package/node_modules/@blockcast/mmt-manifest-verify/package.json +45 -0
  356. package/node_modules/@blockcast/mmt-manifest-verify/src/admission.ts +171 -0
  357. package/node_modules/@blockcast/mmt-manifest-verify/src/index.ts +3 -0
  358. package/node_modules/@blockcast/mmt-manifest-verify/src/source-auth.ts +230 -0
  359. package/node_modules/@blockcast/mmt-manifest-verify/src/verifier.ts +669 -0
  360. package/node_modules/@noble/hashes/LICENSE +21 -0
  361. package/node_modules/@noble/hashes/README.md +521 -0
  362. package/node_modules/@noble/hashes/_assert.d.ts +17 -0
  363. package/node_modules/@noble/hashes/_assert.d.ts.map +1 -0
  364. package/node_modules/@noble/hashes/_assert.js +18 -0
  365. package/node_modules/@noble/hashes/_assert.js.map +1 -0
  366. package/node_modules/@noble/hashes/_blake.d.ts +14 -0
  367. package/node_modules/@noble/hashes/_blake.d.ts.map +1 -0
  368. package/node_modules/@noble/hashes/_blake.js +50 -0
  369. package/node_modules/@noble/hashes/_blake.js.map +1 -0
  370. package/node_modules/@noble/hashes/_md.d.ts +51 -0
  371. package/node_modules/@noble/hashes/_md.d.ts.map +1 -0
  372. package/node_modules/@noble/hashes/_md.js +162 -0
  373. package/node_modules/@noble/hashes/_md.js.map +1 -0
  374. package/node_modules/@noble/hashes/_u64.d.ts +55 -0
  375. package/node_modules/@noble/hashes/_u64.d.ts.map +1 -0
  376. package/node_modules/@noble/hashes/_u64.js +90 -0
  377. package/node_modules/@noble/hashes/_u64.js.map +1 -0
  378. package/node_modules/@noble/hashes/argon2.d.ts +32 -0
  379. package/node_modules/@noble/hashes/argon2.d.ts.map +1 -0
  380. package/node_modules/@noble/hashes/argon2.js +401 -0
  381. package/node_modules/@noble/hashes/argon2.js.map +1 -0
  382. package/node_modules/@noble/hashes/blake1.d.ts +106 -0
  383. package/node_modules/@noble/hashes/blake1.d.ts.map +1 -0
  384. package/node_modules/@noble/hashes/blake1.js +459 -0
  385. package/node_modules/@noble/hashes/blake1.js.map +1 -0
  386. package/node_modules/@noble/hashes/blake2.d.ts +116 -0
  387. package/node_modules/@noble/hashes/blake2.d.ts.map +1 -0
  388. package/node_modules/@noble/hashes/blake2.js +420 -0
  389. package/node_modules/@noble/hashes/blake2.js.map +1 -0
  390. package/node_modules/@noble/hashes/blake2b.d.ts +11 -0
  391. package/node_modules/@noble/hashes/blake2b.d.ts.map +1 -0
  392. package/node_modules/@noble/hashes/blake2b.js +14 -0
  393. package/node_modules/@noble/hashes/blake2b.js.map +1 -0
  394. package/node_modules/@noble/hashes/blake2s.d.ts +20 -0
  395. package/node_modules/@noble/hashes/blake2s.d.ts.map +1 -0
  396. package/node_modules/@noble/hashes/blake2s.js +24 -0
  397. package/node_modules/@noble/hashes/blake2s.js.map +1 -0
  398. package/node_modules/@noble/hashes/blake3.d.ts +54 -0
  399. package/node_modules/@noble/hashes/blake3.d.ts.map +1 -0
  400. package/node_modules/@noble/hashes/blake3.js +255 -0
  401. package/node_modules/@noble/hashes/blake3.js.map +1 -0
  402. package/node_modules/@noble/hashes/crypto.d.ts +2 -0
  403. package/node_modules/@noble/hashes/crypto.d.ts.map +1 -0
  404. package/node_modules/@noble/hashes/crypto.js +5 -0
  405. package/node_modules/@noble/hashes/crypto.js.map +1 -0
  406. package/node_modules/@noble/hashes/cryptoNode.d.ts +2 -0
  407. package/node_modules/@noble/hashes/cryptoNode.d.ts.map +1 -0
  408. package/node_modules/@noble/hashes/cryptoNode.js +18 -0
  409. package/node_modules/@noble/hashes/cryptoNode.js.map +1 -0
  410. package/node_modules/@noble/hashes/eskdf.d.ts +47 -0
  411. package/node_modules/@noble/hashes/eskdf.d.ts.map +1 -0
  412. package/node_modules/@noble/hashes/eskdf.js +166 -0
  413. package/node_modules/@noble/hashes/eskdf.js.map +1 -0
  414. package/node_modules/@noble/hashes/esm/_assert.d.ts +17 -0
  415. package/node_modules/@noble/hashes/esm/_assert.d.ts.map +1 -0
  416. package/node_modules/@noble/hashes/esm/_assert.js +15 -0
  417. package/node_modules/@noble/hashes/esm/_assert.js.map +1 -0
  418. package/node_modules/@noble/hashes/esm/_blake.d.ts +14 -0
  419. package/node_modules/@noble/hashes/esm/_blake.d.ts.map +1 -0
  420. package/node_modules/@noble/hashes/esm/_blake.js +45 -0
  421. package/node_modules/@noble/hashes/esm/_blake.js.map +1 -0
  422. package/node_modules/@noble/hashes/esm/_md.d.ts +51 -0
  423. package/node_modules/@noble/hashes/esm/_md.d.ts.map +1 -0
  424. package/node_modules/@noble/hashes/esm/_md.js +155 -0
  425. package/node_modules/@noble/hashes/esm/_md.js.map +1 -0
  426. package/node_modules/@noble/hashes/esm/_u64.d.ts +55 -0
  427. package/node_modules/@noble/hashes/esm/_u64.d.ts.map +1 -0
  428. package/node_modules/@noble/hashes/esm/_u64.js +67 -0
  429. package/node_modules/@noble/hashes/esm/_u64.js.map +1 -0
  430. package/node_modules/@noble/hashes/esm/argon2.d.ts +32 -0
  431. package/node_modules/@noble/hashes/esm/argon2.d.ts.map +1 -0
  432. package/node_modules/@noble/hashes/esm/argon2.js +392 -0
  433. package/node_modules/@noble/hashes/esm/argon2.js.map +1 -0
  434. package/node_modules/@noble/hashes/esm/blake1.d.ts +106 -0
  435. package/node_modules/@noble/hashes/esm/blake1.d.ts.map +1 -0
  436. package/node_modules/@noble/hashes/esm/blake1.js +452 -0
  437. package/node_modules/@noble/hashes/esm/blake1.js.map +1 -0
  438. package/node_modules/@noble/hashes/esm/blake2.d.ts +116 -0
  439. package/node_modules/@noble/hashes/esm/blake2.d.ts.map +1 -0
  440. package/node_modules/@noble/hashes/esm/blake2.js +413 -0
  441. package/node_modules/@noble/hashes/esm/blake2.js.map +1 -0
  442. package/node_modules/@noble/hashes/esm/blake2b.d.ts +11 -0
  443. package/node_modules/@noble/hashes/esm/blake2b.d.ts.map +1 -0
  444. package/node_modules/@noble/hashes/esm/blake2b.js +11 -0
  445. package/node_modules/@noble/hashes/esm/blake2b.js.map +1 -0
  446. package/node_modules/@noble/hashes/esm/blake2s.d.ts +20 -0
  447. package/node_modules/@noble/hashes/esm/blake2s.d.ts.map +1 -0
  448. package/node_modules/@noble/hashes/esm/blake2s.js +21 -0
  449. package/node_modules/@noble/hashes/esm/blake2s.js.map +1 -0
  450. package/node_modules/@noble/hashes/esm/blake3.d.ts +54 -0
  451. package/node_modules/@noble/hashes/esm/blake3.d.ts.map +1 -0
  452. package/node_modules/@noble/hashes/esm/blake3.js +251 -0
  453. package/node_modules/@noble/hashes/esm/blake3.js.map +1 -0
  454. package/node_modules/@noble/hashes/esm/crypto.d.ts +2 -0
  455. package/node_modules/@noble/hashes/esm/crypto.d.ts.map +1 -0
  456. package/node_modules/@noble/hashes/esm/crypto.js +2 -0
  457. package/node_modules/@noble/hashes/esm/crypto.js.map +1 -0
  458. package/node_modules/@noble/hashes/esm/cryptoNode.d.ts +2 -0
  459. package/node_modules/@noble/hashes/esm/cryptoNode.d.ts.map +1 -0
  460. package/node_modules/@noble/hashes/esm/cryptoNode.js +15 -0
  461. package/node_modules/@noble/hashes/esm/cryptoNode.js.map +1 -0
  462. package/node_modules/@noble/hashes/esm/eskdf.d.ts +47 -0
  463. package/node_modules/@noble/hashes/esm/eskdf.d.ts.map +1 -0
  464. package/node_modules/@noble/hashes/esm/eskdf.js +160 -0
  465. package/node_modules/@noble/hashes/esm/eskdf.js.map +1 -0
  466. package/node_modules/@noble/hashes/esm/hkdf.d.ts +36 -0
  467. package/node_modules/@noble/hashes/esm/hkdf.d.ts.map +1 -0
  468. package/node_modules/@noble/hashes/esm/hkdf.js +82 -0
  469. package/node_modules/@noble/hashes/esm/hkdf.js.map +1 -0
  470. package/node_modules/@noble/hashes/esm/hmac.d.ts +35 -0
  471. package/node_modules/@noble/hashes/esm/hmac.d.ts.map +1 -0
  472. package/node_modules/@noble/hashes/esm/hmac.js +86 -0
  473. package/node_modules/@noble/hashes/esm/hmac.js.map +1 -0
  474. package/node_modules/@noble/hashes/esm/index.d.ts +2 -0
  475. package/node_modules/@noble/hashes/esm/index.d.ts.map +1 -0
  476. package/node_modules/@noble/hashes/esm/index.js +33 -0
  477. package/node_modules/@noble/hashes/esm/index.js.map +1 -0
  478. package/node_modules/@noble/hashes/esm/legacy.d.ts +71 -0
  479. package/node_modules/@noble/hashes/esm/legacy.d.ts.map +1 -0
  480. package/node_modules/@noble/hashes/esm/legacy.js +281 -0
  481. package/node_modules/@noble/hashes/esm/legacy.js.map +1 -0
  482. package/node_modules/@noble/hashes/esm/package.json +10 -0
  483. package/node_modules/@noble/hashes/esm/pbkdf2.d.ts +23 -0
  484. package/node_modules/@noble/hashes/esm/pbkdf2.d.ts.map +1 -0
  485. package/node_modules/@noble/hashes/esm/pbkdf2.js +97 -0
  486. package/node_modules/@noble/hashes/esm/pbkdf2.js.map +1 -0
  487. package/node_modules/@noble/hashes/esm/ripemd160.d.ts +13 -0
  488. package/node_modules/@noble/hashes/esm/ripemd160.d.ts.map +1 -0
  489. package/node_modules/@noble/hashes/esm/ripemd160.js +13 -0
  490. package/node_modules/@noble/hashes/esm/ripemd160.js.map +1 -0
  491. package/node_modules/@noble/hashes/esm/scrypt.d.ts +34 -0
  492. package/node_modules/@noble/hashes/esm/scrypt.d.ts.map +1 -0
  493. package/node_modules/@noble/hashes/esm/scrypt.js +228 -0
  494. package/node_modules/@noble/hashes/esm/scrypt.js.map +1 -0
  495. package/node_modules/@noble/hashes/esm/sha1.d.ts +11 -0
  496. package/node_modules/@noble/hashes/esm/sha1.d.ts.map +1 -0
  497. package/node_modules/@noble/hashes/esm/sha1.js +11 -0
  498. package/node_modules/@noble/hashes/esm/sha1.js.map +1 -0
  499. package/node_modules/@noble/hashes/esm/sha2.d.ts +159 -0
  500. package/node_modules/@noble/hashes/esm/sha2.d.ts.map +1 -0
  501. package/node_modules/@noble/hashes/esm/sha2.js +375 -0
  502. package/node_modules/@noble/hashes/esm/sha2.js.map +1 -0
  503. package/node_modules/@noble/hashes/esm/sha256.d.ts +20 -0
  504. package/node_modules/@noble/hashes/esm/sha256.d.ts.map +1 -0
  505. package/node_modules/@noble/hashes/esm/sha256.js +20 -0
  506. package/node_modules/@noble/hashes/esm/sha256.js.map +1 -0
  507. package/node_modules/@noble/hashes/esm/sha3-addons.d.ts +142 -0
  508. package/node_modules/@noble/hashes/esm/sha3-addons.d.ts.map +1 -0
  509. package/node_modules/@noble/hashes/esm/sha3-addons.js +393 -0
  510. package/node_modules/@noble/hashes/esm/sha3-addons.js.map +1 -0
  511. package/node_modules/@noble/hashes/esm/sha3.d.ts +53 -0
  512. package/node_modules/@noble/hashes/esm/sha3.d.ts.map +1 -0
  513. package/node_modules/@noble/hashes/esm/sha3.js +234 -0
  514. package/node_modules/@noble/hashes/esm/sha3.js.map +1 -0
  515. package/node_modules/@noble/hashes/esm/sha512.d.ts +26 -0
  516. package/node_modules/@noble/hashes/esm/sha512.d.ts.map +1 -0
  517. package/node_modules/@noble/hashes/esm/sha512.js +26 -0
  518. package/node_modules/@noble/hashes/esm/sha512.js.map +1 -0
  519. package/node_modules/@noble/hashes/esm/utils.d.ts +161 -0
  520. package/node_modules/@noble/hashes/esm/utils.d.ts.map +1 -0
  521. package/node_modules/@noble/hashes/esm/utils.js +281 -0
  522. package/node_modules/@noble/hashes/esm/utils.js.map +1 -0
  523. package/node_modules/@noble/hashes/hkdf.d.ts +36 -0
  524. package/node_modules/@noble/hashes/hkdf.d.ts.map +1 -0
  525. package/node_modules/@noble/hashes/hkdf.js +88 -0
  526. package/node_modules/@noble/hashes/hkdf.js.map +1 -0
  527. package/node_modules/@noble/hashes/hmac.d.ts +35 -0
  528. package/node_modules/@noble/hashes/hmac.d.ts.map +1 -0
  529. package/node_modules/@noble/hashes/hmac.js +91 -0
  530. package/node_modules/@noble/hashes/hmac.js.map +1 -0
  531. package/node_modules/@noble/hashes/index.d.ts +1 -0
  532. package/node_modules/@noble/hashes/index.d.ts.map +1 -0
  533. package/node_modules/@noble/hashes/index.js +33 -0
  534. package/node_modules/@noble/hashes/index.js.map +1 -0
  535. package/node_modules/@noble/hashes/legacy.d.ts +71 -0
  536. package/node_modules/@noble/hashes/legacy.d.ts.map +1 -0
  537. package/node_modules/@noble/hashes/legacy.js +287 -0
  538. package/node_modules/@noble/hashes/legacy.js.map +1 -0
  539. package/node_modules/@noble/hashes/package.json +266 -0
  540. package/node_modules/@noble/hashes/pbkdf2.d.ts +23 -0
  541. package/node_modules/@noble/hashes/pbkdf2.d.ts.map +1 -0
  542. package/node_modules/@noble/hashes/pbkdf2.js +101 -0
  543. package/node_modules/@noble/hashes/pbkdf2.js.map +1 -0
  544. package/node_modules/@noble/hashes/ripemd160.d.ts +13 -0
  545. package/node_modules/@noble/hashes/ripemd160.d.ts.map +1 -0
  546. package/node_modules/@noble/hashes/ripemd160.js +16 -0
  547. package/node_modules/@noble/hashes/ripemd160.js.map +1 -0
  548. package/node_modules/@noble/hashes/scrypt.d.ts +34 -0
  549. package/node_modules/@noble/hashes/scrypt.d.ts.map +1 -0
  550. package/node_modules/@noble/hashes/scrypt.js +232 -0
  551. package/node_modules/@noble/hashes/scrypt.js.map +1 -0
  552. package/node_modules/@noble/hashes/sha1.d.ts +11 -0
  553. package/node_modules/@noble/hashes/sha1.d.ts.map +1 -0
  554. package/node_modules/@noble/hashes/sha1.js +14 -0
  555. package/node_modules/@noble/hashes/sha1.js.map +1 -0
  556. package/node_modules/@noble/hashes/sha2.d.ts +159 -0
  557. package/node_modules/@noble/hashes/sha2.d.ts.map +1 -0
  558. package/node_modules/@noble/hashes/sha2.js +384 -0
  559. package/node_modules/@noble/hashes/sha2.js.map +1 -0
  560. package/node_modules/@noble/hashes/sha256.d.ts +20 -0
  561. package/node_modules/@noble/hashes/sha256.d.ts.map +1 -0
  562. package/node_modules/@noble/hashes/sha256.js +23 -0
  563. package/node_modules/@noble/hashes/sha256.js.map +1 -0
  564. package/node_modules/@noble/hashes/sha3-addons.d.ts +142 -0
  565. package/node_modules/@noble/hashes/sha3-addons.d.ts.map +1 -0
  566. package/node_modules/@noble/hashes/sha3-addons.js +402 -0
  567. package/node_modules/@noble/hashes/sha3-addons.js.map +1 -0
  568. package/node_modules/@noble/hashes/sha3.d.ts +53 -0
  569. package/node_modules/@noble/hashes/sha3.d.ts.map +1 -0
  570. package/node_modules/@noble/hashes/sha3.js +239 -0
  571. package/node_modules/@noble/hashes/sha3.js.map +1 -0
  572. package/node_modules/@noble/hashes/sha512.d.ts +26 -0
  573. package/node_modules/@noble/hashes/sha512.d.ts.map +1 -0
  574. package/node_modules/@noble/hashes/sha512.js +29 -0
  575. package/node_modules/@noble/hashes/sha512.js.map +1 -0
  576. package/node_modules/@noble/hashes/src/_assert.ts +22 -0
  577. package/node_modules/@noble/hashes/src/_blake.ts +50 -0
  578. package/node_modules/@noble/hashes/src/_md.ts +176 -0
  579. package/node_modules/@noble/hashes/src/_u64.ts +91 -0
  580. package/node_modules/@noble/hashes/src/argon2.ts +497 -0
  581. package/node_modules/@noble/hashes/src/blake1.ts +534 -0
  582. package/node_modules/@noble/hashes/src/blake2.ts +486 -0
  583. package/node_modules/@noble/hashes/src/blake2b.ts +10 -0
  584. package/node_modules/@noble/hashes/src/blake2s.ts +20 -0
  585. package/node_modules/@noble/hashes/src/blake3.ts +272 -0
  586. package/node_modules/@noble/hashes/src/crypto.ts +9 -0
  587. package/node_modules/@noble/hashes/src/cryptoNode.ts +15 -0
  588. package/node_modules/@noble/hashes/src/eskdf.ts +187 -0
  589. package/node_modules/@noble/hashes/src/hkdf.ts +88 -0
  590. package/node_modules/@noble/hashes/src/hmac.ts +94 -0
  591. package/node_modules/@noble/hashes/src/index.ts +31 -0
  592. package/node_modules/@noble/hashes/src/legacy.ts +293 -0
  593. package/node_modules/@noble/hashes/src/pbkdf2.ts +122 -0
  594. package/node_modules/@noble/hashes/src/ripemd160.ts +12 -0
  595. package/node_modules/@noble/hashes/src/scrypt.ts +257 -0
  596. package/node_modules/@noble/hashes/src/sha1.ts +10 -0
  597. package/node_modules/@noble/hashes/src/sha2.ts +402 -0
  598. package/node_modules/@noble/hashes/src/sha256.ts +24 -0
  599. package/node_modules/@noble/hashes/src/sha3-addons.ts +499 -0
  600. package/node_modules/@noble/hashes/src/sha3.ts +258 -0
  601. package/node_modules/@noble/hashes/src/sha512.ts +34 -0
  602. package/node_modules/@noble/hashes/src/utils.ts +395 -0
  603. package/node_modules/@noble/hashes/utils.d.ts +161 -0
  604. package/node_modules/@noble/hashes/utils.d.ts.map +1 -0
  605. package/node_modules/@noble/hashes/utils.js +313 -0
  606. package/node_modules/@noble/hashes/utils.js.map +1 -0
  607. package/package.json +203 -0
  608. package/schemas/catalog.schema.json +360 -0
  609. package/src/align.ts +239 -0
  610. package/src/audio-init-parser.ts +189 -0
  611. package/src/audio-ring-buffer.ts +120 -0
  612. package/src/authored-timing.ts +63 -0
  613. package/src/carp-catalog.ts +845 -0
  614. package/src/catalog-fec-params.ts +535 -0
  615. package/src/catalog-network-source.ts +49 -0
  616. package/src/catalog-validator.ts +34 -0
  617. package/src/cmaf-block-assembler.ts +1167 -0
  618. package/src/cmaf-group-batcher.ts +104 -0
  619. package/src/cmcd-producer.ts +334 -0
  620. package/src/codec-utils.ts +1611 -0
  621. package/src/fec-codec.ts +61 -0
  622. package/src/fec-manager.ts +2081 -0
  623. package/src/fec-starvation-detector.ts +128 -0
  624. package/src/fec-worker-setup.ts +213 -0
  625. package/src/fmp4-completion.ts +47 -0
  626. package/src/fmp4-reader.ts +112 -0
  627. package/src/index.ts +486 -0
  628. package/src/iso-bmff-timing.ts +813 -0
  629. package/src/loc.ts +181 -0
  630. package/src/manifest-auth-plan.ts +107 -0
  631. package/src/media-router.ts +795 -0
  632. package/src/mfu-reassembler.ts +3741 -0
  633. package/src/mfu-reassembly-policy.ts +134 -0
  634. package/src/mmtp-container-parser.ts +1836 -0
  635. package/src/mmtp-decode-pipeline.ts +389 -0
  636. package/src/mmtp-router.ts +3136 -0
  637. package/src/mmtp-to-cmaf.ts +1024 -0
  638. package/src/multicast-auth-catalog.ts +340 -0
  639. package/src/multicast-input.ts +439 -0
  640. package/src/multicast-manager.ts +1720 -0
  641. package/src/multicast-transport.ts +321 -0
  642. package/src/multicast-unwrap.ts +132 -0
  643. package/src/no-modules.ts +20 -0
  644. package/src/phase14-catalog-override.ts +336 -0
  645. package/src/repair-fetch.ts +238 -0
  646. package/src/repair-layers.ts +490 -0
  647. package/src/sample-combiner.ts +938 -0
  648. package/src/signaling.ts +78 -0
  649. package/src/source-auth.ts +1 -0
  650. package/src/switching-set-alignment.ts +109 -0
  651. package/src/timestamp-codec.ts +265 -0
  652. package/src/track-pipeline-setup.ts +189 -0
  653. package/src/track-pipeline.ts +332 -0
  654. package/src/track-session.ts +994 -0
  655. package/src/transport-pool.ts +128 -0
  656. package/src/wasm-mfu-reassembler.ts +389 -0
  657. package/src/wasm-mmtp-container-parser.ts +23 -0
  658. package/test-vectors/align.json +199 -0
  659. package/test-vectors/catalog/README.md +40 -0
  660. package/test-vectors/catalog/negative/fec-enhancement-repair-removed.json +27 -0
  661. package/test-vectors/catalog/negative/fec-repair-layer-geometry.json +32 -0
  662. package/test-vectors/catalog/negative/fec-repair-layer-missing-symbols.json +30 -0
  663. package/test-vectors/catalog/negative/fec-repair-multiple-depends.json +31 -0
  664. package/test-vectors/catalog/negative/fec-repair-overlay-with-layer.json +33 -0
  665. package/test-vectors/catalog/negative/fec-repair-priority-out-of-band.json +23 -0
  666. package/test-vectors/catalog/negative/fec-repair-source-symbols-without-scope.json +32 -0
  667. package/test-vectors/catalog/negative/legacy-selection-params.json +15 -0
  668. package/test-vectors/catalog/negative/media-track-repair-layer.json +16 -0
  669. package/test-vectors/catalog/negative/missing-mmtp-fields.json +12 -0
  670. package/test-vectors/catalog/negative/multicast-auth-padded-key.json +37 -0
  671. package/test-vectors/catalog/negative/multicast-auth-reused-track-format.json +53 -0
  672. package/test-vectors/catalog/negative/multicast-auth-trailing-pad-bits.json +37 -0
  673. package/test-vectors/catalog/negative/multicast-endpoint-missing-protocol-and-source.json +24 -0
  674. package/test-vectors/catalog/negative/network-source-object.json +25 -0
  675. package/test-vectors/catalog/negative/raptorq-source-symbols-over-max.json +23 -0
  676. package/test-vectors/catalog/negative/raptorq-unaligned-symbol.json +26 -0
  677. package/test-vectors/catalog/negative/repair-track-legacy-shape.json +19 -0
  678. package/test-vectors/catalog/negative/track-role-empty.json +16 -0
  679. package/test-vectors/catalog/negative/track-role-non-string.json +16 -0
  680. package/test-vectors/catalog/positive/fec-layered-repair.json +104 -0
  681. package/test-vectors/catalog/positive/fec-multicast.json +51 -0
  682. package/test-vectors/catalog/positive/flat-av.json +30 -0
  683. package/test-vectors/catalog/positive/multicast-auth-rotation.json +55 -0
  684. package/test-vectors/catalog/positive/ticks-and-mixed-timescales.json +39 -0
  685. package/test-vectors/catalog/positive/track-role.json +49 -0
  686. package/test-vectors/catalog/releases/moqcast-native-v16-v1/catalog.json +65 -0
  687. package/test-vectors/catalog/releases/moqcast-native-v16-v1.archive.sha256 +1 -0
  688. package/test-vectors/catalog-multicast-auth-v1.json +26 -0
  689. package/test-vectors/catalog-validation.json +148 -0
  690. package/test-vectors/catalog-validator-public-api.mjs +25 -0
  691. package/test-vectors/generate-source-auth-fixture.mjs +114 -0
  692. package/test-vectors/source-auth-v1.json +61 -0
  693. package/test-vectors/source-contiguity-v1.json +131 -0
  694. package/test-vectors/subsample-placement-v1.json +230 -0
@@ -0,0 +1,3741 @@
1
+ /**
2
+ * @mmt/container - MFU Fragment Reassembler
3
+ *
4
+ * Handles authored fragmented MFUs (Media Fragment Units) according to
5
+ * ISO/IEC 23008-1:2023 Section 9.2.3.3. Fragmented runs are admitted in the
6
+ * exact FI/fragment_counter countdown carried on the wire. FI=1 supplies the
7
+ * intrinsic data-unit key. Every fragmented fragment is addressed by
8
+ * (stream scope, MPU, packetSequenceNumber + fragmentCounter mod 2^32); a
9
+ * continuation joins, parks under, or is stale against the one run holding
10
+ * its address, and other runs are never consulted.
11
+ *
12
+ * Fragmentation Indicator (FI) values:
13
+ * - 0: Complete MFU (single packet, no fragmentation)
14
+ * - 1: First fragment
15
+ * - 2: Middle fragment
16
+ * - 3: Last fragment
17
+ *
18
+ * @packageDocumentation
19
+ */
20
+
21
+ import { extractMFUPayload, isFecRepairPacket } from '@blockcast/mmt-base'
22
+
23
+ /** Source transport admitted by the MMTP router. */
24
+ export type MmtpSourcePath = 'multicast' | 'moq'
25
+
26
+ /**
27
+ * Strict whole-frame delivery provenance, folded over EVERY contributing
28
+ * fragment rather than the head fragment alone.
29
+ *
30
+ * This is deliberately NOT `source`. `source` answers a routing question —
31
+ * "is this the reliable-MoQ copy that must not confirm multicast takeover?" —
32
+ * and head-fragment-wins is the documented, deterministic answer to it.
33
+ * Provenance answers an evidence question: "did every byte of this frame
34
+ * physically arrive over multicast?" A frame with a multicast head and a
35
+ * QUIC-delivered tail is a legitimate routing win for `source: 'multicast'`
36
+ * and is emphatically NOT multicast-exclusive delivery.
37
+ *
38
+ * - 'multicast' — every contributing fragment was explicitly multicast.
39
+ * The ONLY value that may be counted as physical-delivery evidence.
40
+ * - 'mixed' — at least one contributing fragment arrived over MoQ.
41
+ * - 'unknown' — no fragment arrived over MoQ, but at least one carried no
42
+ * attribution at all. Absence is preserved as its own state rather than
43
+ * defaulted, so a consumer cannot mistake "never recorded" for "proven".
44
+ */
45
+ export type MmtpDeliveryProvenance = 'multicast' | 'mixed' | 'unknown'
46
+
47
+ /**
48
+ * Fold one fragment's `source` into a running provenance verdict.
49
+ *
50
+ * Fail-closed, order-independent and associative: 'mixed' absorbs everything,
51
+ * a missing attribution degrades 'multicast' to 'unknown', and nothing ever
52
+ * upgrades a verdict. Fold over an empty set is 'unknown' — no fragment ever
53
+ * proved anything.
54
+ */
55
+ export function foldDeliveryProvenance(
56
+ verdict: MmtpDeliveryProvenance,
57
+ source: MmtpSourcePath | undefined
58
+ ): MmtpDeliveryProvenance {
59
+ if (verdict === 'mixed') return 'mixed'
60
+ if (source === 'moq') return 'mixed'
61
+ if (source === undefined) return 'unknown'
62
+ return verdict
63
+ }
64
+
65
+ /**
66
+ * One fragment's own provenance.
67
+ *
68
+ * An explicit `deliveryProvenance` wins: FEC-recovered fragments carry no
69
+ * `source` (they were never delivered as themselves) but their block's symbol
70
+ * provenance IS known, and the router supplies it. Falling back to `source`
71
+ * keeps directly-delivered fragments working unchanged.
72
+ */
73
+ function fragmentProvenance(fragment: {
74
+ source?: MmtpSourcePath
75
+ deliveryProvenance?: MmtpDeliveryProvenance
76
+ }): MmtpDeliveryProvenance {
77
+ if (fragment.deliveryProvenance !== undefined) return fragment.deliveryProvenance
78
+ if (fragment.source === 'moq') return 'mixed'
79
+ if (fragment.source === 'multicast') return 'multicast'
80
+ return 'unknown'
81
+ }
82
+
83
+ /** Strict provenance for a complete fragment set. */
84
+ export function deliveryProvenanceOf(
85
+ fragments: readonly {
86
+ source?: MmtpSourcePath
87
+ deliveryProvenance?: MmtpDeliveryProvenance
88
+ }[]
89
+ ): MmtpDeliveryProvenance {
90
+ if (fragments.length === 0) return 'unknown'
91
+ let verdict: MmtpDeliveryProvenance = 'multicast'
92
+ for (const fragment of fragments) {
93
+ const own = fragmentProvenance(fragment)
94
+ verdict =
95
+ verdict === 'mixed' || own === 'mixed'
96
+ ? 'mixed'
97
+ : verdict === 'unknown' || own === 'unknown'
98
+ ? 'unknown'
99
+ : 'multicast'
100
+ }
101
+ return verdict
102
+ }
103
+
104
+ /**
105
+ * MFU Fragment for reassembly
106
+ */
107
+ export interface MFUFragment {
108
+ fragmentationIndicator: number
109
+ mpuSequenceNumber: number
110
+ fragmentCounter: number
111
+ data: Uint8Array
112
+ timestamp: bigint
113
+ /** Random Access Point flag from MMTP header - indicates keyframe */
114
+ rapFlag: boolean
115
+ /** MFU DU priority. FFmpeg marks random-access samples with high priority. */
116
+ priority?: number
117
+ sampleNumber?: number
118
+ movieFragmentSequenceNumber?: number
119
+ sampleOffset?: number
120
+ /** Non-timed MFU Item_ID from the 4-byte DU header. */
121
+ itemId?: number
122
+ /** Authored u32 MMTP packet_sequence_number, including on recovered packets. */
123
+ packetSequenceNumber?: number
124
+ /** True when this accepted source packet was reconstructed by FEC. */
125
+ recovered?: boolean
126
+ /** Transport that supplied this accepted fragment. Absent for recovered data. */
127
+ source?: MmtpSourcePath
128
+ /**
129
+ * Explicit provenance for this fragment, overriding what `source` implies.
130
+ * Set by the router for FEC-recovered fragments, which have no `source` of
131
+ * their own but whose FEC block's symbol provenance is known.
132
+ */
133
+ deliveryProvenance?: MmtpDeliveryProvenance
134
+ /** Immutable catalog identity supplied by the owning ingress subscription. */
135
+ catalogTrackName?: string
136
+ /** Physical MMTP stream identity when no catalog identity is available. */
137
+ packetId?: number
138
+ }
139
+
140
+ /**
141
+ * Reassembled MFU result
142
+ */
143
+ export interface ReassembledMFU {
144
+ mpuSequenceNumber: number
145
+ /** MMTP packet_sequence_number of the first fragment in delivery order. */
146
+ packetSequenceNumber?: number
147
+ /** Every authoritative MMTP source-packet sequence that formed this MFU. */
148
+ packetSequenceNumbers?: number[]
149
+ /** Subset of packetSequenceNumbers reconstructed by FEC. */
150
+ repairedPacketSequenceNumbers?: number[]
151
+ data: Uint8Array
152
+ timestamp: bigint
153
+ fragmentCount: number
154
+ /** Random Access Point flag - indicates keyframe (from first fragment) */
155
+ rapFlag: boolean
156
+ /** MFU DU priority from the first fragment that carried it. */
157
+ priority?: number
158
+ sampleNumber?: number
159
+ movieFragmentSequenceNumber?: number
160
+ /**
161
+ * This DU's authored base byte offset within its sample (moqcast #37). 0
162
+ * for a whole-sample DU; nonzero identifies one subsample DU among
163
+ * siblings partitioning an oversized sample. Constant across the DU's own
164
+ * fragments — feed into a sample combiner to reassemble the full sample.
165
+ */
166
+ sampleOffset?: number
167
+ /**
168
+ * Transport that supplied the accepted FI=1 head fragment (or the complete
169
+ * FI=0 frame). This is the deterministic winner for mixed-path assembly.
170
+ *
171
+ * ROUTING ONLY. It describes one fragment, so it is not evidence about the
172
+ * whole frame — read {@link ReassembledMFU.deliveryProvenance} for that.
173
+ */
174
+ source?: MmtpSourcePath
175
+ /**
176
+ * Strict provenance folded over every contributing fragment. Only
177
+ * 'multicast' proves multicast-exclusive delivery of this frame.
178
+ */
179
+ deliveryProvenance: MmtpDeliveryProvenance
180
+ /** Catalog identity from the accepted FI=1 head fragment, or FI=0 frame. */
181
+ catalogTrackName?: string
182
+ }
183
+
184
+ /**
185
+ * Reassembler statistics
186
+ */
187
+ export interface ReassemblerStats {
188
+ totalFragments: number
189
+ reassembledMFUs: number
190
+ /** Fragments rejected by strict authored DU-state validation. */
191
+ rejectedFragments: number
192
+ /** Incomplete MFUs discarded after their configured deadline. */
193
+ timedOutMFUs: number
194
+ /** Incomplete MFUs discarded to enforce the configured memory bound. */
195
+ evictedMemoryLimit: number
196
+ outOfOrderFragments: number
197
+ }
198
+
199
+ export const MFU_REASSEMBLY_ERROR_CODES = [
200
+ 'invalid-fragment-state',
201
+ 'missing-data-unit-identity',
202
+ 'missing-packet-sequence',
203
+ 'conflicting-data-unit-identity',
204
+ 'overlapping-data-unit',
205
+ 'fragment-counter-mismatch',
206
+ 'data-unit-identity-mismatch',
207
+ 'offset-overflow',
208
+ 'active-data-unit-limit',
209
+ 'incomplete-terminal-fragment',
210
+ 'ambiguous-continuation',
211
+ ] as const
212
+
213
+ export type MfuReassemblyErrorCode = (typeof MFU_REASSEMBLY_ERROR_CODES)[number]
214
+
215
+ const mfuReassemblyErrorCodes = new Set<string>(MFU_REASSEMBLY_ERROR_CODES)
216
+
217
+ export function isMfuReassemblyErrorCode(value: unknown): value is MfuReassemblyErrorCode {
218
+ return typeof value === 'string' && mfuReassemblyErrorCodes.has(value)
219
+ }
220
+
221
+ export class MfuReassemblyError extends Error {
222
+ readonly name = 'MfuReassemblyError'
223
+
224
+ constructor(
225
+ readonly code: MfuReassemblyErrorCode,
226
+ message: string
227
+ ) {
228
+ super(message)
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Identity-free test for a reassembly rejection.
234
+ *
235
+ * Consumers that inline `@blockcast/mmt-container` more than once (a component
236
+ * bundle whose `@blockcast/*` dependencies each carry their own copy) end up
237
+ * with several distinct `MfuReassemblyError` classes. An `instanceof` check
238
+ * against one copy then misses rejections raised by another, and a per-fragment
239
+ * rejection escapes as an uncaught throw that aborts the rest of the packet's
240
+ * processing. The wire contract of a rejection is its name plus one of the
241
+ * closed error codes, so that is what this guard checks.
242
+ */
243
+ export function isMfuReassemblyError(error: unknown): error is MfuReassemblyError {
244
+ if (error instanceof MfuReassemblyError) return true
245
+ if (typeof error !== 'object' || error === null) return false
246
+ return (
247
+ Reflect.get(error, 'name') === 'MfuReassemblyError' &&
248
+ isMfuReassemblyErrorCode(Reflect.get(error, 'code'))
249
+ )
250
+ }
251
+
252
+ function assembledPacketSequences(fragments: readonly MFUFragment[]): {
253
+ packetSequenceNumbers?: number[]
254
+ repairedPacketSequenceNumbers?: number[]
255
+ } {
256
+ const packetSequenceNumbers: number[] = []
257
+ const repairedPacketSequenceNumbers: number[] = []
258
+ const seen = new Set<number>()
259
+ const repaired = new Set<number>()
260
+ for (const fragment of fragments) {
261
+ const sequence = fragment.packetSequenceNumber
262
+ if (typeof sequence !== 'number' || !Number.isSafeInteger(sequence) || sequence < 0 || sequence > 0xffffffff) {
263
+ return {}
264
+ }
265
+ if (!seen.has(sequence)) {
266
+ seen.add(sequence)
267
+ packetSequenceNumbers.push(sequence)
268
+ }
269
+ if (fragment.recovered === true && !repaired.has(sequence)) {
270
+ repaired.add(sequence)
271
+ repairedPacketSequenceNumbers.push(sequence)
272
+ }
273
+ }
274
+ return { packetSequenceNumbers, repairedPacketSequenceNumbers }
275
+ }
276
+
277
+ export interface PendingMfuDebug {
278
+ key: string
279
+ fragments: number
280
+ fi0: number
281
+ fi1: number
282
+ fi2: number
283
+ fi3: number
284
+ firstFi: number
285
+ lastFi: number
286
+ firstOrder: number
287
+ lastOrder: number
288
+ minCounter: number
289
+ maxCounter: number
290
+ minOffset?: number
291
+ maxOffset?: number
292
+ movieFragmentSequenceNumber?: number
293
+ sampleNumber?: number
294
+ mpuSequenceNumber?: number
295
+ sortedHead: Array<{
296
+ fi: number
297
+ counter: number
298
+ order: number
299
+ seq?: number
300
+ offset?: number
301
+ }>
302
+ sortedTail: Array<{
303
+ fi: number
304
+ counter: number
305
+ order: number
306
+ seq?: number
307
+ offset?: number
308
+ }>
309
+ }
310
+
311
+ export interface MFUReassemblerOptions {
312
+ /** Timeout (ms) for incomplete MFUs. Derive from frame duration or FEC interleaveDepthMs.
313
+ * Pass null for live clock-free operation; memory remains bounded by maxBuffered. */
314
+ timeoutMs: number | null
315
+ /** Max concurrent incomplete MFUs, including continuations awaiting FI=1. */
316
+ maxBuffered?: number
317
+ }
318
+
319
+ /** Reassembly surface consumed by MmtpContainerParser and MmtpRouter.
320
+ * Implemented by both the TypeScript engine and the shared
321
+ * Rust/WASM IFC adapter. */
322
+ export interface MfuReassemblerBackend {
323
+ timeoutMs: number | null
324
+ readonly stats: ReassemblerStats
325
+ onDeferredAssembly?: (result: ReassembledMFU) => void
326
+ /** Returns null only for a valid incomplete DU; strict rejection throws
327
+ * `MfuReassemblyError`. */
328
+ addFragment(fragment: MFUFragment): ReassembledMFU | null
329
+ flush(): void
330
+ resetStream(catalogTrackName: string): void
331
+ reset(): void
332
+ readonly pendingCount: number
333
+ getStatus(): {
334
+ bufferedMFUs: number
335
+ pendingFragments: number
336
+ pendingSamples: PendingMfuDebug[]
337
+ stats: ReassemblerStats
338
+ }
339
+ }
340
+
341
+ function streamScope(fragment: MFUFragment): readonly [kind: string, identity: string | number | null] {
342
+ if (fragment.catalogTrackName !== undefined) return ['catalog', fragment.catalogTrackName]
343
+ if (fragment.packetId !== undefined) return ['packet', fragment.packetId]
344
+ return ['unspecified', null]
345
+ }
346
+
347
+ function isU32(value: unknown): value is number {
348
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 0xffffffff
349
+ }
350
+
351
+ /**
352
+ * Wire address of one fragmented data unit: the physical stream scope, the
353
+ * MPU, and `packetSequenceNumber + fragmentCounter` mod 2^32. Under the
354
+ * contiguous-source producer profile every fragment of one data unit occupies
355
+ * consecutive packet sequence numbers on its packet ID while
356
+ * `fragmentCounter` counts the payloads that follow, so the sum is the
357
+ * sequence number of the data unit's last fragment: equal for every fragment
358
+ * of one data unit and distinct between data units of one MPU. Transport
359
+ * association only; objectKey remains the intrinsic DU identity.
360
+ */
361
+ function dataUnitAddress(fragment: MFUFragment, packetSequenceNumber: number): string {
362
+ return JSON.stringify([
363
+ ...streamScope(fragment),
364
+ fragment.mpuSequenceNumber,
365
+ (packetSequenceNumber + fragment.fragmentCounter) >>> 0,
366
+ ])
367
+ }
368
+
369
+ function hasAnySampleIdentityField(fragment: MFUFragment): boolean {
370
+ return (
371
+ fragment.movieFragmentSequenceNumber !== undefined ||
372
+ fragment.sampleNumber !== undefined ||
373
+ fragment.sampleOffset !== undefined
374
+ )
375
+ }
376
+
377
+ type IdentifiedSampleFragment = MFUFragment &
378
+ Required<Pick<MFUFragment, 'movieFragmentSequenceNumber' | 'sampleNumber' | 'sampleOffset'>>
379
+
380
+ function hasSampleIdentity(fragment: MFUFragment): fragment is IdentifiedSampleFragment {
381
+ return (
382
+ isU32(fragment.movieFragmentSequenceNumber) &&
383
+ fragment.movieFragmentSequenceNumber > 0 &&
384
+ isU32(fragment.sampleNumber) &&
385
+ fragment.sampleNumber > 0 &&
386
+ isU32(fragment.sampleOffset)
387
+ )
388
+ }
389
+
390
+ function hasValidFragmentHeader(fragment: MFUFragment): boolean {
391
+ const { fragmentationIndicator: fi, fragmentCounter: counter } = fragment
392
+ if (!Number.isInteger(fi) || fi < 0 || fi > 3) return false
393
+ if (!Number.isInteger(counter) || counter < 0 || counter > 0xff) return false
394
+ return fi === 0 || fi === 3 ? counter === 0 : counter > 0
395
+ }
396
+
397
+ function fragmentCounterOrder(fragment: MFUFragment): number {
398
+ return 0xff - fragment.fragmentCounter
399
+ }
400
+
401
+ function objectKey(fragment: MFUFragment): string {
402
+ const scope = streamScope(fragment)
403
+ if (fragment.itemId !== undefined) {
404
+ return JSON.stringify([...scope, fragment.mpuSequenceNumber, 'item', fragment.itemId])
405
+ }
406
+ if (hasSampleIdentity(fragment)) {
407
+ return JSON.stringify([
408
+ ...scope,
409
+ fragment.mpuSequenceNumber,
410
+ 'sample',
411
+ fragment.movieFragmentSequenceNumber,
412
+ fragment.sampleNumber,
413
+ // The DU's authored base offset (moqcast #37): sibling subsample DUs
414
+ // of one sample get distinct keys instead of colliding. Only ever
415
+ // called with the unmutated FI=1 (or FI=0) fragment, whose
416
+ // sampleOffset is that authored base -- never the accumulating
417
+ // per-continuation value written by #associateDataUnit.
418
+ fragment.sampleOffset,
419
+ ])
420
+ }
421
+ return JSON.stringify([...scope, fragment.mpuSequenceNumber, 'unidentified'])
422
+ }
423
+
424
+ function ordersByOffset(fragments: MFUFragment[]): boolean {
425
+ return fragments.every(fragment => hasSampleIdentity(fragment))
426
+ }
427
+
428
+ type ActiveDataUnit = {
429
+ /** Wire address every continuation of this data unit carries. */
430
+ address: string
431
+ bufferKey: string
432
+ nextFragmentCounter: number
433
+ /** Monotonic admission ordinal of this data unit's slot. Never a clock. */
434
+ admissionSeq: number
435
+ catalogTrackName?: string
436
+ } & (
437
+ | {
438
+ kind: 'sample'
439
+ movieFragmentSequenceNumber: number
440
+ sampleNumber: number
441
+ nextOffset: number
442
+ }
443
+ | {
444
+ kind: 'item'
445
+ itemId: number
446
+ }
447
+ )
448
+
449
+ interface DataUnitAssociation {
450
+ bufferKey: string
451
+ first: boolean
452
+ terminal: boolean
453
+ }
454
+
455
+ interface PendingContinuationRun {
456
+ fragments: Map<number, MFUFragment>
457
+ startedAt: number
458
+ /** Monotonic admission ordinal of this data unit's slot. Never a clock. */
459
+ admissionSeq: number
460
+ timeoutId?: ReturnType<typeof setTimeout>
461
+ }
462
+
463
+ interface BufferTimeoutState {
464
+ startedAt: number
465
+ timeoutId?: ReturnType<typeof setTimeout>
466
+ }
467
+
468
+ function checkedSampleEnd(offset: number, byteLength: number): number | null {
469
+ const end = offset + byteLength
470
+ return Number.isSafeInteger(end) && end <= 0xffffffff ? end : null
471
+ }
472
+
473
+ /**
474
+ * MFU Reassembler with bounded reordered-fragment association
475
+ * Based on ISO/IEC 23008-1:2023 Section 9.2.3.3
476
+ */
477
+
478
+ /**
479
+ * Completed-address window, as a multiple of maxBuffered. A late continuation
480
+ * is refused as stale while its data unit's address is within this window and
481
+ * parks as a headless data unit once the address has been forgotten. The
482
+ * value is a cross-engine invariant shared with the Rust reassembler
483
+ * (mmt-core COMPLETED_ADDRESS_WINDOW_MULTIPLE); change both together.
484
+ */
485
+ export const COMPLETED_ADDRESS_WINDOW_MULTIPLE = 2
486
+
487
+ /**
488
+ * Cross-engine anchors for the clock-free eviction guard. The bound itself is
489
+ * `maxBuffered`, a host parameter — these fix the one scenario both regression
490
+ * corpora drive so the Rust and TypeScript guards assert the same boundary
491
+ * rather than each deriving its own. Shared with the Rust reassembler
492
+ * (`EVICTION_GUARD_MAX_BUFFERED` / `EVICTION_GUARD_DATA_UNITS` in
493
+ * `mmt-core/src/reassembler/policy/mmt.rs`); change both together.
494
+ *
495
+ * `EVICTION_GUARD_DATA_UNITS` headless data units admitted against a bound of
496
+ * `EVICTION_GUARD_MAX_BUFFERED` must evict exactly the difference.
497
+ */
498
+ export const EVICTION_GUARD_MAX_BUFFERED = 4
499
+ export const EVICTION_GUARD_DATA_UNITS = 12
500
+
501
+ /**
502
+ * Does a parked continuation queue hold a `maxBuffered` slot of its own?
503
+ *
504
+ * One rule, four call sites. Two read the bound: the queue that is *counted*
505
+ * toward it (`#unopenedParkedRunCount`) is exactly the queue that is *offered*
506
+ * to eviction (`#evictLeastRecentlyAdmitted`). Two gate admission against it:
507
+ * `#parkContinuation` and the FI=1 arm of `#associateDataUnit` each ask whether
508
+ * this address is already charged before moving the bound. Stating the rule once
509
+ * is what keeps those four answers from drifting apart — a queue that charges a
510
+ * slot no eviction can reclaim wedges the receiver, because the bound then
511
+ * refuses every new data unit forever with `active-data-unit-limit`.
512
+ *
513
+ * Two exclusions, for two different reasons:
514
+ *
515
+ * - **An open run owns the address.** The data unit is already charged through
516
+ * that run, so its queue costs nothing further.
517
+ * - **The queue is empty.** It holds nothing, so it charges nothing and offers
518
+ * nothing.
519
+ *
520
+ * The emptiness half is belt-and-braces, not load-bearing. Both engines discard
521
+ * an emptied queue eagerly — `#deletePendingRun` here on every drain exit,
522
+ * `discard_empty_parked_run` in Rust — and `#parkContinuation` always inserts
523
+ * into a queue it just created, so an empty queue is not observable today.
524
+ * Correctness must not depend on that invariant holding in all three places per
525
+ * engine: routing every site that asks the question through this one predicate
526
+ * is what keeps the engines agreeing if a future edit ever misses one of them.
527
+ *
528
+ * Scope: this rule decides *slot accounting* — counted, offered, admitted. A
529
+ * queue carries two further per-slot facts, its admission ordinal and its
530
+ * retention anchor (`startedAt` plus the timer armed from it), and both must
531
+ * move when an empty queue is charged again. `#parkContinuation` resets them
532
+ * explicitly; Rust gets the same contract structurally, by deriving each from
533
+ * the queue's live entries. Same contract, two mechanisms.
534
+ *
535
+ * The *reap schedule* asks the same question, and every site that arms a
536
+ * parked queue reaches one function, `#armHeadlessPendingTimeout`, which takes
537
+ * the test through this rule: `set timeoutMs` calls it for every queue when
538
+ * the policy changes, `#parkContinuation` calls it after a created or
539
+ * re-anchored queue holds its first fragment, and a run closing without
540
+ * completing calls it for the queue it leaves behind. It never arms a timer
541
+ * for an empty queue, because a timeout reports a data unit that failed to
542
+ * complete in time and an empty queue holds no such data unit. Charging one
543
+ * would record an MFU that never existed. No site installs a parked-queue
544
+ * timer of its own, so neither the slot rule nor the `startedAt` anchoring can
545
+ * drift one site at a time.
546
+ *
547
+ * That is the answer Rust reaches structurally: `next_timeout_at` derives
548
+ * candidate deadlines from the queue's live entries, so an empty queue yields
549
+ * none and no sweep is ever scheduled on its account, and `cleanup_timeouts`
550
+ * retains by `parked_run_holds_a_slot`, so a sweep arriving for any *other*
551
+ * queue leaves an empty one in place rather than charging
552
+ * `record_policy_timeout` for it. Both engines therefore agree on when the
553
+ * reap is scheduled, and the recorded statistic is unchanged for every queue
554
+ * that really did hold a data unit: `#onPendingTimeout` charges
555
+ * `stats.timedOutMFUs`, the shape Rust's `record_policy_timeout` produces, and
556
+ * takes the same slot test before charging, so the statistic cannot be
557
+ * recorded for an empty queue whatever order the arming sites run in.
558
+ *
559
+ * The trade this makes, in both engines: a queue that is emptied without being
560
+ * discarded is invisible to the reap schedule, to the slot count, and to
561
+ * eviction, so nothing reclaims it until `reset()` clears the policy state.
562
+ * Disposal of an emptied queue therefore rests entirely on the eager discard
563
+ * sites (`#deletePendingRun` here, `discard_empty_parked_run` in Rust), where
564
+ * it is housekeeping rather than an expiry; a drain exit that skips that call
565
+ * leaks the queue for the life of the reassembler rather than charging a
566
+ * phantom timeout for it. Converged under BLO-32420. The arming half of this
567
+ * rule is pinned in `mfu-reassembler-eviction.test.ts`: a headless queue that
568
+ * holds fragments is armed by `set timeoutMs` and by its run closing, and
569
+ * disarmed by `timeoutMs = null`. The emptiness half is Rust-only, because
570
+ * `#pendingContinuations` is unreachable from this package's tests.
571
+ *
572
+ * The public counters are a separate matter and *are* governed: `pendingCount`
573
+ * — and through it `getStatus().bufferedMFUs` — delegates to
574
+ * `#unopenedParkedRunCount`, exactly as Rust's `buffered_count` is built on
575
+ * `unopened_parked_run_count`. Filtering either on the open run alone makes the
576
+ * engines report different numbers for the same state out of a host-observable
577
+ * API.
578
+ *
579
+ * Twin of `parked_run_holds_a_slot` in
580
+ * `mmt-core/src/reassembler/policy/mmt.rs`; change both together.
581
+ */
582
+ export function parkedRunHoldsASlot(hasOpenRun: boolean, fragmentCount: number): boolean {
583
+ return !hasOpenRun && fragmentCount > 0
584
+ }
585
+
586
+ export class MFUReassembler {
587
+ // Fragment buffers and active runs use the authored MFU DU identity.
588
+ // Continuations may wait in one counter-indexed queue per data-unit address
589
+ // until FI=1 supplies that identity. The address is transport association
590
+ // only; the object key stays the intrinsic DU identity.
591
+ #buffers: Map<string, MFUFragment[]> = new Map()
592
+ #timeouts: Map<string, BufferTimeoutState> = new Map()
593
+ #activeDataUnits: Map<string, ActiveDataUnit> = new Map()
594
+ #pendingContinuations: Map<string, PendingContinuationRun> = new Map()
595
+ #emitted: Set<string> = new Set() // same sample keys, so FEC-recovered duplicate heads are dropped
596
+ #emittedOrder: string[] = []
597
+ // Addresses of emitted data units. A continuation addressed to one of them
598
+ // is a late copy of a fragment the run already consumed (FEC recovery
599
+ // supplied it first), so it is rejected as stale rather than parked as a
600
+ // headless data unit that would hold a slot until reset. Bounded at
601
+ // COMPLETED_ADDRESS_WINDOW_MULTIPLE x maxBuffered, the same window the Rust
602
+ // engine keeps, so both engines forget an address after the same number of
603
+ // completions. (#emitted is a separate, per-engine window: this engine keeps
604
+ // maxBuffered sample keys where Rust keeps 2x; the completed set does not
605
+ // derive from it.)
606
+ #completed: Set<string> = new Set()
607
+ #completedOrder: string[] = []
608
+ #timeoutMs: number | null
609
+ #maxBuffered: number
610
+ /**
611
+ * Monotonic admission ordinal. Counts slot admissions, never reads a clock,
612
+ * so eviction order survives on hosts that run `timeoutMs: null`.
613
+ */
614
+ #nextAdmissionSeq = 0
615
+
616
+ /** Statistics for debugging */
617
+ readonly stats: ReassemblerStats = {
618
+ totalFragments: 0,
619
+ reassembledMFUs: 0,
620
+ rejectedFragments: 0,
621
+ timedOutMFUs: 0,
622
+ evictedMemoryLimit: 0,
623
+ outOfOrderFragments: 0,
624
+ }
625
+
626
+ constructor(options: MFUReassemblerOptions) {
627
+ if (options.timeoutMs !== null && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
628
+ throw new Error('[MFUReassembler] timeoutMs must be > 0 (derive from frame duration or FEC config)')
629
+ }
630
+ const maxBuffered = options.maxBuffered ?? 100
631
+ if (!Number.isInteger(maxBuffered) || maxBuffered <= 0) {
632
+ throw new Error('[MFUReassembler] maxBuffered must be a positive integer')
633
+ }
634
+ this.#timeoutMs = options.timeoutMs
635
+ this.#maxBuffered = maxBuffered
636
+ }
637
+
638
+ /** Update the expiry policy for all retained and future incomplete MFUs. */
639
+ set timeoutMs(ms: number | null) {
640
+ if (ms !== null && (!Number.isFinite(ms) || ms <= 0)) {
641
+ throw new Error('[MFUReassembler] timeoutMs must be > 0 (derive from frame duration or FEC config)')
642
+ }
643
+ this.#timeoutMs = ms
644
+ const now = Date.now()
645
+ for (const [bufferKey, state] of this.#timeouts) {
646
+ if (state.timeoutId !== undefined) clearTimeout(state.timeoutId)
647
+ delete state.timeoutId
648
+ if (ms !== null) {
649
+ state.timeoutId = setTimeout(() => this.#onTimeout(bufferKey), Math.max(0, state.startedAt + ms - now))
650
+ }
651
+ }
652
+ // Parked queues re-arm through the one arming function, which reads the
653
+ // new policy from `#timeoutMs` (assigned above) and keeps each queue on its
654
+ // own `startedAt` anchor. Clearing first releases any timer armed under the
655
+ // old policy so the helper's already-armed guard cannot skip the re-arm.
656
+ for (const address of this.#pendingContinuations.keys()) {
657
+ this.#clearPendingTimeout(address)
658
+ this.#armHeadlessPendingTimeout(address)
659
+ }
660
+ }
661
+ get timeoutMs(): number | null {
662
+ return this.#timeoutMs
663
+ }
664
+
665
+ /**
666
+ * Add a fragment and return reassembled MFU if complete
667
+ */
668
+ addFragment(fragment: MFUFragment): ReassembledMFU | null {
669
+ this.stats.totalFragments++
670
+
671
+ if (!hasValidFragmentHeader(fragment)) {
672
+ this.#reject('invalid-fragment-state', 'invalid MFU fragmentation indicator or counter')
673
+ }
674
+ if (fragment.itemId !== undefined && hasAnySampleIdentityField(fragment)) {
675
+ this.#reject('conflicting-data-unit-identity', 'MFU carries timed and non-timed DU identities')
676
+ }
677
+
678
+ const { mpuSequenceNumber, fragmentationIndicator } = fragment
679
+
680
+ // A complete FT=2 data unit still requires its intrinsic DU identity.
681
+ // The transport parser strips the deterministic timed/non-timed DU header
682
+ // before calling this layer; direct callers must provide the same identity.
683
+ // Don't check or update #emitted: in MMTP over MoQ, multiple complete MFUs
684
+ // share the same mpuSequenceNumber (all frames in a GOP have the same mpuSeq).
685
+ // Blocking FI=0 would prevent keyframe fragment assembly at the same mpuSeq.
686
+ if (fragmentationIndicator === 0) {
687
+ const sampleIdentity = hasSampleIdentity(fragment)
688
+ const hasSampleFields = hasAnySampleIdentityField(fragment)
689
+ const hasItemIdentity = fragment.itemId !== undefined
690
+ if (!sampleIdentity && !hasItemIdentity) {
691
+ this.#reject('missing-data-unit-identity', 'complete MFU has no intrinsic DU identity')
692
+ }
693
+ if (hasSampleFields && !sampleIdentity) {
694
+ this.#reject('missing-data-unit-identity', 'complete MFU has an incomplete sample identity')
695
+ }
696
+ if (hasItemIdentity && !isU32(fragment.itemId)) {
697
+ this.#reject('missing-data-unit-identity', 'complete MFU has an invalid Item_ID')
698
+ }
699
+ this.stats.reassembledMFUs++
700
+ return {
701
+ mpuSequenceNumber,
702
+ packetSequenceNumber: fragment.packetSequenceNumber,
703
+ ...assembledPacketSequences([fragment]),
704
+ data: fragment.data,
705
+ timestamp: fragment.timestamp,
706
+ fragmentCount: 1,
707
+ rapFlag: fragment.rapFlag,
708
+ priority: fragment.priority,
709
+ sampleNumber: fragment.sampleNumber,
710
+ movieFragmentSequenceNumber: fragment.movieFragmentSequenceNumber,
711
+ sampleOffset: fragment.sampleOffset,
712
+ source: fragment.source,
713
+ deliveryProvenance: deliveryProvenanceOf([fragment]),
714
+ catalogTrackName: fragment.catalogTrackName,
715
+ }
716
+ }
717
+
718
+ const sequence = fragment.packetSequenceNumber
719
+ if (!isU32(sequence)) {
720
+ this.#reject('missing-packet-sequence', 'fragmented MFU has no MMTP packet sequence number')
721
+ }
722
+ const address = dataUnitAddress(fragment, sequence)
723
+
724
+ if (fragmentationIndicator !== 1) {
725
+ // Attribution is an exact per-data-unit lookup. The continuation joins
726
+ // the one open run holding its address when its counter is that run's
727
+ // next. It is stale when the run has already counted past the counter,
728
+ // or when the data unit was already emitted: the position was accepted
729
+ // once, so no run can ever take it again. Otherwise (no open run at this
730
+ // address, or the run has not reached the counter yet) it parks under
731
+ // its own address. Other runs' counters are never consulted.
732
+ const open = this.#openRunAt(address)
733
+ if (open === undefined) {
734
+ if (this.#completed.has(address)) {
735
+ this.#reject('fragment-counter-mismatch', 'MFU continuation names a data unit that already completed')
736
+ }
737
+ this.#parkContinuation(address, fragment)
738
+ return null
739
+ }
740
+ const [runKey, active] = open
741
+ if (active.nextFragmentCounter > fragment.fragmentCounter) {
742
+ this.#parkContinuation(address, fragment)
743
+ return null
744
+ }
745
+ if (active.nextFragmentCounter < fragment.fragmentCounter) {
746
+ this.#reject('fragment-counter-mismatch', 'MFU continuation is behind the authored fragment-counter countdown')
747
+ }
748
+ const result = this.#addAssociatedFragment(fragment, runKey, address)
749
+ if (result !== null) return result
750
+ return this.#drainPendingContinuations(address, runKey)
751
+ }
752
+
753
+ const runKey = objectKey(fragment)
754
+ if ((hasSampleIdentity(fragment) || isU32(fragment.itemId)) && this.#emitted.has(runKey)) {
755
+ // The identity already emitted, so this first fragment is a duplicate
756
+ // and is dropped. Queues under other addresses belong to other data
757
+ // units and are untouched.
758
+ return null
759
+ }
760
+ if (this.#activeDataUnits.has(runKey)) {
761
+ this.#reject('overlapping-data-unit', 'fragmented MFU overlaps an active data unit')
762
+ }
763
+ // Two heads with one address are one data unit delivered twice or an
764
+ // identity clash on the wire. The established run keeps the address.
765
+ if (this.#openRunAt(address) !== undefined) {
766
+ this.#reject('overlapping-data-unit', 'fragmented MFU opens on an address held by an open data unit')
767
+ }
768
+ const pending = this.#pendingContinuations.get(address)
769
+ // A parked continuation of this data unit cannot carry a counter at or
770
+ // above its own head's countdown.
771
+ if (pending !== undefined && [...pending.fragments.keys()].some(counter => counter >= fragment.fragmentCounter)) {
772
+ this.#reject(
773
+ 'fragment-counter-mismatch',
774
+ 'queued continuation counter is not below the authored first-fragment counter'
775
+ )
776
+ }
777
+
778
+ // The run's deadline starts at the earliest arrival of its own fragments.
779
+ const result = this.#addAssociatedFragment(fragment, runKey, address, pending?.startedAt)
780
+ this.#clearPendingTimeout(address)
781
+ if (result !== null) return result
782
+ return this.#drainPendingContinuations(address, runKey)
783
+ }
784
+
785
+ #addAssociatedFragment(
786
+ fragment: MFUFragment,
787
+ runKey: string,
788
+ address: string,
789
+ startedAt?: number
790
+ ): ReassembledMFU | null {
791
+ const association = this.#associateDataUnit(fragment, runKey, address)
792
+ const { bufferKey, first, terminal } = association
793
+
794
+ let fragments = this.#buffers.get(bufferKey)
795
+ if (first) {
796
+ if (fragments !== undefined) {
797
+ this.#reject('overlapping-data-unit', 'intrinsic data-unit buffer already exists')
798
+ }
799
+ fragments = []
800
+ this.#buffers.set(bufferKey, fragments)
801
+ } else if (fragments === undefined) {
802
+ throw new Error('[MFUReassembler] active data unit has no fragment buffer')
803
+ }
804
+
805
+ // A lossy-path deadline is measured from the first received fragment,
806
+ // including a continuation that arrived before FI=1. Reliable paths are
807
+ // clock-free and never install this timer.
808
+ if (first) this.#setTimeout(bufferKey, startedAt)
809
+ fragments.push(fragment)
810
+
811
+ if (!terminal) return null
812
+ const result = this.#tryReassemble(bufferKey)
813
+ this.#activeDataUnits.delete(runKey)
814
+ if (result === null) {
815
+ this.#clearTimeout(bufferKey)
816
+ this.#buffers.delete(bufferKey)
817
+ this.#reject('incomplete-terminal-fragment', 'validated terminal fragment did not complete its data unit')
818
+ }
819
+ this.#markCompleted(address)
820
+ return result
821
+ }
822
+
823
+ #parkContinuation(address: string, fragment: MFUFragment): void {
824
+ let pending = this.#pendingContinuations.get(address)
825
+ const open = this.#openRunAt(address)
826
+ // A data unit is charged one slot: through its open run, or through its
827
+ // queue while no run is open. Parking under an address that is already
828
+ // charged adds nothing to the bound, so admission is judged by what this
829
+ // fragment would add, not by the total alone. The queue half routes through
830
+ // `parkedRunHoldsASlot` rather than restating it, so this gate cannot drift
831
+ // from the sites that count and evict by it: an empty queue charges nothing
832
+ // here for the same reason it is offered nothing there. Gating on
833
+ // `pending === undefined` instead would skip the bound entirely for a queue
834
+ // that exists but holds nothing, overshooting `maxBuffered` by one while
835
+ // Rust evicts — the cross-engine split BLO-31813 exists to close.
836
+ const alreadyCharged =
837
+ open !== undefined || parkedRunHoldsASlot(open !== undefined, pending?.fragments.size ?? 0)
838
+ if (!alreadyCharged && this.#incompleteRunCount() >= this.#maxBuffered) {
839
+ // `open` and `pending` are captured above, and eviction can delete from
840
+ // `#pendingContinuations` — so both are read across a mutation. Neither
841
+ // goes stale, and the predicate is what makes that true: eviction only
842
+ // runs when `open === undefined`, and an empty queue is not a candidate,
843
+ // so the record the `else if` below writes to cannot be the one released
844
+ // here. Re-read both if that rule is ever loosened.
845
+ if (!this.#evictLeastRecentlyAdmitted()) {
846
+ this.#reject('active-data-unit-limit', 'active MFU data-unit limit reached')
847
+ }
848
+ }
849
+ // Both branches below leave the timer to `#armHeadlessPendingTimeout`,
850
+ // called once the fragment is in, so the queue is armed only while it
851
+ // holds a data unit and through the same slot rule as every other site.
852
+ let armAfterInsert = false
853
+ if (pending === undefined) {
854
+ // A queue opened under an already-charged address inherits that slot's
855
+ // ordinal, so a run does not renew its admission by parking a fragment.
856
+ // The arm below is a no-op under an open run: the run's own deadline
857
+ // covers the data unit, and `#armHeadlessPendingTimeout` tests for it.
858
+ pending = {
859
+ fragments: new Map(),
860
+ startedAt: Date.now(),
861
+ admissionSeq: open !== undefined ? open[1].admissionSeq : this.#nextAdmissionSeq++,
862
+ }
863
+ this.#pendingContinuations.set(address, pending)
864
+ armAfterInsert = true
865
+ } else if (!alreadyCharged) {
866
+ // A queue that exists but holds nothing charged no slot, so this fragment
867
+ // is a fresh admission — and every per-slot fact the record still carries
868
+ // describes an occupancy that ended when its last entry left. Rust derives
869
+ // all of them from the queue's *live* entries, so re-parking moves them
870
+ // together; TypeScript caches them as fields written once at creation, so
871
+ // they have to be reset together here or the queue goes transparent to the
872
+ // bound while staying stale on its deadline:
873
+ //
874
+ // - Ordinal. `slot_admission_seq` mins over the entries and an empty queue
875
+ // has none, so Rust mints a fresh one. Decides victim ordering.
876
+ // - Retention anchor. `cleanup_timeouts` retains on `min(received_at)`,
877
+ // which a re-parked entry moves forward. Left stale, `#onPendingTimeout`
878
+ // can discard the queue on the emptied queue's deadline while it holds a
879
+ // fragment that was just charged a slot — and that may have evicted
880
+ // another data unit to get it.
881
+ // - Armed timer. `#armHeadlessPendingTimeout` anchors on `startedAt` and
882
+ // arms only a queue that holds a fragment, so the re-arm runs once this
883
+ // fragment is in (below), where the creation branch arms too, rather
884
+ // than here, where the queue is still empty and the call would arm
885
+ // nothing. Any timer left on the record is cleared first so the queue
886
+ // cannot keep running on the old anchor.
887
+ //
888
+ // `Date.now()` here is the same retention anchor the queue-creation branch
889
+ // above already reads, not a clock in the admission order: the ordinal
890
+ // stays a monotonic sequence, so eviction remains clock-free per PR #93.
891
+ //
892
+ // Unreachable while every drain exit discards an emptied queue; pinned so
893
+ // the engines cannot pick different victims — or different expiries — if
894
+ // one ever misses a discard site.
895
+ pending.admissionSeq = this.#nextAdmissionSeq++
896
+ pending.startedAt = Date.now()
897
+ this.#clearPendingTimeout(address)
898
+ armAfterInsert = true
899
+ }
900
+ // The same data unit naming the same countdown position twice: the first
901
+ // parked copy is kept.
902
+ if (pending.fragments.has(fragment.fragmentCounter)) {
903
+ this.#reject('fragment-counter-mismatch', 'duplicate queued continuation counter would replace retained media')
904
+ }
905
+ pending.fragments.set(fragment.fragmentCounter, fragment)
906
+ // A created or re-anchored queue holds its first fragment only now, so this
907
+ // is the first point at which `#armHeadlessPendingTimeout` arms it. With
908
+ // `startedAt` set one tick earlier the deadline it computes is the full
909
+ // `timeoutMs`, the same schedule a raw timer armed at creation would have
910
+ // kept. The duplicate check above cannot reject on this path: both branches
911
+ // that set the flag run exactly when the queue is empty.
912
+ if (armAfterInsert) this.#armHeadlessPendingTimeout(address)
913
+ }
914
+
915
+ #drainPendingContinuations(address: string, runKey: string): ReassembledMFU | null {
916
+ while (true) {
917
+ const active = this.#activeDataUnits.get(runKey)
918
+ const pending = this.#pendingContinuations.get(address)
919
+ if (active === undefined) {
920
+ // The run closed. Its queue holds only this data unit's fragments;
921
+ // drop it once empty so it is not counted as a headless data unit.
922
+ if (pending?.fragments.size === 0) this.#deletePendingRun(address)
923
+ return null
924
+ }
925
+ const fragment = pending?.fragments.get(active.nextFragmentCounter)
926
+ if (fragment === undefined) {
927
+ if (pending?.fragments.size === 0) this.#deletePendingRun(address)
928
+ return null
929
+ }
930
+ pending!.fragments.delete(active.nextFragmentCounter)
931
+ if (pending!.fragments.size === 0) this.#deletePendingRun(address)
932
+ const result = this.#addAssociatedFragment(fragment, runKey, address)
933
+ if (result !== null) {
934
+ return result
935
+ }
936
+ }
937
+ }
938
+
939
+ /** Parked data units that hold a `maxBuffered` slot of their own. Membership
940
+ * is `parkedRunHoldsASlot`, the same rule #evictLeastRecentlyAdmitted offers
941
+ * as candidates, so nothing can be counted here that eviction cannot reclaim.
942
+ *
943
+ * One site, two callers — `#incompleteRunCount` and `pendingCount` — mirroring
944
+ * Rust's `unopened_parked_run_count`, which `incomplete_run_count` and
945
+ * `buffered_count` both delegate to. Both callers used to inline this loop
946
+ * verbatim, so converging the *rule* moved one copy and left the other as the
947
+ * divergence (Ally's review of #400). The rule can no longer drift now that
948
+ * every site routes through `parkedRunHoldsASlot`; keeping the loop itself at
949
+ * one site is what stops the scaffolding drifting the next time this question
950
+ * grows an argument. */
951
+ #unopenedParkedRunCount(): number {
952
+ let count = 0
953
+ for (const [address, pending] of this.#pendingContinuations) {
954
+ if (parkedRunHoldsASlot(this.#openRunAt(address) !== undefined, pending.fragments.size)) {
955
+ count++
956
+ }
957
+ }
958
+ return count
959
+ }
960
+
961
+ /** Every open run plus every headless parked data unit that holds a slot,
962
+ * one slot each. This is the quantity maxBuffered bounds. The parked term is
963
+ * `#unopenedParkedRunCount`, whose membership is `parkedRunHoldsASlot` — the
964
+ * same rule #evictLeastRecentlyAdmitted offers as candidates, so nothing can
965
+ * be counted here that eviction cannot reclaim. */
966
+ #incompleteRunCount(): number {
967
+ return this.#activeDataUnits.size + this.#unopenedParkedRunCount()
968
+ }
969
+
970
+ /**
971
+ * Drop the incomplete data unit admitted longest ago so a newly arriving one
972
+ * can take its slot. Selection is by monotonic admission ordinal, never by
973
+ * wall clock, so this is a bounded-capacity replacement policy and not a
974
+ * timeout: it behaves identically on hosts that never drive `cleanup_timeouts`
975
+ * (`timeoutMs: null`). Only incomplete data units are candidates — a data
976
+ * unit that completed has already left both maps.
977
+ *
978
+ * Returns false only if no slot is charged, which cannot happen at the bound
979
+ * (`maxBuffered` is a positive integer); admission then falls back to the
980
+ * fail-closed `active-data-unit-limit` reject, which stays a stable host code.
981
+ * That holds because the candidate set here is built from the same
982
+ * `parkedRunHoldsASlot` rule `#incompleteRunCount` counts by, so reaching the
983
+ * bound implies at least one candidate. Filtering one and not the other would
984
+ * make the claim false: a queue counted but not offered charges a slot no
985
+ * eviction can reclaim, and the receiver refuses every later data unit
986
+ * permanently.
987
+ */
988
+ #evictLeastRecentlyAdmitted(): boolean {
989
+ let victimAddress: string | undefined
990
+ let victimSeq = Number.POSITIVE_INFINITY
991
+ for (const [, active] of this.#activeDataUnits) {
992
+ if (active.admissionSeq < victimSeq) {
993
+ victimSeq = active.admissionSeq
994
+ victimAddress = active.address
995
+ }
996
+ }
997
+ for (const [address, pending] of this.#pendingContinuations) {
998
+ // Exactly the queues #incompleteRunCount charges a slot to: a queue under
999
+ // an open run is that run's slot, already considered above, and an empty
1000
+ // queue holds nothing to evict.
1001
+ if (!parkedRunHoldsASlot(this.#openRunAt(address) !== undefined, pending.fragments.size)) {
1002
+ continue
1003
+ }
1004
+ if (pending.admissionSeq < victimSeq) {
1005
+ victimSeq = pending.admissionSeq
1006
+ victimAddress = address
1007
+ }
1008
+ }
1009
+ if (victimAddress === undefined) return false
1010
+ this.#releaseSlot(victimAddress)
1011
+ this.stats.evictedMemoryLimit++
1012
+ return true
1013
+ }
1014
+
1015
+ /**
1016
+ * Release every form of state one address holds: its open run, that run's
1017
+ * buffered fragments and deadline, and its continuation queue. The address is
1018
+ * deliberately not marked completed — the data unit never emitted, so a later
1019
+ * fragment of it may open a fresh slot rather than being rejected as stale.
1020
+ */
1021
+ #releaseSlot(address: string): void {
1022
+ const open = this.#openRunAt(address)
1023
+ if (open !== undefined) {
1024
+ const [runKey, active] = open
1025
+ this.#activeDataUnits.delete(runKey)
1026
+ this.#clearTimeout(active.bufferKey)
1027
+ this.#buffers.delete(active.bufferKey)
1028
+ }
1029
+ this.#deletePendingRun(address)
1030
+ }
1031
+
1032
+ #reject(code: MfuReassemblyErrorCode, message: string): never {
1033
+ this.stats.rejectedFragments++
1034
+ throw new MfuReassemblyError(code, message)
1035
+ }
1036
+
1037
+ #associateDataUnit(fragment: MFUFragment, runKey: string, address: string): DataUnitAssociation {
1038
+ const fi = fragment.fragmentationIndicator
1039
+ if (fi === 1) {
1040
+ // Opening a run is free when this data unit already holds a parked
1041
+ // slot: the run inherits it, so the bound does not move.
1042
+ //
1043
+ // A second head on an open address is refused upstream, so no run is open
1044
+ // here. Read that live rather than assume it, and route the answer through
1045
+ // `parkedRunHoldsASlot`, so this gate is the fourth site of one rule
1046
+ // instead of a fourth restatement of it. `parked` is non-null wherever
1047
+ // this is true, because the rule requires a non-empty queue.
1048
+ const parked = this.#pendingContinuations.get(address)
1049
+ const inheritsParkedSlot = parkedRunHoldsASlot(
1050
+ this.#openRunAt(address) !== undefined,
1051
+ parked?.fragments.size ?? 0,
1052
+ )
1053
+
1054
+ // Resolve identity and byte extent BEFORE taking a slot. Eviction
1055
+ // replaces a healthy data unit, so it must only ever run for a fragment
1056
+ // that is going to be admitted -- a reject reached after eviction would
1057
+ // destroy valid state and break the contract these rejects exist to
1058
+ // uphold ("without advancing or replacing valid state"). addFragment
1059
+ // pre-validates only the FI/counter pair and the itemId/sample-field
1060
+ // conflict, so every reject below is reachable at the bound.
1061
+ const sampleIdentity = hasSampleIdentity(fragment)
1062
+ const hasSampleFields = hasAnySampleIdentityField(fragment)
1063
+ const hasItemIdentity = fragment.itemId !== undefined
1064
+ if (sampleIdentity && hasItemIdentity) {
1065
+ this.#reject('conflicting-data-unit-identity', 'fragmented MFU carries timed and non-timed DU identities')
1066
+ }
1067
+ if (!sampleIdentity && !hasItemIdentity) {
1068
+ this.#reject('missing-data-unit-identity', 'first fragment has no intrinsic DU identity')
1069
+ }
1070
+ if (hasSampleFields && !sampleIdentity) {
1071
+ this.#reject('missing-data-unit-identity', 'first fragment has an incomplete sample identity')
1072
+ }
1073
+ if (hasItemIdentity && !isU32(fragment.itemId)) {
1074
+ this.#reject('missing-data-unit-identity', 'first fragment has an invalid Item_ID')
1075
+ }
1076
+
1077
+ const bufferKey = objectKey(fragment)
1078
+ if (this.#buffers.has(bufferKey)) {
1079
+ this.#reject('overlapping-data-unit', 'intrinsic data-unit buffer already exists')
1080
+ }
1081
+
1082
+ let sampleEnd: number | null = null
1083
+ if (sampleIdentity) {
1084
+ sampleEnd = checkedSampleEnd(fragment.sampleOffset, fragment.data.byteLength)
1085
+ if (sampleEnd === null) this.#reject('offset-overflow', 'sample byte offset overflow')
1086
+ }
1087
+
1088
+ // The fragment is admissible from here on, so charging it a slot cannot
1089
+ // be undone by a later reject. A second head on an open address was
1090
+ // already refused upstream, so this address holds no run and can never
1091
+ // be its own eviction victim.
1092
+ if (!inheritsParkedSlot && this.#incompleteRunCount() >= this.#maxBuffered) {
1093
+ if (!this.#evictLeastRecentlyAdmitted()) {
1094
+ this.#reject('active-data-unit-limit', 'active MFU data-unit limit reached')
1095
+ }
1096
+ }
1097
+ // An inherited slot keeps the ordinal it was admitted with: the head
1098
+ // arriving late does not make the data unit younger than one parked
1099
+ // after it.
1100
+ const admissionSeq = inheritsParkedSlot ? parked!.admissionSeq : this.#nextAdmissionSeq++
1101
+
1102
+ let active: ActiveDataUnit
1103
+ if (sampleIdentity) {
1104
+ active = {
1105
+ address,
1106
+ kind: 'sample',
1107
+ bufferKey,
1108
+ nextFragmentCounter: fragment.fragmentCounter - 1,
1109
+ admissionSeq,
1110
+ movieFragmentSequenceNumber: fragment.movieFragmentSequenceNumber,
1111
+ sampleNumber: fragment.sampleNumber,
1112
+ nextOffset: sampleEnd!,
1113
+ ...(fragment.catalogTrackName === undefined ? {} : { catalogTrackName: fragment.catalogTrackName }),
1114
+ }
1115
+ } else {
1116
+ active = {
1117
+ address,
1118
+ kind: 'item',
1119
+ bufferKey,
1120
+ nextFragmentCounter: fragment.fragmentCounter - 1,
1121
+ admissionSeq,
1122
+ itemId: fragment.itemId!,
1123
+ ...(fragment.catalogTrackName === undefined ? {} : { catalogTrackName: fragment.catalogTrackName }),
1124
+ }
1125
+ }
1126
+ this.#activeDataUnits.set(objectKey(fragment), active)
1127
+ return { bufferKey, first: true, terminal: false }
1128
+ }
1129
+
1130
+ const active = this.#activeDataUnits.get(runKey)
1131
+ if (active === undefined) {
1132
+ throw new Error('[MFUReassembler] continuation was not parked before association')
1133
+ }
1134
+ if (fragment.fragmentCounter !== active.nextFragmentCounter) {
1135
+ this.#reject(
1136
+ 'fragment-counter-mismatch',
1137
+ 'MFU continuation does not follow the authored fragment-counter countdown'
1138
+ )
1139
+ }
1140
+
1141
+ if (active.kind === 'sample') {
1142
+ if (fragment.itemId !== undefined) {
1143
+ this.#reject('data-unit-identity-mismatch', 'sample continuation carries an Item_ID')
1144
+ }
1145
+ if (
1146
+ hasAnySampleIdentityField(fragment) &&
1147
+ (!hasSampleIdentity(fragment) ||
1148
+ fragment.movieFragmentSequenceNumber !== active.movieFragmentSequenceNumber ||
1149
+ fragment.sampleNumber !== active.sampleNumber ||
1150
+ fragment.sampleOffset !== active.nextOffset)
1151
+ ) {
1152
+ this.#reject('data-unit-identity-mismatch', 'sample continuation identity does not match')
1153
+ }
1154
+ const nextOffset = checkedSampleEnd(active.nextOffset, fragment.data.byteLength)
1155
+ if (nextOffset === null) this.#reject('offset-overflow', 'sample byte offset overflow')
1156
+ fragment.movieFragmentSequenceNumber = active.movieFragmentSequenceNumber
1157
+ fragment.sampleNumber = active.sampleNumber
1158
+ fragment.sampleOffset = active.nextOffset
1159
+ if (fi === 2) {
1160
+ active.nextOffset = nextOffset
1161
+ active.nextFragmentCounter = fragment.fragmentCounter - 1
1162
+ }
1163
+ } else {
1164
+ if (hasAnySampleIdentityField(fragment) || (fragment.itemId !== undefined && fragment.itemId !== active.itemId)) {
1165
+ this.#reject('data-unit-identity-mismatch', 'Item_ID continuation identity does not match')
1166
+ }
1167
+ fragment.itemId = active.itemId
1168
+ if (fi === 2) active.nextFragmentCounter = fragment.fragmentCounter - 1
1169
+ }
1170
+
1171
+ return {
1172
+ bufferKey: active.bufferKey,
1173
+ first: false,
1174
+ terminal: fi === 3,
1175
+ }
1176
+ }
1177
+
1178
+ #tryReassemble(bufferKey: string): ReassembledMFU | null {
1179
+ const fragments = this.#buffers.get(bufferKey)
1180
+ if (!fragments || fragments.length === 0) {
1181
+ return null
1182
+ }
1183
+
1184
+ const sorted = [...fragments].sort((a, b) => this.#orderValue(a) - this.#orderValue(b))
1185
+
1186
+ if (sorted[0]!.fragmentationIndicator !== 1 || sorted[sorted.length - 1]!.fragmentationIndicator !== 3) {
1187
+ return null
1188
+ }
1189
+ if (sorted.slice(1, -1).some(fragment => fragment.fragmentationIndicator !== 2)) {
1190
+ return null
1191
+ }
1192
+
1193
+ // ISO fragment_counter is the number of succeeding payloads. After
1194
+ // normalization (`255 - counter`), a complete non-wrapping chain is strictly
1195
+ // consecutive. This also caps a data unit at 256 payloads.
1196
+ for (let i = 1; i < sorted.length; i++) {
1197
+ if (this.#orderValue(sorted[i]!) !== this.#orderValue(sorted[i - 1]!) + 1) {
1198
+ return null
1199
+ }
1200
+ }
1201
+
1202
+ // When every fragment carries a timed-MFU byte offset, independently prove
1203
+ // byte adjacency within this data unit. Counter order remains authoritative;
1204
+ // conflicting offsets fail closed instead of changing the fragment order.
1205
+ // sorted[0]'s offset is this DU's authored base (moqcast #37: zero for a
1206
+ // whole-sample DU, nonzero for one subsample DU of a partitioned sample) --
1207
+ // never asserted against a literal zero.
1208
+ if (ordersByOffset(sorted)) {
1209
+ for (let i = 1; i < sorted.length; i++) {
1210
+ const previous = sorted[i - 1]!
1211
+ if (sorted[i]!.sampleOffset !== previous.sampleOffset! + previous.data.byteLength) {
1212
+ return null
1213
+ }
1214
+ }
1215
+ }
1216
+
1217
+ fragments.splice(0, fragments.length, ...sorted)
1218
+ return this.#assemble(bufferKey, fragments)
1219
+ }
1220
+
1221
+ #assemble(bufferKey: string, fragments: MFUFragment[]): ReassembledMFU {
1222
+ // Reassemble: concatenate all fragment data
1223
+ const totalSize = fragments.reduce((sum, f) => sum + f.data.length, 0)
1224
+ const data = new Uint8Array(totalSize)
1225
+
1226
+ let offset = 0
1227
+ for (const fragment of fragments) {
1228
+ data.set(fragment.data, offset)
1229
+ offset += fragment.data.length
1230
+ }
1231
+
1232
+ // Cleanup + mark the intrinsic identity as emitted to prevent a recovered
1233
+ // duplicate from starting another fragmented run.
1234
+ this.#clearTimeout(bufferKey)
1235
+ this.#buffers.delete(bufferKey)
1236
+ this.#markEmitted(bufferKey)
1237
+ this.stats.reassembledMFUs++
1238
+
1239
+ return {
1240
+ mpuSequenceNumber: fragments[0]!.mpuSequenceNumber,
1241
+ packetSequenceNumber: fragments[0]!.packetSequenceNumber,
1242
+ ...assembledPacketSequences(fragments),
1243
+ data,
1244
+ timestamp: fragments[0]!.timestamp,
1245
+ fragmentCount: fragments.length,
1246
+ rapFlag: fragments[0]!.rapFlag,
1247
+ priority: fragments.find(f => f.priority !== undefined)?.priority,
1248
+ sampleNumber: fragments[0]!.sampleNumber,
1249
+ movieFragmentSequenceNumber: fragments[0]!.movieFragmentSequenceNumber,
1250
+ sampleOffset: fragments[0]!.sampleOffset,
1251
+ source: fragments[0]!.source,
1252
+ deliveryProvenance: deliveryProvenanceOf(fragments),
1253
+ catalogTrackName: fragments[0]!.catalogTrackName,
1254
+ }
1255
+ }
1256
+
1257
+ #setTimeout(bufferKey: string, startedAt = Date.now()): void {
1258
+ const state: BufferTimeoutState = { startedAt }
1259
+ if (this.#timeoutMs !== null) {
1260
+ state.timeoutId = setTimeout(
1261
+ () => this.#onTimeout(bufferKey),
1262
+ Math.max(0, startedAt + this.#timeoutMs - Date.now())
1263
+ )
1264
+ }
1265
+ this.#timeouts.set(bufferKey, state)
1266
+ }
1267
+
1268
+ #clearPendingTimeout(address: string): void {
1269
+ const pending = this.#pendingContinuations.get(address)
1270
+ if (pending?.timeoutId !== undefined) clearTimeout(pending.timeoutId)
1271
+ if (pending !== undefined) {
1272
+ delete pending.timeoutId
1273
+ }
1274
+ }
1275
+
1276
+ /**
1277
+ * A queue whose run closed without completing is headless again and expires
1278
+ * from its own first arrival, exactly as it would had the head never come.
1279
+ *
1280
+ * An empty queue is not armed: it holds no data unit that could fail to
1281
+ * complete, so there is nothing for a deadline to expire. Every arming site
1282
+ * reaches this function, which takes that test through `parkedRunHoldsASlot`
1283
+ * rather than filtering on the open run alone, which is how this engine
1284
+ * reaches Rust's answer: its
1285
+ * `next_timeout_at` derives candidate deadlines from the queue's live
1286
+ * entries, so an empty queue yields none (BLO-32420).
1287
+ */
1288
+ #armHeadlessPendingTimeout(address: string): void {
1289
+ const pending = this.#pendingContinuations.get(address)
1290
+ if (pending === undefined || pending.timeoutId !== undefined || this.#timeoutMs === null) return
1291
+ if (!parkedRunHoldsASlot(this.#openRunAt(address) !== undefined, pending.fragments.size)) return
1292
+ pending.timeoutId = setTimeout(
1293
+ () => this.#onPendingTimeout(address),
1294
+ Math.max(0, pending.startedAt + this.#timeoutMs - Date.now())
1295
+ )
1296
+ }
1297
+
1298
+ #deletePendingRun(address: string): void {
1299
+ this.#clearPendingTimeout(address)
1300
+ this.#pendingContinuations.delete(address)
1301
+ }
1302
+
1303
+ #onPendingTimeout(address: string): void {
1304
+ const pending = this.#pendingContinuations.get(address)
1305
+ if (pending === undefined) return
1306
+ // The charging site takes the same slot rule as the arming sites, evaluated
1307
+ // live at expiry the way Rust's `cleanup_timeouts` evaluates it at sweep
1308
+ // time: a queue that holds no slot is retained rather than charged
1309
+ // `record_policy_timeout`. That makes "an empty queue is never charged a
1310
+ // timeout" a property of this method, not of arming order. The fired timer
1311
+ // is released so a later re-park can arm the queue again.
1312
+ if (!parkedRunHoldsASlot(this.#openRunAt(address) !== undefined, pending.fragments.size)) {
1313
+ this.#clearPendingTimeout(address)
1314
+ return
1315
+ }
1316
+ this.stats.timedOutMFUs++
1317
+ console.warn(
1318
+ `[MfuReassembler] timeout: run=${address} fragments=${pending.fragments.size} ` +
1319
+ '(continuations awaiting first fragment, discarded)'
1320
+ )
1321
+ this.#pendingContinuations.delete(address)
1322
+ }
1323
+
1324
+ #clearTimeout(bufferKey: string): void {
1325
+ const state = this.#timeouts.get(bufferKey)
1326
+ if (state !== undefined) {
1327
+ if (state.timeoutId !== undefined) clearTimeout(state.timeoutId)
1328
+ this.#timeouts.delete(bufferKey)
1329
+ }
1330
+ }
1331
+
1332
+ /** Callback for deferred assembly results (set by caller for async frame delivery) */
1333
+ onDeferredAssembly?: (result: ReassembledMFU) => void
1334
+
1335
+ #markEmitted(bufferKey: string): void {
1336
+ if (this.#emitted.has(bufferKey)) return
1337
+ this.#emitted.add(bufferKey)
1338
+ this.#emittedOrder.push(bufferKey)
1339
+ while (this.#emittedOrder.length > this.#maxBuffered) {
1340
+ const oldest = this.#emittedOrder.shift()
1341
+ if (oldest !== undefined) this.#emitted.delete(oldest)
1342
+ }
1343
+ }
1344
+
1345
+ #markCompleted(address: string): void {
1346
+ if (this.#completed.has(address)) return
1347
+ this.#completed.add(address)
1348
+ this.#completedOrder.push(address)
1349
+ const cap = this.#maxBuffered * COMPLETED_ADDRESS_WINDOW_MULTIPLE
1350
+ while (this.#completedOrder.length > cap) {
1351
+ const oldest = this.#completedOrder.shift()
1352
+ if (oldest !== undefined) this.#completed.delete(oldest)
1353
+ }
1354
+ }
1355
+
1356
+ #orderValue(fragment: MFUFragment): number {
1357
+ return fragmentCounterOrder(fragment)
1358
+ }
1359
+
1360
+ /** Close the run behind an expired buffer. Early arrivals parked under its
1361
+ * address stay, now headless, and expire from their own arrival. */
1362
+ #removeActiveDataUnitForBuffer(bufferKey: string): void {
1363
+ for (const [objectKey, active] of this.#activeDataUnits) {
1364
+ if (active.bufferKey === bufferKey) {
1365
+ this.#activeDataUnits.delete(objectKey)
1366
+ this.#armHeadlessPendingTimeout(active.address)
1367
+ }
1368
+ }
1369
+ }
1370
+
1371
+ /** The one open run holding this address, with its object key. Admission
1372
+ * rejects a second head on an open address, so at most one run can match. */
1373
+ #openRunAt(address: string): readonly [string, ActiveDataUnit] | undefined {
1374
+ for (const entry of this.#activeDataUnits) {
1375
+ if (entry[1].address === address) return entry
1376
+ }
1377
+ return undefined
1378
+ }
1379
+
1380
+ #onTimeout(bufferKey: string): void {
1381
+ const fragments = this.#buffers.get(bufferKey)
1382
+ if (!fragments || fragments.length === 0) {
1383
+ this.#buffers.delete(bufferKey)
1384
+ this.#timeouts.delete(bufferKey)
1385
+ this.#removeActiveDataUnitForBuffer(bufferKey)
1386
+ this.stats.timedOutMFUs++
1387
+ console.warn(`[MfuReassembler] timeout: key=${bufferKey} fragments=0 (no data buffered)`)
1388
+ return
1389
+ }
1390
+
1391
+ // Discard partial MFUs — incomplete AVCC data crashes WebCodecs with
1392
+ // "Unable to determine size of bitstream buffer". FEC recovery handles
1393
+ // loss; partial assembly only produces garbage frames.
1394
+ this.stats.timedOutMFUs++
1395
+ console.warn(`[MfuReassembler] timeout: key=${bufferKey} fragments=${fragments.length} (partial, discarded)`)
1396
+
1397
+ this.#buffers.delete(bufferKey)
1398
+ this.#timeouts.delete(bufferKey)
1399
+ this.#removeActiveDataUnitForBuffer(bufferKey)
1400
+ }
1401
+
1402
+ /**
1403
+ * Flush all buffered fragments
1404
+ */
1405
+ flush(): void {
1406
+ for (const key of this.#buffers.keys()) {
1407
+ this.#clearTimeout(key)
1408
+ }
1409
+ this.#buffers.clear()
1410
+ this.#timeouts.clear()
1411
+ this.#activeDataUnits.clear()
1412
+ for (const address of this.#pendingContinuations.keys()) {
1413
+ this.#clearPendingTimeout(address)
1414
+ }
1415
+ this.#pendingContinuations.clear()
1416
+ this.#emitted.clear()
1417
+ this.#emittedOrder.length = 0
1418
+ this.#completed.clear()
1419
+ this.#completedOrder.length = 0
1420
+ }
1421
+
1422
+ /** Drop one catalog stream's incomplete and dedup state. */
1423
+ resetStream(catalogTrackName: string): void {
1424
+ const belongsToStream = (key: string): boolean => {
1425
+ try {
1426
+ const decoded = JSON.parse(key)
1427
+ return Array.isArray(decoded) && decoded[0] === 'catalog' && decoded[1] === catalogTrackName
1428
+ } catch {
1429
+ return false
1430
+ }
1431
+ }
1432
+ const discarded = new Set<string>()
1433
+ for (const [key, fragments] of this.#buffers) {
1434
+ if (fragments.some(fragment => fragment.catalogTrackName === catalogTrackName)) {
1435
+ discarded.add(key)
1436
+ }
1437
+ }
1438
+ for (const [objectKey, active] of this.#activeDataUnits) {
1439
+ if (active.catalogTrackName === catalogTrackName) {
1440
+ discarded.add(active.bufferKey)
1441
+ this.#activeDataUnits.delete(objectKey)
1442
+ }
1443
+ }
1444
+ for (const [address, pending] of this.#pendingContinuations) {
1445
+ if (
1446
+ [...pending.fragments.values()].some(fragment => fragment.catalogTrackName === catalogTrackName) ||
1447
+ belongsToStream(address)
1448
+ )
1449
+ this.#deletePendingRun(address)
1450
+ }
1451
+ for (const key of this.#emitted) {
1452
+ if (belongsToStream(key)) discarded.add(key)
1453
+ }
1454
+ for (const key of discarded) {
1455
+ this.#clearTimeout(key)
1456
+ this.#buffers.delete(key)
1457
+ this.#emitted.delete(key)
1458
+ }
1459
+ this.#emittedOrder = this.#emittedOrder.filter(key => !discarded.has(key))
1460
+ for (const address of this.#completed) {
1461
+ if (belongsToStream(address)) this.#completed.delete(address)
1462
+ }
1463
+ this.#completedOrder = this.#completedOrder.filter(address => this.#completed.has(address))
1464
+ }
1465
+
1466
+ /** Reset all state — flush buffers and zero stats */
1467
+ reset(): void {
1468
+ this.flush()
1469
+ this.stats.totalFragments = 0
1470
+ this.stats.reassembledMFUs = 0
1471
+ this.stats.rejectedFragments = 0
1472
+ this.stats.timedOutMFUs = 0
1473
+ this.stats.evictedMemoryLimit = 0
1474
+ this.stats.outOfOrderFragments = 0
1475
+ }
1476
+
1477
+ /**
1478
+ * Get number of pending MFUs: engine buffers plus every headless parked data
1479
+ * unit that holds a slot.
1480
+ *
1481
+ * This is not one of the four charging sites, but it shares their second term,
1482
+ * so it takes the same rule — by delegating to `#unopenedParkedRunCount`, the
1483
+ * one site that applies it. Rust's public `buffered_count()` is
1484
+ * `inner.buffered_count() + unopened_parked_run_count()`, and that count is
1485
+ * `parked_run_holds_a_slot` — so filtering here on the open run alone would
1486
+ * report a phantom slot for an empty published queue in TypeScript and not in
1487
+ * Rust, out of a *host-observable* counter (`getStatus().bufferedMFUs`). Same
1488
+ * state, two numbers, decided by which engine the host loaded: the divergence
1489
+ * class this file exists to close, so the public counters have to agree too.
1490
+ *
1491
+ * Distinct from `#incompleteRunCount`, which sums `#activeDataUnits` rather
1492
+ * than `#buffers`. Those are different quantities by design and are not being
1493
+ * merged; only the parked term they share is required to agree, which is what
1494
+ * routing both through one method makes structural rather than conventional.
1495
+ */
1496
+ get pendingCount(): number {
1497
+ return this.#buffers.size + this.#unopenedParkedRunCount()
1498
+ }
1499
+
1500
+ /**
1501
+ * Get current buffer status
1502
+ */
1503
+ getStatus(): {
1504
+ bufferedMFUs: number
1505
+ pendingFragments: number
1506
+ pendingSamples: PendingMfuDebug[]
1507
+ stats: ReassemblerStats
1508
+ } {
1509
+ let pendingFragments = 0
1510
+ for (const fragments of this.#buffers.values()) {
1511
+ pendingFragments += fragments.length
1512
+ }
1513
+ for (const pending of this.#pendingContinuations.values()) {
1514
+ pendingFragments += pending.fragments.size
1515
+ }
1516
+
1517
+ return {
1518
+ bufferedMFUs: this.pendingCount,
1519
+ pendingFragments,
1520
+ pendingSamples: this.#pendingDebugSamples(),
1521
+ stats: { ...this.stats },
1522
+ }
1523
+ }
1524
+
1525
+ #pendingDebugSamples(): PendingMfuDebug[] {
1526
+ const out: PendingMfuDebug[] = []
1527
+ for (const [key, fragments] of this.#buffers) {
1528
+ if (fragments.length === 0) continue
1529
+ const sorted = [...fragments].sort((a, b) => this.#orderValue(a) - this.#orderValue(b))
1530
+ const fiCounts = [0, 0, 0, 0]
1531
+ let minCounter = Infinity
1532
+ let maxCounter = -Infinity
1533
+ let minOffset = Infinity
1534
+ let maxOffset = -Infinity
1535
+ for (const fragment of sorted) {
1536
+ fiCounts[fragment.fragmentationIndicator] = (fiCounts[fragment.fragmentationIndicator] ?? 0) + 1
1537
+ minCounter = Math.min(minCounter, fragment.fragmentCounter)
1538
+ maxCounter = Math.max(maxCounter, fragment.fragmentCounter)
1539
+ if (typeof fragment.sampleOffset === 'number') {
1540
+ minOffset = Math.min(minOffset, fragment.sampleOffset)
1541
+ maxOffset = Math.max(maxOffset, fragment.sampleOffset)
1542
+ }
1543
+ }
1544
+ const point = (fragment: MFUFragment) => ({
1545
+ fi: fragment.fragmentationIndicator,
1546
+ counter: fragment.fragmentCounter,
1547
+ order: this.#orderValue(fragment),
1548
+ ...(typeof fragment.packetSequenceNumber === 'number' ? { seq: fragment.packetSequenceNumber } : {}),
1549
+ ...(typeof fragment.sampleOffset === 'number' ? { offset: fragment.sampleOffset } : {}),
1550
+ })
1551
+ const summary: PendingMfuDebug = {
1552
+ key,
1553
+ fragments: sorted.length,
1554
+ fi0: fiCounts[0] ?? 0,
1555
+ fi1: fiCounts[1] ?? 0,
1556
+ fi2: fiCounts[2] ?? 0,
1557
+ fi3: fiCounts[3] ?? 0,
1558
+ firstFi: sorted[0]!.fragmentationIndicator,
1559
+ lastFi: sorted[sorted.length - 1]!.fragmentationIndicator,
1560
+ firstOrder: this.#orderValue(sorted[0]!),
1561
+ lastOrder: this.#orderValue(sorted[sorted.length - 1]!),
1562
+ minCounter,
1563
+ maxCounter,
1564
+ movieFragmentSequenceNumber: sorted.find(f => f.movieFragmentSequenceNumber !== undefined)
1565
+ ?.movieFragmentSequenceNumber,
1566
+ sampleNumber: sorted.find(f => f.sampleNumber !== undefined)?.sampleNumber,
1567
+ mpuSequenceNumber: sorted[0]!.mpuSequenceNumber,
1568
+ sortedHead: sorted.slice(0, 6).map(point),
1569
+ sortedTail: sorted.slice(-6).map(point),
1570
+ }
1571
+ if (minOffset !== Infinity) summary.minOffset = minOffset
1572
+ if (maxOffset !== -Infinity) summary.maxOffset = maxOffset
1573
+ out.push(summary)
1574
+ if (out.length >= 12) break
1575
+ }
1576
+ return out
1577
+ }
1578
+ }
1579
+
1580
+ /**
1581
+ * Create MFU fragment from MMTP packet for reassembly
1582
+ */
1583
+ export function createFragment(mmtpPacket: Uint8Array): MFUFragment {
1584
+ const extraction = extractMFUPayload(mmtpPacket)
1585
+ if (!extraction) throw new Error('Unable to extract MFU payload from MMTP packet')
1586
+ const { header, mpu, data, mfuDuHeader, itemId } = extraction
1587
+
1588
+ return {
1589
+ fragmentationIndicator: mpu.fragmentationIndicator,
1590
+ mpuSequenceNumber: mpu.mpuSequenceNumber,
1591
+ fragmentCounter: mpu.fragmentCounter,
1592
+ data,
1593
+ timestamp: header.timestamp,
1594
+ rapFlag: header.rapFlag,
1595
+ sampleNumber: mfuDuHeader?.sampleNumber,
1596
+ movieFragmentSequenceNumber: mfuDuHeader?.movieFragmentSequenceNumber,
1597
+ sampleOffset: mfuDuHeader?.offset,
1598
+ priority: mfuDuHeader?.priority,
1599
+ itemId: itemId ?? undefined,
1600
+ packetSequenceNumber: header.sequenceNumber,
1601
+ packetId: header.packetId,
1602
+ }
1603
+ }
1604
+
1605
+ /**
1606
+ * Process MMTP packet with fragment reassembly
1607
+ */
1608
+ export function processMMTPWithReassembly(mmtpPacket: Uint8Array, reassembler: MFUReassembler): ReassembledMFU | null {
1609
+ // Skip FEC repair packets
1610
+ if (isFecRepairPacket(mmtpPacket)) {
1611
+ return null
1612
+ }
1613
+
1614
+ const fragment = createFragment(mmtpPacket)
1615
+ return reassembler.addFragment(fragment)
1616
+ }
1617
+
1618
+ // =============================================================================
1619
+ // Inline Tests (vitest)
1620
+ // =============================================================================
1621
+
1622
+ if (import.meta.vitest) {
1623
+ const { describe, test, expect, vi } = import.meta.vitest
1624
+
1625
+ const expectRejection = (call: () => unknown, code: MfuReassemblyErrorCode): void => {
1626
+ let rejection: unknown
1627
+ try {
1628
+ call()
1629
+ } catch (error) {
1630
+ rejection = error
1631
+ }
1632
+ expect(rejection).toBeInstanceOf(MfuReassemblyError)
1633
+ expect((rejection as MfuReassemblyError).code).toBe(code)
1634
+ }
1635
+
1636
+ const testPacketSequence = (mpu: number, counter: number, run = 0): number =>
1637
+ (mpu * 1000 + run - counter + 0x1_0000_0000) % 0x1_0000_0000
1638
+
1639
+ describe('MFUReassembler', () => {
1640
+ test('should return complete MFU for FI=0', () => {
1641
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
1642
+ const result = reassembler.addFragment({
1643
+ fragmentationIndicator: 0,
1644
+ mpuSequenceNumber: 1,
1645
+ fragmentCounter: 0,
1646
+ itemId: 1,
1647
+ data: new Uint8Array([1, 2, 3, 4]),
1648
+ timestamp: 1000n,
1649
+
1650
+ rapFlag: true,
1651
+ })
1652
+ expect(result).not.toBeNull()
1653
+ expect(result?.data).toEqual(new Uint8Array([1, 2, 3, 4]))
1654
+ expect(result?.fragmentCount).toBe(1)
1655
+ expect(result?.rapFlag).toBe(true)
1656
+ })
1657
+
1658
+ test('rejects a complete MFU without a valid sample identity', () => {
1659
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
1660
+ const complete = (identity: Partial<MFUFragment>): MFUFragment => ({
1661
+ fragmentationIndicator: 0,
1662
+ mpuSequenceNumber: 1,
1663
+ fragmentCounter: 0,
1664
+ data: new Uint8Array([1]),
1665
+ timestamp: 1000n,
1666
+ rapFlag: true,
1667
+ ...identity,
1668
+ })
1669
+ expectRejection(() => reassembler.addFragment(complete({})), 'missing-data-unit-identity')
1670
+ expectRejection(
1671
+ () =>
1672
+ reassembler.addFragment(
1673
+ complete({
1674
+ movieFragmentSequenceNumber: 2,
1675
+ sampleNumber: 1,
1676
+ })
1677
+ ),
1678
+ 'missing-data-unit-identity'
1679
+ )
1680
+ })
1681
+
1682
+ test('accepts a complete subsample MFU with a nonzero authored offset', () => {
1683
+ // moqcast #37: a subsample DU's offset is its authored base byte
1684
+ // position within the sample -- nonzero identifies one subsample DU
1685
+ // among siblings partitioning an oversized sample.
1686
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
1687
+ const result = reassembler.addFragment({
1688
+ fragmentationIndicator: 0,
1689
+ mpuSequenceNumber: 1,
1690
+ fragmentCounter: 0,
1691
+ movieFragmentSequenceNumber: 2,
1692
+ sampleNumber: 1,
1693
+ sampleOffset: 4,
1694
+ data: new Uint8Array([1]),
1695
+ timestamp: 1000n,
1696
+ rapFlag: true,
1697
+ })
1698
+ expect(result).not.toBeNull()
1699
+ expect(result?.sampleOffset).toBe(4)
1700
+ })
1701
+
1702
+ test('accepts a fragmented subsample DU whose first fragment has a nonzero authored offset', () => {
1703
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
1704
+
1705
+ expect(
1706
+ reassembler.addFragment({
1707
+ fragmentationIndicator: 1,
1708
+ mpuSequenceNumber: 2,
1709
+ fragmentCounter: 1,
1710
+ movieFragmentSequenceNumber: 2,
1711
+ sampleNumber: 1,
1712
+ sampleOffset: 4,
1713
+ packetSequenceNumber: testPacketSequence(2, 1),
1714
+ data: new Uint8Array([0xa1]),
1715
+ timestamp: 1000n,
1716
+ rapFlag: true,
1717
+ })
1718
+ ).toBeNull()
1719
+ expect(reassembler.pendingCount).toBe(1)
1720
+ expect(reassembler.getStatus().pendingFragments).toBe(1)
1721
+
1722
+ const out = reassembler.addFragment({
1723
+ fragmentationIndicator: 3,
1724
+ mpuSequenceNumber: 2,
1725
+ fragmentCounter: 0,
1726
+ data: new Uint8Array([0xa2]),
1727
+ timestamp: 1001n,
1728
+ rapFlag: false,
1729
+ packetSequenceNumber: testPacketSequence(2, 0),
1730
+ })
1731
+ expect(out?.data).toEqual(new Uint8Array([0xa1, 0xa2]))
1732
+ expect(out?.sampleOffset).toBe(4)
1733
+ })
1734
+
1735
+ // Rust twin: `a_mid_data_unit_sequence_gap_strands_the_data_unit` in
1736
+ // `mmt-core/src/reassembler/policy/mmt.rs`.
1737
+ //
1738
+ // One data unit's fragments occupy sequences 100, 102, 103 with an
1739
+ // unrelated complete unit at 101 -- an interleave *inside* the fragmented
1740
+ // unit rather than after it. ISO/IEC 23008-1 permits that, so this is the
1741
+ // boundary of the contiguous-source producer profile, not a bug within it.
1742
+ //
1743
+ // This test exists because no fixture in either corpus could previously
1744
+ // express a mid-data-unit sequence gap at all: the helpers derive
1745
+ // continuation sequences arithmetically from the head, so every fixture
1746
+ // was contiguous by construction and the profile's edge was untestable
1747
+ // (BLO-33550). It pins current behaviour rather than the guarantee we
1748
+ // would prefer -- change the association path and update it deliberately.
1749
+ test('a mid-data-unit packet sequence gap strands the data unit', () => {
1750
+ const reassembler = new MFUReassembler({ timeoutMs: null, maxBuffered: 10 })
1751
+
1752
+ expect(
1753
+ reassembler.addFragment({
1754
+ fragmentationIndicator: 1,
1755
+ mpuSequenceNumber: 7,
1756
+ fragmentCounter: 2,
1757
+ movieFragmentSequenceNumber: 9,
1758
+ sampleNumber: 17,
1759
+ sampleOffset: 0,
1760
+ packetSequenceNumber: 100,
1761
+ data: new Uint8Array([0xa1]),
1762
+ timestamp: 1100n,
1763
+ rapFlag: true,
1764
+ })
1765
+ ).toBeNull()
1766
+
1767
+ // An unrelated complete unit of the same MPU consumes sequence 101,
1768
+ // *between* the fragmented unit's head and its next fragment.
1769
+ const interleaved = reassembler.addFragment({
1770
+ fragmentationIndicator: 0,
1771
+ mpuSequenceNumber: 7,
1772
+ fragmentCounter: 0,
1773
+ movieFragmentSequenceNumber: 9,
1774
+ sampleNumber: 18,
1775
+ sampleOffset: 0,
1776
+ packetSequenceNumber: 101,
1777
+ data: new Uint8Array([0xff]),
1778
+ timestamp: 1101n,
1779
+ rapFlag: true,
1780
+ })
1781
+ expect(interleaved?.data).toEqual(new Uint8Array([0xff]))
1782
+
1783
+ expect(
1784
+ reassembler.addFragment({
1785
+ fragmentationIndicator: 2,
1786
+ mpuSequenceNumber: 7,
1787
+ fragmentCounter: 1,
1788
+ packetSequenceNumber: 102,
1789
+ data: new Uint8Array([0xa2]),
1790
+ timestamp: 1102n,
1791
+ rapFlag: false,
1792
+ })
1793
+ ).toBeNull()
1794
+
1795
+ const output = reassembler.addFragment({
1796
+ fragmentationIndicator: 3,
1797
+ mpuSequenceNumber: 7,
1798
+ fragmentCounter: 0,
1799
+ packetSequenceNumber: 103,
1800
+ data: new Uint8Array([0xa3]),
1801
+ timestamp: 1103n,
1802
+ rapFlag: false,
1803
+ })
1804
+
1805
+ // What we would want, and do not get: `output` carrying a1 a2 a3. The
1806
+ // head addresses 100+2=102; both continuations address 102+1 = 103+0 =
1807
+ // 103, so they are two data units and neither can complete.
1808
+ expect(output).toBeNull()
1809
+
1810
+ // Fail-closed, not corrupt: only the interleaver emitted, and no
1811
+ // fragment was donated across data units.
1812
+ expect(reassembler.stats.reassembledMFUs).toBe(1)
1813
+ expect(reassembler.stats.rejectedFragments).toBe(0)
1814
+
1815
+ // The loss is invisible at this scale -- since BLO-31513 the bound is
1816
+ // enforced by replacement, so at `maxBuffered` this same stranding is
1817
+ // charged only to `evictedMemoryLimit` and presents as intermittent
1818
+ // missing media rather than an error.
1819
+ expect(reassembler.stats.evictedMemoryLimit).toBe(0)
1820
+
1821
+ // Two slots for one data unit -- the open run at 102 plus the headless
1822
+ // parked queue at 103 -- so sustained profile-violating interleave
1823
+ // consumes `maxBuffered` at twice the expected rate and evicts healthy
1824
+ // data units alongside the stranded one. Matches the Rust twin's
1825
+ // `buffered_count() == 2` / `pending_fragment_count() == 3`.
1826
+ expect(reassembler.getStatus().bufferedMFUs).toBe(2)
1827
+ expect(reassembler.getStatus().pendingFragments).toBe(3)
1828
+ })
1829
+
1830
+ test('sibling subsample DUs of one sample reassemble independently by offset', () => {
1831
+ // moqcast #37: each subsample DU of a partitioned sample runs its own
1832
+ // FI chain and gets its own buffer, keyed in part by its authored base
1833
+ // offset -- distinct from a sibling DU sharing (movie, sample).
1834
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
1835
+
1836
+ expect(
1837
+ reassembler.addFragment({
1838
+ mpuSequenceNumber: 3,
1839
+ movieFragmentSequenceNumber: 5,
1840
+ sampleNumber: 9,
1841
+ fragmentationIndicator: 1,
1842
+ fragmentCounter: 1,
1843
+ sampleOffset: 0,
1844
+ data: new Uint8Array([0xa1]),
1845
+ timestamp: 2000n,
1846
+ rapFlag: true,
1847
+ packetSequenceNumber: testPacketSequence(3, 1, 0),
1848
+ })
1849
+ ).toBeNull()
1850
+ // The sibling's head sits at its own packet sequence, so its address
1851
+ // (packetSequenceNumber + fragmentCounter) differs from the first's.
1852
+ // Both stay open at once and every headerless continuation names exactly
1853
+ // one of them by that address. A second head on an already-open address
1854
+ // is refused instead ('rejects an overlapping non-timed item without
1855
+ // replacing the active run').
1856
+ expect(
1857
+ reassembler.addFragment({
1858
+ mpuSequenceNumber: 3,
1859
+ movieFragmentSequenceNumber: 5,
1860
+ sampleNumber: 9,
1861
+ fragmentationIndicator: 1,
1862
+ fragmentCounter: 2,
1863
+ sampleOffset: 3,
1864
+ data: new Uint8Array([0xb1]),
1865
+ timestamp: 2001n,
1866
+ rapFlag: false,
1867
+ packetSequenceNumber: testPacketSequence(3, 1, 1),
1868
+ })
1869
+ ).toBeNull()
1870
+ expect(reassembler.pendingCount).toBe(2)
1871
+
1872
+ const first = reassembler.addFragment({
1873
+ mpuSequenceNumber: 3,
1874
+ fragmentationIndicator: 3,
1875
+ fragmentCounter: 0,
1876
+ data: new Uint8Array([0xa2]),
1877
+ timestamp: 2000n,
1878
+ rapFlag: false,
1879
+ packetSequenceNumber: testPacketSequence(3, 0, 0),
1880
+ })
1881
+ expect(first?.data).toEqual(new Uint8Array([0xa1, 0xa2]))
1882
+ expect(first?.sampleOffset).toBe(0)
1883
+
1884
+ expect(
1885
+ reassembler.addFragment({
1886
+ mpuSequenceNumber: 3,
1887
+ fragmentationIndicator: 2,
1888
+ fragmentCounter: 1,
1889
+ data: new Uint8Array([0xb2]),
1890
+ timestamp: 2001n,
1891
+ rapFlag: false,
1892
+ packetSequenceNumber: testPacketSequence(3, 0, 1),
1893
+ })
1894
+ ).toBeNull()
1895
+ const second = reassembler.addFragment({
1896
+ mpuSequenceNumber: 3,
1897
+ fragmentationIndicator: 3,
1898
+ fragmentCounter: 0,
1899
+ data: new Uint8Array([0xb3]),
1900
+ timestamp: 2001n,
1901
+ rapFlag: false,
1902
+ packetSequenceNumber: testPacketSequence(3, 0, 2),
1903
+ })
1904
+ expect(second?.data).toEqual(new Uint8Array([0xb1, 0xb2, 0xb3]))
1905
+ expect(second?.sampleOffset).toBe(3)
1906
+ expect(reassembler.pendingCount).toBe(0)
1907
+ })
1908
+
1909
+ test('isolates colliding fragment coordinates by catalog track identity', () => {
1910
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
1911
+ const fragment = (
1912
+ catalogTrackName: string,
1913
+ fragmentationIndicator: number,
1914
+ fragmentCounter: number,
1915
+ byte: number
1916
+ ): MFUFragment => ({
1917
+ itemId: 1,
1918
+ fragmentationIndicator,
1919
+ fragmentCounter,
1920
+ mpuSequenceNumber: 1,
1921
+ data: new Uint8Array([byte]),
1922
+ timestamp: 1000n,
1923
+ rapFlag: fragmentationIndicator === 1,
1924
+ catalogTrackName,
1925
+ packetSequenceNumber: testPacketSequence(1, fragmentCounter),
1926
+ })
1927
+
1928
+ expect(reassembler.addFragment(fragment('video/720p', 1, 1, 0x71))).toBeNull()
1929
+ expect(reassembler.addFragment(fragment('video/540p', 1, 1, 0x51))).toBeNull()
1930
+ const high = reassembler.addFragment(fragment('video/720p', 3, 0, 0x72))
1931
+ const low = reassembler.addFragment(fragment('video/540p', 3, 0, 0x52))
1932
+
1933
+ expect(high?.data).toEqual(new Uint8Array([0x71, 0x72]))
1934
+ expect(high?.catalogTrackName).toBe('video/720p')
1935
+ expect(low?.data).toEqual(new Uint8Array([0x51, 0x52]))
1936
+ expect(low?.catalogTrackName).toBe('video/540p')
1937
+ })
1938
+
1939
+ test('rejects an overlapping non-timed item without replacing the active run', () => {
1940
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
1941
+ const fragment = (
1942
+ itemId: number,
1943
+ fragmentationIndicator: number,
1944
+ fragmentCounter: number,
1945
+ byte: number
1946
+ ): MFUFragment => ({
1947
+ itemId,
1948
+ fragmentationIndicator,
1949
+ fragmentCounter,
1950
+ mpuSequenceNumber: 1,
1951
+ data: new Uint8Array([byte]),
1952
+ timestamp: 1000n,
1953
+ rapFlag: false,
1954
+ packetSequenceNumber: testPacketSequence(1, fragmentCounter),
1955
+ })
1956
+
1957
+ expect(reassembler.addFragment(fragment(10, 1, 1, 0x10))).toBeNull()
1958
+ expectRejection(() => reassembler.addFragment(fragment(20, 1, 1, 0x20)), 'overlapping-data-unit')
1959
+ expect(reassembler.pendingCount).toBe(1)
1960
+ expect(reassembler.addFragment(fragment(10, 3, 0, 0x11))?.data).toEqual(new Uint8Array([0x10, 0x11]))
1961
+ expect(reassembler.pendingCount).toBe(0)
1962
+ })
1963
+
1964
+ test('associates continuations by authored state when packet timestamps change', () => {
1965
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
1966
+ const fragment = (
1967
+ itemId: number | undefined,
1968
+ fragmentationIndicator: number,
1969
+ fragmentCounter: number,
1970
+ byte: number,
1971
+ timestamp: bigint
1972
+ ): MFUFragment => ({
1973
+ ...(itemId === undefined ? {} : { itemId }),
1974
+ fragmentationIndicator,
1975
+ fragmentCounter,
1976
+ mpuSequenceNumber: 1,
1977
+ data: new Uint8Array([byte]),
1978
+ timestamp,
1979
+ rapFlag: false,
1980
+ packetSequenceNumber: testPacketSequence(1, fragmentCounter),
1981
+ })
1982
+
1983
+ expect(reassembler.addFragment(fragment(10, 1, 2, 0x10, 1000n))).toBeNull()
1984
+ expect(reassembler.addFragment(fragment(undefined, 2, 1, 0x11, 1001n))).toBeNull()
1985
+ const out = reassembler.addFragment(fragment(undefined, 3, 0, 0x12, 1002n))
1986
+ expect(out?.data).toEqual(new Uint8Array([0x10, 0x11, 0x12]))
1987
+ expect(out?.timestamp).toBe(1000n)
1988
+ })
1989
+
1990
+ test('should buffer and reassemble 2 fragments', () => {
1991
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
1992
+
1993
+ // First fragment (FI=1)
1994
+ const result1 = reassembler.addFragment({
1995
+ fragmentationIndicator: 1,
1996
+ mpuSequenceNumber: 1,
1997
+ fragmentCounter: 1,
1998
+ itemId: 1,
1999
+ data: new Uint8Array([1, 2]),
2000
+ timestamp: 1000n,
2001
+ rapFlag: true,
2002
+ packetSequenceNumber: testPacketSequence(1, 1),
2003
+ })
2004
+ expect(result1).toBeNull()
2005
+
2006
+ // Last fragment (FI=3)
2007
+ const result2 = reassembler.addFragment({
2008
+ fragmentationIndicator: 3,
2009
+ mpuSequenceNumber: 1,
2010
+ fragmentCounter: 0,
2011
+ data: new Uint8Array([3, 4]),
2012
+ timestamp: 1000n,
2013
+ rapFlag: false,
2014
+ packetSequenceNumber: testPacketSequence(1, 0),
2015
+ })
2016
+ expect(result2).not.toBeNull()
2017
+ expect(result2?.data).toEqual(new Uint8Array([1, 2, 3, 4]))
2018
+ expect(result2?.fragmentCount).toBe(2)
2019
+ expect(result2?.rapFlag).toBe(true) // Should use first fragment's rapFlag
2020
+ })
2021
+
2022
+ test('buffers a terminal continuation until its authored first fragment arrives', () => {
2023
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2024
+
2025
+ // Last fragment first (FI=3)
2026
+ expect(
2027
+ reassembler.addFragment({
2028
+ fragmentationIndicator: 3,
2029
+ mpuSequenceNumber: 1,
2030
+ fragmentCounter: 0,
2031
+ data: new Uint8Array([3, 4]),
2032
+ timestamp: 1000n,
2033
+ rapFlag: false,
2034
+ packetSequenceNumber: testPacketSequence(1, 0),
2035
+ })
2036
+ ).toBeNull()
2037
+ expect(reassembler.pendingCount).toBe(1)
2038
+
2039
+ // Then first fragment (FI=1)
2040
+ const completed = reassembler.addFragment({
2041
+ fragmentationIndicator: 1,
2042
+ mpuSequenceNumber: 1,
2043
+ fragmentCounter: 1,
2044
+ itemId: 1,
2045
+ data: new Uint8Array([1, 2]),
2046
+ timestamp: 1000n,
2047
+ rapFlag: true,
2048
+ packetSequenceNumber: testPacketSequence(1, 1),
2049
+ })
2050
+
2051
+ expect(completed?.data).toEqual(new Uint8Array([1, 2, 3, 4]))
2052
+ expect(completed?.rapFlag).toBe(true)
2053
+ expect(reassembler.pendingCount).toBe(0)
2054
+ })
2055
+
2056
+ test('clock-free mode keeps an admitted MFU until completion', async () => {
2057
+ const reassembler = new MFUReassembler({ timeoutMs: null })
2058
+
2059
+ reassembler.addFragment({
2060
+ fragmentationIndicator: 1,
2061
+ mpuSequenceNumber: 1,
2062
+ fragmentCounter: 1,
2063
+ itemId: 1,
2064
+ data: new Uint8Array([1, 2]),
2065
+ timestamp: 1000n,
2066
+ rapFlag: true,
2067
+ packetSequenceNumber: testPacketSequence(1, 1),
2068
+ })
2069
+
2070
+ await new Promise(resolve => setTimeout(resolve, 5))
2071
+
2072
+ const result = reassembler.addFragment({
2073
+ fragmentationIndicator: 3,
2074
+ mpuSequenceNumber: 1,
2075
+ fragmentCounter: 0,
2076
+ data: new Uint8Array([3, 4]),
2077
+ timestamp: 1000n,
2078
+ rapFlag: false,
2079
+ packetSequenceNumber: testPacketSequence(1, 0),
2080
+ })
2081
+
2082
+ expect(result).not.toBeNull()
2083
+ expect(result?.data).toEqual(new Uint8Array([1, 2, 3, 4]))
2084
+ expect(reassembler.stats.timedOutMFUs).toBe(0)
2085
+ })
2086
+
2087
+ test('should track pending count', () => {
2088
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2089
+ expect(reassembler.pendingCount).toBe(0)
2090
+
2091
+ reassembler.addFragment({
2092
+ fragmentationIndicator: 1,
2093
+ mpuSequenceNumber: 1,
2094
+ fragmentCounter: 1,
2095
+ itemId: 1,
2096
+ data: new Uint8Array([1]),
2097
+ timestamp: 1000n,
2098
+ rapFlag: false,
2099
+ packetSequenceNumber: testPacketSequence(1, 1),
2100
+ })
2101
+ expect(reassembler.pendingCount).toBe(1)
2102
+
2103
+ reassembler.flush()
2104
+ expect(reassembler.pendingCount).toBe(0)
2105
+ })
2106
+
2107
+ test('should reassemble 3 fragments (FI=1, FI=2, FI=3)', () => {
2108
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2109
+
2110
+ // First fragment
2111
+ expect(
2112
+ reassembler.addFragment({
2113
+ fragmentationIndicator: 1,
2114
+ mpuSequenceNumber: 10,
2115
+ fragmentCounter: 2,
2116
+ itemId: 1,
2117
+ data: new Uint8Array([0xaa, 0xbb]),
2118
+ timestamp: 5000n,
2119
+ rapFlag: true,
2120
+ packetSequenceNumber: testPacketSequence(10, 2),
2121
+ })
2122
+ ).toBeNull()
2123
+
2124
+ // Middle fragment
2125
+ expect(
2126
+ reassembler.addFragment({
2127
+ fragmentationIndicator: 2,
2128
+ mpuSequenceNumber: 10,
2129
+ fragmentCounter: 1,
2130
+ data: new Uint8Array([0xcc, 0xdd]),
2131
+ timestamp: 5000n,
2132
+ rapFlag: false,
2133
+ packetSequenceNumber: testPacketSequence(10, 1),
2134
+ })
2135
+ ).toBeNull()
2136
+
2137
+ // Last fragment — triggers reassembly
2138
+ const result = reassembler.addFragment({
2139
+ fragmentationIndicator: 3,
2140
+ mpuSequenceNumber: 10,
2141
+ fragmentCounter: 0,
2142
+ data: new Uint8Array([0xee, 0xff]),
2143
+ timestamp: 5000n,
2144
+ rapFlag: false,
2145
+ packetSequenceNumber: testPacketSequence(10, 0),
2146
+ })
2147
+
2148
+ expect(result).not.toBeNull()
2149
+ expect(result!.data).toEqual(new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]))
2150
+ expect(result!.fragmentCount).toBe(3)
2151
+ expect(result!.rapFlag).toBe(true) // from first fragment
2152
+ expect(result!.mpuSequenceNumber).toBe(10)
2153
+ })
2154
+
2155
+ test('should handle interleaved MPU sequences', () => {
2156
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2157
+
2158
+ // MPU 1: first fragment
2159
+ reassembler.addFragment({
2160
+ fragmentationIndicator: 1,
2161
+ mpuSequenceNumber: 1,
2162
+ fragmentCounter: 1,
2163
+ itemId: 1,
2164
+ data: new Uint8Array([0x01]),
2165
+ timestamp: 100n,
2166
+ rapFlag: false,
2167
+ packetSequenceNumber: testPacketSequence(1, 1),
2168
+ })
2169
+ // MPU 2: complete (FI=0)
2170
+ const r2 = reassembler.addFragment({
2171
+ fragmentationIndicator: 0,
2172
+ mpuSequenceNumber: 2,
2173
+ fragmentCounter: 0,
2174
+ itemId: 2,
2175
+ data: new Uint8Array([0x02]),
2176
+ timestamp: 200n,
2177
+ rapFlag: false,
2178
+ })
2179
+ expect(r2).not.toBeNull()
2180
+ expect(r2!.mpuSequenceNumber).toBe(2)
2181
+
2182
+ // MPU 1: last fragment
2183
+ const r1 = reassembler.addFragment({
2184
+ fragmentationIndicator: 3,
2185
+ mpuSequenceNumber: 1,
2186
+ fragmentCounter: 0,
2187
+ data: new Uint8Array([0x03]),
2188
+ timestamp: 100n,
2189
+ rapFlag: false,
2190
+ packetSequenceNumber: testPacketSequence(1, 0),
2191
+ })
2192
+ expect(r1).not.toBeNull()
2193
+ expect(r1!.data).toEqual(new Uint8Array([0x01, 0x03]))
2194
+ })
2195
+
2196
+ test('rejects overlapping fragmented MFUs sharing one stream and MPU', () => {
2197
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2198
+ const mpuSeq = 5
2199
+
2200
+ expect(
2201
+ reassembler.addFragment({
2202
+ fragmentationIndicator: 1,
2203
+ mpuSequenceNumber: mpuSeq,
2204
+ fragmentCounter: 1,
2205
+ itemId: 10,
2206
+ data: new Uint8Array([0xa1]),
2207
+ timestamp: 100n,
2208
+ rapFlag: true,
2209
+ packetSequenceNumber: testPacketSequence(mpuSeq, 1),
2210
+ })
2211
+ ).toBeNull()
2212
+ expectRejection(
2213
+ () =>
2214
+ reassembler.addFragment({
2215
+ fragmentationIndicator: 1,
2216
+ mpuSequenceNumber: mpuSeq,
2217
+ fragmentCounter: 1,
2218
+ itemId: 20,
2219
+ data: new Uint8Array([0xb1]),
2220
+ timestamp: 200n,
2221
+ rapFlag: false,
2222
+ packetSequenceNumber: testPacketSequence(mpuSeq, 1),
2223
+ }),
2224
+ 'overlapping-data-unit'
2225
+ )
2226
+ expect(reassembler.pendingCount).toBe(1)
2227
+
2228
+ const a = reassembler.addFragment({
2229
+ fragmentationIndicator: 3,
2230
+ mpuSequenceNumber: mpuSeq,
2231
+ fragmentCounter: 0,
2232
+ data: new Uint8Array([0xa2]),
2233
+ timestamp: 101n,
2234
+ rapFlag: false,
2235
+ packetSequenceNumber: testPacketSequence(mpuSeq, 0),
2236
+ })
2237
+ expect(a).not.toBeNull()
2238
+ expect(a!.data).toEqual(new Uint8Array([0xa1, 0xa2]))
2239
+ expect(a!.rapFlag).toBe(true) // from A's first fragment
2240
+
2241
+ expect(
2242
+ reassembler.addFragment({
2243
+ fragmentationIndicator: 1,
2244
+ mpuSequenceNumber: mpuSeq,
2245
+ fragmentCounter: 1,
2246
+ itemId: 20,
2247
+ data: new Uint8Array([0xb1]),
2248
+ timestamp: 200n,
2249
+ rapFlag: false,
2250
+ packetSequenceNumber: testPacketSequence(mpuSeq, 1, 10),
2251
+ })
2252
+ ).toBeNull()
2253
+ const b = reassembler.addFragment({
2254
+ fragmentationIndicator: 3,
2255
+ mpuSequenceNumber: mpuSeq,
2256
+ fragmentCounter: 0,
2257
+ data: new Uint8Array([0xb2]),
2258
+ timestamp: 201n,
2259
+ rapFlag: false,
2260
+ packetSequenceNumber: testPacketSequence(mpuSeq, 0, 10),
2261
+ })
2262
+ expect(b).not.toBeNull()
2263
+ expect(b!.data).toEqual(new Uint8Array([0xb1, 0xb2]))
2264
+ })
2265
+
2266
+ test('reassembles FFmpeg timed MFU fragments by sample number and byte offset', () => {
2267
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2268
+ const common = {
2269
+ mpuSequenceNumber: 0,
2270
+ movieFragmentSequenceNumber: 9,
2271
+ sampleNumber: 17,
2272
+ }
2273
+
2274
+ expect(
2275
+ reassembler.addFragment({
2276
+ ...common,
2277
+ fragmentationIndicator: 1,
2278
+ fragmentCounter: 2,
2279
+ sampleOffset: 0,
2280
+ data: new Uint8Array([0xa1]),
2281
+ timestamp: 1000n,
2282
+ rapFlag: true,
2283
+ packetSequenceNumber: testPacketSequence(0, 2),
2284
+ })
2285
+ ).toBeNull()
2286
+ expect(
2287
+ reassembler.addFragment({
2288
+ ...common,
2289
+ fragmentationIndicator: 2,
2290
+ fragmentCounter: 1,
2291
+ sampleOffset: 1,
2292
+ data: new Uint8Array([0xb2]),
2293
+ timestamp: 1001n,
2294
+ rapFlag: false,
2295
+ packetSequenceNumber: testPacketSequence(0, 1),
2296
+ })
2297
+ ).toBeNull()
2298
+ const out = reassembler.addFragment({
2299
+ ...common,
2300
+ fragmentationIndicator: 3,
2301
+ fragmentCounter: 0,
2302
+ sampleOffset: 2,
2303
+ data: new Uint8Array([0xc3]),
2304
+ timestamp: 1002n,
2305
+ rapFlag: false,
2306
+ packetSequenceNumber: testPacketSequence(0, 0),
2307
+ })
2308
+
2309
+ expect(out).not.toBeNull()
2310
+ expect(out!.data).toEqual(new Uint8Array([0xa1, 0xb2, 0xc3]))
2311
+ expect(out!.timestamp).toBe(1000n)
2312
+ expect(out!.sampleNumber).toBe(17)
2313
+ expect(out!.movieFragmentSequenceNumber).toBe(9)
2314
+ })
2315
+
2316
+ test('buffers a counter gap without advancing the active data unit', () => {
2317
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2318
+ const common = {
2319
+ mpuSequenceNumber: 0,
2320
+ movieFragmentSequenceNumber: 9,
2321
+ sampleNumber: 17,
2322
+ timestamp: 1000n,
2323
+ rapFlag: true,
2324
+ }
2325
+
2326
+ expect(
2327
+ reassembler.addFragment({
2328
+ ...common,
2329
+ fragmentationIndicator: 1,
2330
+ fragmentCounter: 2,
2331
+ sampleOffset: 0,
2332
+ data: new Uint8Array([0xa1, 0xa2]),
2333
+ packetSequenceNumber: testPacketSequence(0, 2),
2334
+ })
2335
+ ).toBeNull()
2336
+ expect(
2337
+ reassembler.addFragment({
2338
+ ...common,
2339
+ fragmentationIndicator: 3,
2340
+ fragmentCounter: 0,
2341
+ sampleOffset: 4,
2342
+ data: new Uint8Array([0xc1, 0xc2]),
2343
+ packetSequenceNumber: testPacketSequence(0, 0),
2344
+ })
2345
+ ).toBeNull()
2346
+ expect(reassembler.pendingCount).toBe(1)
2347
+
2348
+ const out = reassembler.addFragment({
2349
+ ...common,
2350
+ fragmentationIndicator: 2,
2351
+ fragmentCounter: 1,
2352
+ sampleOffset: 2,
2353
+ data: new Uint8Array([0xb1, 0xb2]),
2354
+ packetSequenceNumber: testPacketSequence(0, 1),
2355
+ })
2356
+ expect(out?.data).toEqual(new Uint8Array([0xa1, 0xa2, 0xb1, 0xb2, 0xc1, 0xc2]))
2357
+ })
2358
+
2359
+ test('reassembles first-only DU-header fragments across changing packet timestamps', () => {
2360
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2361
+
2362
+ expect(
2363
+ reassembler.addFragment({
2364
+ fragmentationIndicator: 1,
2365
+ mpuSequenceNumber: 44,
2366
+ fragmentCounter: 2,
2367
+ movieFragmentSequenceNumber: 9,
2368
+ sampleNumber: 17,
2369
+ sampleOffset: 0,
2370
+ data: new Uint8Array([0xa1]),
2371
+ timestamp: 12345n,
2372
+ rapFlag: true,
2373
+ packetSequenceNumber: testPacketSequence(44, 2),
2374
+ })
2375
+ ).toBeNull()
2376
+ expect(
2377
+ reassembler.addFragment({
2378
+ fragmentationIndicator: 2,
2379
+ mpuSequenceNumber: 44,
2380
+ fragmentCounter: 1,
2381
+ data: new Uint8Array([0xb2]),
2382
+ timestamp: 12346n,
2383
+ rapFlag: false,
2384
+ packetSequenceNumber: testPacketSequence(44, 1),
2385
+ })
2386
+ ).toBeNull()
2387
+ const out = reassembler.addFragment({
2388
+ fragmentationIndicator: 3,
2389
+ mpuSequenceNumber: 44,
2390
+ fragmentCounter: 0,
2391
+ data: new Uint8Array([0xc3]),
2392
+ timestamp: 12347n,
2393
+ rapFlag: false,
2394
+ packetSequenceNumber: testPacketSequence(44, 0),
2395
+ })
2396
+
2397
+ expect(out).not.toBeNull()
2398
+ expect(out!.data).toEqual(new Uint8Array([0xa1, 0xb2, 0xc3]))
2399
+ expect(out!.timestamp).toBe(12345n)
2400
+ expect(out!.sampleNumber).toBe(17)
2401
+ expect(out!.movieFragmentSequenceNumber).toBe(9)
2402
+ })
2403
+
2404
+ test('reassembles first-only DU-header fragments with an ISO countdown', () => {
2405
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2406
+ const timestamp = 12345n
2407
+
2408
+ expect(
2409
+ reassembler.addFragment({
2410
+ fragmentationIndicator: 1,
2411
+ mpuSequenceNumber: 44,
2412
+ fragmentCounter: 3,
2413
+ movieFragmentSequenceNumber: 9,
2414
+ sampleNumber: 17,
2415
+ sampleOffset: 0,
2416
+ data: new Uint8Array([0xa1]),
2417
+ timestamp,
2418
+ rapFlag: true,
2419
+ packetSequenceNumber: testPacketSequence(44, 3),
2420
+ })
2421
+ ).toBeNull()
2422
+ expect(
2423
+ reassembler.addFragment({
2424
+ fragmentationIndicator: 2,
2425
+ mpuSequenceNumber: 44,
2426
+ fragmentCounter: 2,
2427
+ data: new Uint8Array([0xb2]),
2428
+ timestamp,
2429
+ rapFlag: false,
2430
+ packetSequenceNumber: testPacketSequence(44, 2),
2431
+ })
2432
+ ).toBeNull()
2433
+ expect(
2434
+ reassembler.addFragment({
2435
+ fragmentationIndicator: 2,
2436
+ mpuSequenceNumber: 44,
2437
+ fragmentCounter: 1,
2438
+ data: new Uint8Array([0xc3]),
2439
+ timestamp,
2440
+ rapFlag: false,
2441
+ packetSequenceNumber: testPacketSequence(44, 1),
2442
+ })
2443
+ ).toBeNull()
2444
+ const out = reassembler.addFragment({
2445
+ fragmentationIndicator: 3,
2446
+ mpuSequenceNumber: 44,
2447
+ fragmentCounter: 0,
2448
+ data: new Uint8Array([0xd4]),
2449
+ timestamp,
2450
+ rapFlag: false,
2451
+ packetSequenceNumber: testPacketSequence(44, 0),
2452
+ })
2453
+
2454
+ expect(out).not.toBeNull()
2455
+ expect(out!.data).toEqual(new Uint8Array([0xa1, 0xb2, 0xc3, 0xd4]))
2456
+ expect(out!.sampleNumber).toBe(17)
2457
+ expect(out!.movieFragmentSequenceNumber).toBe(9)
2458
+ })
2459
+
2460
+ test('rejects fragmented MFUs without an authored DU identity', () => {
2461
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2462
+ const timestamp = 22222n
2463
+
2464
+ expectRejection(
2465
+ () =>
2466
+ reassembler.addFragment({
2467
+ fragmentationIndicator: 1,
2468
+ mpuSequenceNumber: 55,
2469
+ fragmentCounter: 2,
2470
+ data: new Uint8Array([0xa1]),
2471
+ timestamp,
2472
+ rapFlag: true,
2473
+ packetSequenceNumber: testPacketSequence(55, 2),
2474
+ }),
2475
+ 'missing-data-unit-identity'
2476
+ )
2477
+ expect(reassembler.pendingCount).toBe(0)
2478
+ })
2479
+
2480
+ test('rejects fragmented MFUs without an authored packet sequence', () => {
2481
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2482
+ expectRejection(
2483
+ () =>
2484
+ reassembler.addFragment({
2485
+ fragmentationIndicator: 1,
2486
+ mpuSequenceNumber: 55,
2487
+ fragmentCounter: 1,
2488
+ itemId: 1,
2489
+ data: new Uint8Array([0xa1]),
2490
+ timestamp: 22222n,
2491
+ rapFlag: true,
2492
+ }),
2493
+ 'missing-packet-sequence'
2494
+ )
2495
+ expect(reassembler.pendingCount).toBe(0)
2496
+ })
2497
+
2498
+ test('rejects fragmented MFUs whose authored packet sequence is not u32', () => {
2499
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2500
+ for (const packetSequenceNumber of [-1, 0x1_0000_0000, 1.5, Number.NaN]) {
2501
+ expectRejection(
2502
+ () =>
2503
+ reassembler.addFragment({
2504
+ fragmentationIndicator: 1,
2505
+ mpuSequenceNumber: 55,
2506
+ fragmentCounter: 1,
2507
+ itemId: 1,
2508
+ data: new Uint8Array([0xa1]),
2509
+ timestamp: 22222n,
2510
+ rapFlag: true,
2511
+ packetSequenceNumber,
2512
+ }),
2513
+ 'missing-packet-sequence'
2514
+ )
2515
+ }
2516
+ expect(reassembler.pendingCount).toBe(0)
2517
+ })
2518
+
2519
+ test('a packet sequence gap inside a fragmented DU splits it and never completes', () => {
2520
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2521
+ const timestamp = 66666n
2522
+
2523
+ // The head is addressed 1000 + 3 = 1003. One unrelated same-pid packet
2524
+ // consumed psn 1001, so every later fragment is addressed 1004: a
2525
+ // second data unit on the wire, parked headless and never merged with
2526
+ // the head by scope, counter, timestamp, or arrival order.
2527
+ expect(
2528
+ reassembler.addFragment({
2529
+ fragmentationIndicator: 1,
2530
+ mpuSequenceNumber: 80,
2531
+ fragmentCounter: 3,
2532
+ movieFragmentSequenceNumber: 241,
2533
+ sampleNumber: 1,
2534
+ sampleOffset: 0,
2535
+ packetSequenceNumber: 1000,
2536
+ data: new Uint8Array([0xa0]),
2537
+ timestamp,
2538
+ rapFlag: true,
2539
+ })
2540
+ ).toBeNull()
2541
+ for (const [counter, psn] of [
2542
+ [2, 1002],
2543
+ [1, 1003],
2544
+ ] as const) {
2545
+ expect(
2546
+ reassembler.addFragment({
2547
+ fragmentationIndicator: 2,
2548
+ mpuSequenceNumber: 80,
2549
+ fragmentCounter: counter,
2550
+ packetSequenceNumber: psn,
2551
+ data: new Uint8Array([0xa0 + counter]),
2552
+ timestamp,
2553
+ rapFlag: false,
2554
+ })
2555
+ ).toBeNull()
2556
+ }
2557
+ expect(
2558
+ reassembler.addFragment({
2559
+ fragmentationIndicator: 3,
2560
+ mpuSequenceNumber: 80,
2561
+ fragmentCounter: 0,
2562
+ packetSequenceNumber: 1004,
2563
+ data: new Uint8Array([0xb1]),
2564
+ timestamp,
2565
+ rapFlag: false,
2566
+ })
2567
+ ).toBeNull()
2568
+
2569
+ // The open head plus one headless parked data unit, one slot each.
2570
+ expect(reassembler.pendingCount).toBe(2)
2571
+ expect(reassembler.getStatus().pendingFragments).toBe(4)
2572
+ expect(reassembler.stats.reassembledMFUs).toBe(0)
2573
+ expect(reassembler.stats.rejectedFragments).toBe(0)
2574
+ })
2575
+
2576
+ test('keeps adjacent MFUs with equal countdowns in one MPU separate', () => {
2577
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2578
+ const common = { mpuSequenceNumber: 80, packetId: 7, timestamp: 66666n }
2579
+
2580
+ // Two adjacent samples of one MPU fragmented into the same countdown are
2581
+ // both open at once. Each continuation carries its unit's terminal
2582
+ // sequence (100 + 1 and 102 + 1), so each run completes with its own
2583
+ // terminal fragment.
2584
+ expect(
2585
+ reassembler.addFragment({
2586
+ ...common,
2587
+ fragmentationIndicator: 1,
2588
+ fragmentCounter: 1,
2589
+ packetSequenceNumber: 100,
2590
+ movieFragmentSequenceNumber: 241,
2591
+ sampleNumber: 1,
2592
+ sampleOffset: 0,
2593
+ data: new Uint8Array([0xa1]),
2594
+ rapFlag: true,
2595
+ })
2596
+ ).toBeNull()
2597
+ expect(
2598
+ reassembler.addFragment({
2599
+ ...common,
2600
+ fragmentationIndicator: 1,
2601
+ fragmentCounter: 1,
2602
+ packetSequenceNumber: 102,
2603
+ movieFragmentSequenceNumber: 241,
2604
+ sampleNumber: 2,
2605
+ sampleOffset: 0,
2606
+ data: new Uint8Array([0xb1]),
2607
+ rapFlag: false,
2608
+ })
2609
+ ).toBeNull()
2610
+ expect(reassembler.pendingCount).toBe(2)
2611
+
2612
+ const earlier = reassembler.addFragment({
2613
+ ...common,
2614
+ fragmentationIndicator: 3,
2615
+ fragmentCounter: 0,
2616
+ packetSequenceNumber: 101,
2617
+ data: new Uint8Array([0xa2]),
2618
+ rapFlag: false,
2619
+ })
2620
+ expect(earlier?.data).toEqual(new Uint8Array([0xa1, 0xa2]))
2621
+ expect(earlier?.packetSequenceNumbers).toEqual([100, 101])
2622
+
2623
+ const later = reassembler.addFragment({
2624
+ ...common,
2625
+ fragmentationIndicator: 3,
2626
+ fragmentCounter: 0,
2627
+ packetSequenceNumber: 103,
2628
+ data: new Uint8Array([0xb2]),
2629
+ rapFlag: false,
2630
+ })
2631
+ expect(later?.data).toEqual(new Uint8Array([0xb1, 0xb2]))
2632
+ expect(later?.packetSequenceNumbers).toEqual([102, 103])
2633
+ expect(reassembler.pendingCount).toBe(0)
2634
+ })
2635
+
2636
+ test('wraps the fragmented run coordinate at u32 packet sequence', () => {
2637
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2638
+ expect(
2639
+ reassembler.addFragment({
2640
+ fragmentationIndicator: 1,
2641
+ mpuSequenceNumber: 81,
2642
+ fragmentCounter: 1,
2643
+ packetSequenceNumber: 0xffffffff,
2644
+ itemId: 1,
2645
+ data: new Uint8Array([0xa1]),
2646
+ timestamp: 66666n,
2647
+ rapFlag: true,
2648
+ })
2649
+ ).toBeNull()
2650
+ expect(
2651
+ reassembler.addFragment({
2652
+ fragmentationIndicator: 3,
2653
+ mpuSequenceNumber: 81,
2654
+ fragmentCounter: 0,
2655
+ packetSequenceNumber: 0,
2656
+ data: new Uint8Array([0xa2]),
2657
+ timestamp: 66667n,
2658
+ rapFlag: false,
2659
+ })?.data
2660
+ ).toEqual(new Uint8Array([0xa1, 0xa2]))
2661
+ })
2662
+
2663
+ test('dedupes recovered copies by ISO fragment-counter position', () => {
2664
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2665
+ const timestamp = 44444n
2666
+
2667
+ expect(
2668
+ reassembler.addFragment({
2669
+ fragmentationIndicator: 1,
2670
+ mpuSequenceNumber: 77,
2671
+ fragmentCounter: 2,
2672
+ movieFragmentSequenceNumber: 12,
2673
+ sampleNumber: 1,
2674
+ sampleOffset: 0,
2675
+ packetSequenceNumber: 2000,
2676
+ data: new Uint8Array([0xa1]),
2677
+ timestamp,
2678
+ rapFlag: true,
2679
+ })
2680
+ ).toBeNull()
2681
+ expect(
2682
+ reassembler.addFragment({
2683
+ fragmentationIndicator: 2,
2684
+ mpuSequenceNumber: 77,
2685
+ fragmentCounter: 1,
2686
+ packetSequenceNumber: 2001,
2687
+ data: new Uint8Array([0xb2]),
2688
+ timestamp,
2689
+ rapFlag: false,
2690
+ })
2691
+ ).toBeNull()
2692
+ expect(
2693
+ reassembler.addFragment({
2694
+ fragmentationIndicator: 3,
2695
+ mpuSequenceNumber: 77,
2696
+ fragmentCounter: 0,
2697
+ packetSequenceNumber: 2002,
2698
+ data: new Uint8Array([0xc3]),
2699
+ timestamp,
2700
+ rapFlag: false,
2701
+ })
2702
+ ).not.toBeNull()
2703
+
2704
+ reassembler.flush()
2705
+
2706
+ expect(
2707
+ reassembler.addFragment({
2708
+ fragmentationIndicator: 1,
2709
+ mpuSequenceNumber: 78,
2710
+ fragmentCounter: 2,
2711
+ movieFragmentSequenceNumber: 13,
2712
+ sampleNumber: 1,
2713
+ sampleOffset: 0,
2714
+ packetSequenceNumber: 3000,
2715
+ data: new Uint8Array([0xd4]),
2716
+ timestamp,
2717
+ rapFlag: true,
2718
+ })
2719
+ ).toBeNull()
2720
+ expect(
2721
+ reassembler.addFragment({
2722
+ fragmentationIndicator: 2,
2723
+ mpuSequenceNumber: 78,
2724
+ fragmentCounter: 1,
2725
+ packetSequenceNumber: 3001,
2726
+ data: new Uint8Array([0xe5]),
2727
+ timestamp,
2728
+ rapFlag: false,
2729
+ })
2730
+ ).toBeNull()
2731
+ expectRejection(
2732
+ () =>
2733
+ reassembler.addFragment({
2734
+ fragmentationIndicator: 2,
2735
+ mpuSequenceNumber: 78,
2736
+ fragmentCounter: 1,
2737
+ packetSequenceNumber: 3001,
2738
+ data: new Uint8Array([0xe5]),
2739
+ timestamp,
2740
+ rapFlag: false,
2741
+ }),
2742
+ 'fragment-counter-mismatch'
2743
+ )
2744
+ const out = reassembler.addFragment({
2745
+ fragmentationIndicator: 3,
2746
+ mpuSequenceNumber: 78,
2747
+ fragmentCounter: 0,
2748
+ packetSequenceNumber: 3002,
2749
+ data: new Uint8Array([0xf6]),
2750
+ timestamp,
2751
+ rapFlag: false,
2752
+ })
2753
+
2754
+ expect(out).not.toBeNull()
2755
+ expect(out!.data).toEqual(new Uint8Array([0xd4, 0xe5, 0xf6]))
2756
+ })
2757
+
2758
+ test('reassembles middle and terminal continuations that arrive before first', () => {
2759
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2760
+ const timestamp = 54321n
2761
+
2762
+ expect(
2763
+ reassembler.addFragment({
2764
+ fragmentationIndicator: 2,
2765
+ mpuSequenceNumber: 45,
2766
+ fragmentCounter: 1,
2767
+ data: new Uint8Array([0xb2]),
2768
+ timestamp,
2769
+ rapFlag: false,
2770
+ packetSequenceNumber: testPacketSequence(45, 1),
2771
+ })
2772
+ ).toBeNull()
2773
+ expect(
2774
+ reassembler.addFragment({
2775
+ fragmentationIndicator: 3,
2776
+ mpuSequenceNumber: 45,
2777
+ fragmentCounter: 0,
2778
+ data: new Uint8Array([0xc3]),
2779
+ timestamp: timestamp + 1n,
2780
+ rapFlag: false,
2781
+ packetSequenceNumber: testPacketSequence(45, 0),
2782
+ })
2783
+ ).toBeNull()
2784
+ expect(reassembler.pendingCount).toBe(1)
2785
+ const out = reassembler.addFragment({
2786
+ fragmentationIndicator: 1,
2787
+ mpuSequenceNumber: 45,
2788
+ fragmentCounter: 2,
2789
+ movieFragmentSequenceNumber: 10,
2790
+ sampleNumber: 18,
2791
+ sampleOffset: 0,
2792
+ data: new Uint8Array([0xa1]),
2793
+ timestamp,
2794
+ rapFlag: true,
2795
+ packetSequenceNumber: testPacketSequence(45, 2),
2796
+ })
2797
+
2798
+ expect(out).not.toBeNull()
2799
+ expect(out!.data).toEqual(new Uint8Array([0xa1, 0xb2, 0xc3]))
2800
+ expect(out!.sampleNumber).toBe(18)
2801
+ expect(out!.movieFragmentSequenceNumber).toBe(10)
2802
+ expect(reassembler.pendingCount).toBe(0)
2803
+ })
2804
+
2805
+ test('rejects a duplicate continuation before first without replacing retained media', () => {
2806
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2807
+ const timestamp = 54322n
2808
+
2809
+ expect(
2810
+ reassembler.addFragment({
2811
+ fragmentationIndicator: 3,
2812
+ mpuSequenceNumber: 46,
2813
+ fragmentCounter: 0,
2814
+ data: new Uint8Array([0xc3]),
2815
+ timestamp,
2816
+ rapFlag: false,
2817
+ packetSequenceNumber: testPacketSequence(46, 0),
2818
+ })
2819
+ ).toBeNull()
2820
+ expectRejection(
2821
+ () =>
2822
+ reassembler.addFragment({
2823
+ fragmentationIndicator: 3,
2824
+ mpuSequenceNumber: 46,
2825
+ fragmentCounter: 0,
2826
+ data: new Uint8Array([0xff]),
2827
+ timestamp: timestamp + 1n,
2828
+ rapFlag: false,
2829
+ packetSequenceNumber: testPacketSequence(46, 0),
2830
+ }),
2831
+ 'fragment-counter-mismatch'
2832
+ )
2833
+ expect(
2834
+ reassembler.addFragment({
2835
+ fragmentationIndicator: 2,
2836
+ mpuSequenceNumber: 46,
2837
+ fragmentCounter: 1,
2838
+ data: new Uint8Array([0xb2]),
2839
+ timestamp: timestamp + 2n,
2840
+ rapFlag: false,
2841
+ packetSequenceNumber: testPacketSequence(46, 1),
2842
+ })
2843
+ ).toBeNull()
2844
+
2845
+ const out = reassembler.addFragment({
2846
+ fragmentationIndicator: 1,
2847
+ mpuSequenceNumber: 46,
2848
+ fragmentCounter: 2,
2849
+ movieFragmentSequenceNumber: 10,
2850
+ sampleNumber: 19,
2851
+ sampleOffset: 0,
2852
+ data: new Uint8Array([0xa1]),
2853
+ timestamp: timestamp + 3n,
2854
+ rapFlag: true,
2855
+ packetSequenceNumber: testPacketSequence(46, 2),
2856
+ })
2857
+
2858
+ expect(out).not.toBeNull()
2859
+ expect(out!.data).toEqual(new Uint8Array([0xa1, 0xb2, 0xc3]))
2860
+ expect(reassembler.pendingCount).toBe(0)
2861
+ })
2862
+
2863
+ test('rejects invalid ISO fragmentation-indicator and counter combinations', () => {
2864
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2865
+ const invalidHeaders = [
2866
+ { fragmentationIndicator: 0, fragmentCounter: 1 },
2867
+ { fragmentationIndicator: 3, fragmentCounter: 1 },
2868
+ { fragmentationIndicator: 1, fragmentCounter: 0 },
2869
+ { fragmentationIndicator: 2, fragmentCounter: 0 },
2870
+ { fragmentationIndicator: -1, fragmentCounter: 1 },
2871
+ { fragmentationIndicator: 4, fragmentCounter: 1 },
2872
+ { fragmentationIndicator: 1.5, fragmentCounter: 1 },
2873
+ { fragmentationIndicator: 1, fragmentCounter: -1 },
2874
+ { fragmentationIndicator: 1, fragmentCounter: 256 },
2875
+ { fragmentationIndicator: 1, fragmentCounter: 1.5 },
2876
+ { fragmentationIndicator: 1, fragmentCounter: Number.NaN },
2877
+ ]
2878
+
2879
+ for (const header of invalidHeaders) {
2880
+ expectRejection(
2881
+ () =>
2882
+ reassembler.addFragment({
2883
+ ...header,
2884
+ mpuSequenceNumber: 48,
2885
+ data: new Uint8Array([0xa1]),
2886
+ timestamp: 54324n,
2887
+ rapFlag: false,
2888
+ }),
2889
+ 'invalid-fragment-state'
2890
+ )
2891
+ }
2892
+
2893
+ expect(reassembler.pendingCount).toBe(0)
2894
+ expect(reassembler.stats.reassembledMFUs).toBe(0)
2895
+ })
2896
+
2897
+ test('reassembles the maximum ISO chain of 256 payloads', () => {
2898
+ const reassembler = new MFUReassembler({ timeoutMs: null })
2899
+ const common = {
2900
+ mpuSequenceNumber: 47,
2901
+ timestamp: 54323n,
2902
+ rapFlag: false,
2903
+ }
2904
+
2905
+ expect(
2906
+ reassembler.addFragment({
2907
+ ...common,
2908
+ fragmentationIndicator: 1,
2909
+ fragmentCounter: 255,
2910
+ itemId: 1,
2911
+ data: new Uint8Array([0]),
2912
+ rapFlag: true,
2913
+ packetSequenceNumber: testPacketSequence(47, 255),
2914
+ })
2915
+ ).toBeNull()
2916
+ for (let counter = 254; counter > 0; counter--) {
2917
+ expect(
2918
+ reassembler.addFragment({
2919
+ ...common,
2920
+ fragmentationIndicator: 2,
2921
+ fragmentCounter: counter,
2922
+ data: new Uint8Array([255 - counter]),
2923
+ packetSequenceNumber: testPacketSequence(47, counter),
2924
+ })
2925
+ ).toBeNull()
2926
+ }
2927
+ const out = reassembler.addFragment({
2928
+ ...common,
2929
+ fragmentationIndicator: 3,
2930
+ fragmentCounter: 0,
2931
+ data: new Uint8Array([255]),
2932
+ packetSequenceNumber: testPacketSequence(47, 0),
2933
+ })
2934
+
2935
+ expect(out?.fragmentCount).toBe(256)
2936
+ expect(out?.data).toEqual(Uint8Array.from({ length: 256 }, (_, index) => index))
2937
+ })
2938
+
2939
+ test('should not reassemble without last fragment (FI=3)', () => {
2940
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
2941
+ reassembler.addFragment({
2942
+ fragmentationIndicator: 1,
2943
+ mpuSequenceNumber: 1,
2944
+ fragmentCounter: 2,
2945
+ itemId: 1,
2946
+ data: new Uint8Array([1]),
2947
+ timestamp: 1000n,
2948
+ rapFlag: false,
2949
+ packetSequenceNumber: testPacketSequence(1, 2),
2950
+ })
2951
+ const result = reassembler.addFragment({
2952
+ fragmentationIndicator: 2,
2953
+ mpuSequenceNumber: 1,
2954
+ fragmentCounter: 1,
2955
+ data: new Uint8Array([2]),
2956
+ timestamp: 1000n,
2957
+ rapFlag: false,
2958
+ packetSequenceNumber: testPacketSequence(1, 1),
2959
+ })
2960
+ expect(result).toBeNull()
2961
+ expect(reassembler.pendingCount).toBe(1)
2962
+ })
2963
+
2964
+ test('bounds continuations that arrive before their first fragments', () => {
2965
+ const reassembler = new MFUReassembler({
2966
+ timeoutMs: null,
2967
+ maxBuffered: 1,
2968
+ })
2969
+ // Data unit 1 parks headless at address 1000 and takes the only slot.
2970
+ expect(
2971
+ reassembler.addFragment({
2972
+ fragmentationIndicator: 3,
2973
+ mpuSequenceNumber: 1,
2974
+ fragmentCounter: 0,
2975
+ data: new Uint8Array([1]),
2976
+ timestamp: 1000n,
2977
+ rapFlag: false,
2978
+ packetSequenceNumber: testPacketSequence(1, 0),
2979
+ })
2980
+ ).toBeNull()
2981
+ expect(reassembler.pendingCount).toBe(1)
2982
+
2983
+ // Data unit 2 parks headless at address 2000. The bound holds by
2984
+ // replacement: data unit 1, admitted first, yields its slot.
2985
+ expect(
2986
+ reassembler.addFragment({
2987
+ fragmentationIndicator: 3,
2988
+ mpuSequenceNumber: 2,
2989
+ fragmentCounter: 0,
2990
+ data: new Uint8Array([2]),
2991
+ timestamp: 1000n,
2992
+ rapFlag: false,
2993
+ packetSequenceNumber: testPacketSequence(2, 0),
2994
+ })
2995
+ ).toBeNull()
2996
+ expect(reassembler.pendingCount).toBe(1)
2997
+ expect(reassembler.getStatus().pendingFragments).toBe(1)
2998
+ expect(reassembler.stats.evictedMemoryLimit).toBe(1)
2999
+
3000
+ // Data unit 2's head inherits the slot its queue already holds, so it
3001
+ // evicts nothing and completes from the parked continuation.
3002
+ const completed = reassembler.addFragment({
3003
+ fragmentationIndicator: 1,
3004
+ mpuSequenceNumber: 2,
3005
+ fragmentCounter: 1,
3006
+ itemId: 2,
3007
+ data: new Uint8Array([4]),
3008
+ timestamp: 1000n,
3009
+ rapFlag: true,
3010
+ packetSequenceNumber: testPacketSequence(2, 1),
3011
+ })
3012
+ expect(completed?.data).toEqual(new Uint8Array([4, 2]))
3013
+ expect(reassembler.stats.evictedMemoryLimit).toBe(1)
3014
+ expect(reassembler.pendingCount).toBe(0)
3015
+
3016
+ // Data unit 1's continuation was released with its slot, so its head
3017
+ // opens a fresh run and waits rather than completing from evicted state.
3018
+ const result = reassembler.addFragment({
3019
+ fragmentationIndicator: 1,
3020
+ mpuSequenceNumber: 1,
3021
+ fragmentCounter: 1,
3022
+ itemId: 1,
3023
+ data: new Uint8Array([3]),
3024
+ timestamp: 1000n,
3025
+ rapFlag: true,
3026
+ packetSequenceNumber: testPacketSequence(1, 1),
3027
+ })
3028
+ expect(result).toBeNull()
3029
+ expect(reassembler.pendingCount).toBe(1)
3030
+ })
3031
+
3032
+ test('expires a continuation that never receives its first fragment', () => {
3033
+ vi.useFakeTimers()
3034
+ try {
3035
+ const reassembler = new MFUReassembler({
3036
+ timeoutMs: 50,
3037
+ maxBuffered: 1,
3038
+ })
3039
+ expect(
3040
+ reassembler.addFragment({
3041
+ fragmentationIndicator: 3,
3042
+ mpuSequenceNumber: 1,
3043
+ fragmentCounter: 0,
3044
+ data: new Uint8Array([1]),
3045
+ timestamp: 1000n,
3046
+ rapFlag: false,
3047
+ packetSequenceNumber: testPacketSequence(1, 0),
3048
+ })
3049
+ ).toBeNull()
3050
+ vi.advanceTimersByTime(50)
3051
+ expect(reassembler.pendingCount).toBe(0)
3052
+ expect(reassembler.stats.timedOutMFUs).toBe(1)
3053
+ } finally {
3054
+ vi.useRealTimers()
3055
+ }
3056
+ })
3057
+
3058
+ test('retimes retained continuations when the timeout policy changes', () => {
3059
+ vi.useFakeTimers()
3060
+ try {
3061
+ const enabled = new MFUReassembler({ timeoutMs: null, maxBuffered: 1 })
3062
+ expect(
3063
+ enabled.addFragment({
3064
+ fragmentationIndicator: 3,
3065
+ mpuSequenceNumber: 1,
3066
+ fragmentCounter: 0,
3067
+ data: new Uint8Array([1]),
3068
+ timestamp: 1000n,
3069
+ rapFlag: false,
3070
+ packetSequenceNumber: testPacketSequence(1, 0),
3071
+ })
3072
+ ).toBeNull()
3073
+ enabled.timeoutMs = 10
3074
+ vi.advanceTimersByTime(10)
3075
+ expect(enabled.pendingCount).toBe(0)
3076
+
3077
+ const disabled = new MFUReassembler({ timeoutMs: 10, maxBuffered: 1 })
3078
+ expect(
3079
+ disabled.addFragment({
3080
+ fragmentationIndicator: 3,
3081
+ mpuSequenceNumber: 2,
3082
+ fragmentCounter: 0,
3083
+ data: new Uint8Array([2]),
3084
+ timestamp: 1000n,
3085
+ rapFlag: false,
3086
+ packetSequenceNumber: testPacketSequence(2, 0),
3087
+ })
3088
+ ).toBeNull()
3089
+ disabled.timeoutMs = null
3090
+ vi.advanceTimersByTime(10)
3091
+ expect(disabled.pendingCount).toBe(1)
3092
+ disabled.flush()
3093
+ } finally {
3094
+ vi.useRealTimers()
3095
+ }
3096
+ })
3097
+
3098
+ test('bounds active data units by evicting the oldest at maxBuffered', () => {
3099
+ const reassembler = new MFUReassembler({
3100
+ timeoutMs: 1000,
3101
+ maxBuffered: 2,
3102
+ })
3103
+
3104
+ // Two runs remain active; the third first fragment displaces the run
3105
+ // admitted longest ago instead of being refused.
3106
+ for (let i = 0; i < 2; i++) {
3107
+ reassembler.addFragment({
3108
+ fragmentationIndicator: 1,
3109
+ mpuSequenceNumber: i,
3110
+ fragmentCounter: 1,
3111
+ itemId: i,
3112
+ data: new Uint8Array([i]),
3113
+ timestamp: BigInt(i * 100),
3114
+ rapFlag: false,
3115
+ packetSequenceNumber: testPacketSequence(i, 1),
3116
+ })
3117
+ }
3118
+ expect(
3119
+ reassembler.addFragment({
3120
+ fragmentationIndicator: 1,
3121
+ mpuSequenceNumber: 2,
3122
+ fragmentCounter: 1,
3123
+ itemId: 2,
3124
+ data: new Uint8Array([2]),
3125
+ timestamp: 200n,
3126
+ rapFlag: false,
3127
+ packetSequenceNumber: testPacketSequence(2, 1),
3128
+ })
3129
+ ).toBeNull()
3130
+ expect(reassembler.pendingCount).toBe(2)
3131
+ // Eviction is a replacement policy, not a deadline: it must not be
3132
+ // reported as a timeout, and it must be separately countable.
3133
+ expect(reassembler.stats.timedOutMFUs).toBe(0)
3134
+ expect(reassembler.stats.evictedMemoryLimit).toBe(1)
3135
+
3136
+ // The evicted run's continuation no longer has a run to join; it parks
3137
+ // as a fresh headless data unit, which displaces the next-oldest run.
3138
+ expect(
3139
+ reassembler.addFragment({
3140
+ fragmentationIndicator: 3,
3141
+ mpuSequenceNumber: 0,
3142
+ fragmentCounter: 0,
3143
+ data: new Uint8Array([9]),
3144
+ timestamp: 300n,
3145
+ rapFlag: false,
3146
+ packetSequenceNumber: testPacketSequence(0, 0),
3147
+ })
3148
+ ).toBeNull()
3149
+ expect(reassembler.pendingCount).toBe(2)
3150
+ expect(reassembler.stats.evictedMemoryLimit).toBe(2)
3151
+ expect(reassembler.stats.timedOutMFUs).toBe(0)
3152
+ })
3153
+
3154
+ test('should report stats correctly', () => {
3155
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
3156
+
3157
+ // One complete MFU
3158
+ reassembler.addFragment({
3159
+ fragmentationIndicator: 0,
3160
+ mpuSequenceNumber: 1,
3161
+ fragmentCounter: 0,
3162
+ itemId: 1,
3163
+ data: new Uint8Array([1]),
3164
+ timestamp: 1000n,
3165
+ rapFlag: false,
3166
+ })
3167
+
3168
+ const status = reassembler.getStatus()
3169
+ expect(status.bufferedMFUs).toBe(0)
3170
+ expect(status.stats.totalFragments).toBe(1)
3171
+ expect(status.stats.reassembledMFUs).toBe(1)
3172
+ })
3173
+
3174
+ test('rejects an overlapping first without replacing the original fragment', () => {
3175
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
3176
+
3177
+ reassembler.addFragment({
3178
+ fragmentationIndicator: 1,
3179
+ mpuSequenceNumber: 1,
3180
+ fragmentCounter: 1,
3181
+ itemId: 1,
3182
+ data: new Uint8Array([0xaa]),
3183
+ timestamp: 1000n,
3184
+ rapFlag: true,
3185
+ source: 'multicast',
3186
+ packetSequenceNumber: testPacketSequence(1, 1),
3187
+ })
3188
+ // Duplicate of fragmentCounter=1
3189
+ expectRejection(
3190
+ () =>
3191
+ reassembler.addFragment({
3192
+ fragmentationIndicator: 1,
3193
+ mpuSequenceNumber: 1,
3194
+ fragmentCounter: 1,
3195
+ itemId: 2,
3196
+ data: new Uint8Array([0xbb]),
3197
+ timestamp: 1000n,
3198
+ rapFlag: true,
3199
+ source: 'moq',
3200
+ packetSequenceNumber: testPacketSequence(1, 1),
3201
+ }),
3202
+ 'overlapping-data-unit'
3203
+ )
3204
+
3205
+ const result = reassembler.addFragment({
3206
+ fragmentationIndicator: 3,
3207
+ mpuSequenceNumber: 1,
3208
+ fragmentCounter: 0,
3209
+ data: new Uint8Array([0xcc]),
3210
+ timestamp: 1000n,
3211
+ rapFlag: false,
3212
+ packetSequenceNumber: testPacketSequence(1, 0),
3213
+ })
3214
+ expect(result).not.toBeNull()
3215
+ expect(result!.data).toEqual(new Uint8Array([0xaa, 0xcc]))
3216
+ expect(result!.fragmentCount).toBe(2)
3217
+ expect(result!.source).toBe('multicast')
3218
+ })
3219
+ })
3220
+
3221
+ test('should retain an assembly with a gap in fragment counters', () => {
3222
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
3223
+
3224
+ // First fragment declares three succeeding payloads.
3225
+ reassembler.addFragment({
3226
+ fragmentationIndicator: 1,
3227
+ mpuSequenceNumber: 1,
3228
+ fragmentCounter: 3,
3229
+ itemId: 1,
3230
+ data: new Uint8Array([0xaa]),
3231
+ timestamp: 1000n,
3232
+ rapFlag: true,
3233
+ packetSequenceNumber: testPacketSequence(1, 3),
3234
+ })
3235
+ // Counter 1 arrives while counter 2 is missing.
3236
+ expect(
3237
+ reassembler.addFragment({
3238
+ fragmentationIndicator: 2,
3239
+ mpuSequenceNumber: 1,
3240
+ fragmentCounter: 1,
3241
+ data: new Uint8Array([0xcc]),
3242
+ timestamp: 1000n,
3243
+ rapFlag: false,
3244
+ packetSequenceNumber: testPacketSequence(1, 1),
3245
+ })
3246
+ ).toBeNull()
3247
+ // Last fragment has the required zero counter.
3248
+ expect(
3249
+ reassembler.addFragment({
3250
+ fragmentationIndicator: 3,
3251
+ mpuSequenceNumber: 1,
3252
+ fragmentCounter: 0,
3253
+ data: new Uint8Array([0xdd]),
3254
+ timestamp: 1000n,
3255
+ rapFlag: false,
3256
+ packetSequenceNumber: testPacketSequence(1, 0),
3257
+ })
3258
+ ).toBeNull()
3259
+ expect(reassembler.pendingCount).toBe(1)
3260
+ expect(reassembler.getStatus().pendingFragments).toBe(3)
3261
+ })
3262
+
3263
+ test('accepts a reordered countdown and rejects a stale duplicate', () => {
3264
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
3265
+
3266
+ // Counters 3, 1, 0 arrive (gap at 2).
3267
+ reassembler.addFragment({
3268
+ fragmentationIndicator: 1,
3269
+ mpuSequenceNumber: 1,
3270
+ fragmentCounter: 3,
3271
+ itemId: 1,
3272
+ data: new Uint8Array([0xaa]),
3273
+ timestamp: 1000n,
3274
+ rapFlag: true,
3275
+ packetSequenceNumber: testPacketSequence(1, 3),
3276
+ })
3277
+ expect(
3278
+ reassembler.addFragment({
3279
+ fragmentationIndicator: 2,
3280
+ mpuSequenceNumber: 1,
3281
+ fragmentCounter: 1,
3282
+ data: new Uint8Array([0xcc]),
3283
+ timestamp: 1000n,
3284
+ rapFlag: false,
3285
+ packetSequenceNumber: testPacketSequence(1, 1),
3286
+ })
3287
+ ).toBeNull()
3288
+ expect(
3289
+ reassembler.addFragment({
3290
+ fragmentationIndicator: 2,
3291
+ mpuSequenceNumber: 1,
3292
+ fragmentCounter: 2,
3293
+ data: new Uint8Array([0xbb]),
3294
+ timestamp: 1000n,
3295
+ rapFlag: false,
3296
+ packetSequenceNumber: testPacketSequence(1, 2),
3297
+ })
3298
+ ).toBeNull()
3299
+ expectRejection(
3300
+ () =>
3301
+ reassembler.addFragment({
3302
+ fragmentationIndicator: 2,
3303
+ mpuSequenceNumber: 1,
3304
+ fragmentCounter: 1,
3305
+ data: new Uint8Array([0xcc]),
3306
+ timestamp: 1001n,
3307
+ rapFlag: false,
3308
+ packetSequenceNumber: testPacketSequence(1, 1),
3309
+ }),
3310
+ 'fragment-counter-mismatch'
3311
+ )
3312
+ const result = reassembler.addFragment({
3313
+ fragmentationIndicator: 3,
3314
+ mpuSequenceNumber: 1,
3315
+ fragmentCounter: 0,
3316
+ data: new Uint8Array([0xdd]),
3317
+ timestamp: 1002n,
3318
+ rapFlag: false,
3319
+ packetSequenceNumber: testPacketSequence(1, 0),
3320
+ })
3321
+ expect(result).not.toBeNull()
3322
+ expect(result!.data).toEqual(new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd]))
3323
+ expect(result!.fragmentCount).toBe(4)
3324
+ })
3325
+
3326
+ test('rejects an overlapping first without discarding queued continuations', () => {
3327
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
3328
+ const fragment = (fragmentationIndicator: number, fragmentCounter: number, byte: number): MFUFragment => ({
3329
+ fragmentationIndicator,
3330
+ mpuSequenceNumber: 2,
3331
+ fragmentCounter,
3332
+ data: new Uint8Array([byte]),
3333
+ timestamp: 1000n,
3334
+ rapFlag: fragmentationIndicator === 1,
3335
+ packetSequenceNumber: testPacketSequence(2, fragmentCounter),
3336
+ ...(fragmentationIndicator === 1 ? { itemId: 9 } : {}),
3337
+ })
3338
+
3339
+ expect(reassembler.addFragment(fragment(1, 3, 0xaa))).toBeNull()
3340
+ expect(reassembler.addFragment(fragment(2, 1, 0xcc))).toBeNull()
3341
+ expectRejection(() => reassembler.addFragment(fragment(1, 1, 0xee)), 'overlapping-data-unit')
3342
+ expect(reassembler.addFragment(fragment(2, 2, 0xbb))).toBeNull()
3343
+ expect(reassembler.addFragment(fragment(3, 0, 0xdd))?.data).toEqual(new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd]))
3344
+ })
3345
+
3346
+ test('rejects a duplicate emitted intrinsic identity', () => {
3347
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
3348
+
3349
+ expect(
3350
+ reassembler.addFragment({
3351
+ fragmentationIndicator: 1,
3352
+ mpuSequenceNumber: 42,
3353
+ fragmentCounter: 1,
3354
+ itemId: 1,
3355
+ data: new Uint8Array([0xaa]),
3356
+ timestamp: 1000n,
3357
+ rapFlag: true,
3358
+ packetSequenceNumber: testPacketSequence(42, 1),
3359
+ })
3360
+ ).toBeNull()
3361
+ expect(
3362
+ reassembler.addFragment({
3363
+ fragmentationIndicator: 3,
3364
+ mpuSequenceNumber: 42,
3365
+ fragmentCounter: 0,
3366
+ data: new Uint8Array([0xab]),
3367
+ timestamp: 1001n,
3368
+ rapFlag: false,
3369
+ packetSequenceNumber: testPacketSequence(42, 0),
3370
+ })
3371
+ ).not.toBeNull()
3372
+ expect(
3373
+ reassembler.addFragment({
3374
+ fragmentationIndicator: 3,
3375
+ mpuSequenceNumber: 42,
3376
+ fragmentCounter: 0,
3377
+ data: new Uint8Array([0xbc]),
3378
+ timestamp: 1002n,
3379
+ rapFlag: false,
3380
+ packetSequenceNumber: testPacketSequence(42, 0, 10),
3381
+ })
3382
+ ).toBeNull()
3383
+ expect(reassembler.pendingCount).toBe(1)
3384
+ expect(
3385
+ reassembler.addFragment({
3386
+ fragmentationIndicator: 1,
3387
+ mpuSequenceNumber: 42,
3388
+ fragmentCounter: 1,
3389
+ itemId: 1,
3390
+ data: new Uint8Array([0xbb]),
3391
+ timestamp: 2000n,
3392
+ rapFlag: true,
3393
+ packetSequenceNumber: testPacketSequence(42, 1, 10),
3394
+ })
3395
+ ).toBeNull()
3396
+ // The queued FI=3 above is addressed to a data unit of MPU 42 whose own
3397
+ // FI=1 has not arrived. This duplicate head names that same address, but
3398
+ // its identity already emitted, so it is dropped before it can open a run
3399
+ // and touches nothing: the queue is retained and reclaimed by its own
3400
+ // arrival timeout.
3401
+ expect(reassembler.pendingCount).toBe(1)
3402
+
3403
+ const result = reassembler.addFragment({
3404
+ fragmentationIndicator: 1,
3405
+ mpuSequenceNumber: 43,
3406
+ fragmentCounter: 1,
3407
+ itemId: 1,
3408
+ data: new Uint8Array([0xcc]),
3409
+ timestamp: 2000n,
3410
+ rapFlag: false,
3411
+ packetSequenceNumber: testPacketSequence(43, 1),
3412
+ })
3413
+ expect(result).toBeNull()
3414
+ expect(
3415
+ reassembler.addFragment({
3416
+ fragmentationIndicator: 3,
3417
+ mpuSequenceNumber: 43,
3418
+ fragmentCounter: 0,
3419
+ data: new Uint8Array([0xdd]),
3420
+ timestamp: 2001n,
3421
+ rapFlag: false,
3422
+ packetSequenceNumber: testPacketSequence(43, 0),
3423
+ })?.data
3424
+ ).toEqual(new Uint8Array([0xcc, 0xdd]))
3425
+ })
3426
+
3427
+ test('does not create active state for an emitted identity duplicate', () => {
3428
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
3429
+
3430
+ expect(
3431
+ reassembler.addFragment({
3432
+ fragmentationIndicator: 1,
3433
+ mpuSequenceNumber: 50,
3434
+ fragmentCounter: 1,
3435
+ movieFragmentSequenceNumber: 10,
3436
+ sampleNumber: 18,
3437
+ sampleOffset: 0,
3438
+ data: new Uint8Array([0xa1]),
3439
+ timestamp: 1000n,
3440
+ rapFlag: true,
3441
+ packetSequenceNumber: testPacketSequence(50, 1),
3442
+ })
3443
+ ).toBeNull()
3444
+ expect(
3445
+ reassembler.addFragment({
3446
+ fragmentationIndicator: 3,
3447
+ mpuSequenceNumber: 50,
3448
+ fragmentCounter: 0,
3449
+ data: new Uint8Array([0xb2]),
3450
+ timestamp: 1000n,
3451
+ rapFlag: false,
3452
+ packetSequenceNumber: testPacketSequence(50, 0),
3453
+ })?.data
3454
+ ).toEqual(new Uint8Array([0xa1, 0xb2]))
3455
+ expect(reassembler.pendingCount).toBe(0)
3456
+
3457
+ expect(
3458
+ reassembler.addFragment({
3459
+ fragmentationIndicator: 1,
3460
+ mpuSequenceNumber: 50,
3461
+ fragmentCounter: 1,
3462
+ movieFragmentSequenceNumber: 10,
3463
+ sampleNumber: 18,
3464
+ sampleOffset: 0,
3465
+ data: new Uint8Array([0xa1]),
3466
+ timestamp: 2000n,
3467
+ rapFlag: true,
3468
+ packetSequenceNumber: testPacketSequence(50, 1, 10),
3469
+ })
3470
+ ).toBeNull()
3471
+ expect(reassembler.pendingCount).toBe(0)
3472
+ })
3473
+
3474
+ test('flush should clear emitted set and allow re-assembly', () => {
3475
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
3476
+
3477
+ // Use fragmented MFU (FI=1 start + FI=3 end) — only fragmented MFUs
3478
+ // go through the #emitted dedup set. FI=0 (complete) bypasses it.
3479
+ const first = reassembler.addFragment({
3480
+ fragmentationIndicator: 1,
3481
+ mpuSequenceNumber: 42,
3482
+ fragmentCounter: 1,
3483
+ itemId: 1,
3484
+ data: new Uint8Array([0xaa]),
3485
+ timestamp: 1000n,
3486
+ rapFlag: true,
3487
+ packetSequenceNumber: testPacketSequence(42, 1),
3488
+ })
3489
+ expect(first).toBeNull() // first fragment, not yet complete
3490
+ const assembled = reassembler.addFragment({
3491
+ fragmentationIndicator: 3,
3492
+ mpuSequenceNumber: 42,
3493
+ fragmentCounter: 0,
3494
+ data: new Uint8Array([0xbb]),
3495
+ timestamp: 1000n,
3496
+ rapFlag: true,
3497
+ packetSequenceNumber: testPacketSequence(42, 0),
3498
+ })
3499
+ expect(assembled).not.toBeNull() // complete — mpuSeq 42 now in emitted set
3500
+
3501
+ // Rejected before flush — same mpuSeq already emitted
3502
+ expect(
3503
+ reassembler.addFragment({
3504
+ fragmentationIndicator: 1,
3505
+ mpuSequenceNumber: 42,
3506
+ fragmentCounter: 1,
3507
+ itemId: 1,
3508
+ data: new Uint8Array([0xcc]),
3509
+ timestamp: 1000n,
3510
+ rapFlag: true,
3511
+ packetSequenceNumber: testPacketSequence(42, 1, 10),
3512
+ })
3513
+ ).toBeNull()
3514
+
3515
+ // After flush, same mpuSeq should work again
3516
+ reassembler.flush()
3517
+ const r2 = reassembler.addFragment({
3518
+ fragmentationIndicator: 1,
3519
+ mpuSequenceNumber: 42,
3520
+ fragmentCounter: 1,
3521
+ itemId: 1,
3522
+ data: new Uint8Array([0xdd]),
3523
+ timestamp: 1000n,
3524
+ rapFlag: true,
3525
+ packetSequenceNumber: testPacketSequence(42, 1),
3526
+ })
3527
+ expect(r2).toBeNull() // first fragment of new assembly
3528
+ const r3 = reassembler.addFragment({
3529
+ fragmentationIndicator: 3,
3530
+ mpuSequenceNumber: 42,
3531
+ fragmentCounter: 0,
3532
+ data: new Uint8Array([0xee]),
3533
+ timestamp: 1000n,
3534
+ rapFlag: true,
3535
+ packetSequenceNumber: testPacketSequence(42, 0),
3536
+ })
3537
+ expect(r3).not.toBeNull()
3538
+ expect(r3!.data).toEqual(new Uint8Array([0xdd, 0xee]))
3539
+ })
3540
+
3541
+ describe('createFragment', () => {
3542
+ test('should create fragment from complete timed MMTP packet', () => {
3543
+ // Build: MMTP(12) + MPU(8, T=1 FI=0 payloadLength=25) + MFU_DU(14) + data(5)
3544
+ const packet = new Uint8Array(12 + 8 + 14 + 5)
3545
+ // MMTP header: byte0 = rap=1, byte1 = MPU type
3546
+ packet[0] = 0x01 // RAP flag set
3547
+ packet[1] = 0x00 // packet_type = MPU
3548
+ // packetId = 1
3549
+ const view = new DataView(packet.buffer)
3550
+ view.setUint16(2, 1, false)
3551
+ view.setUint32(4, 42, false) // timestamp
3552
+ view.setUint32(8, 100, false) // sequence number
3553
+
3554
+ // MPU header at offset 12
3555
+ view.setUint16(12, 25, false) // 6 remaining MPU bytes + 14 DU + 5 media
3556
+ packet[14] = 0x28 // FT=2, T=1, FI=0, A=0
3557
+ packet[15] = 0 // fragment_counter
3558
+ view.setUint32(16, 999, false) // mpuSequenceNumber
3559
+
3560
+ // MFU DU header (14 bytes) at offset 20.
3561
+ view.setUint32(20, 7, false) // movie_fragment_sequence_number
3562
+ view.setUint32(24, 1, false) // sample_number
3563
+ // Video data at offset 34
3564
+ packet[34] = 0xde
3565
+ packet[35] = 0xad
3566
+ packet[36] = 0xbe
3567
+ packet[37] = 0xef
3568
+ packet[38] = 0x01
3569
+
3570
+ const frag = createFragment(packet)
3571
+ expect(frag.mpuSequenceNumber).toBe(999)
3572
+ expect(frag.fragmentationIndicator).toBe(0)
3573
+ expect(frag.rapFlag).toBe(true)
3574
+ expect(frag.movieFragmentSequenceNumber).toBe(7)
3575
+ expect(frag.sampleNumber).toBe(1)
3576
+ expect(frag.sampleOffset).toBe(0)
3577
+ expect(frag.data.length).toBe(5) // 5 bytes of video data
3578
+ })
3579
+
3580
+ test('reads Item_ID from a non-timed MFU DU header', () => {
3581
+ const packet = new Uint8Array(12 + 8 + 4 + 2)
3582
+ const view = new DataView(packet.buffer)
3583
+ view.setUint16(2, 1, false)
3584
+ view.setUint32(4, 42, false)
3585
+ view.setUint32(8, 100, false)
3586
+ view.setUint16(12, 12, false) // 6 remaining MPU bytes + 4 DU + 2 media
3587
+ packet[14] = 0x20 // FT=2, T=0, FI=0
3588
+ view.setUint32(16, 999, false)
3589
+ view.setUint32(20, 0x01020304, false)
3590
+ packet.set([0xde, 0xad], 24)
3591
+
3592
+ const fragment = createFragment(packet)
3593
+ expect(fragment.itemId).toBe(0x01020304)
3594
+ expect(fragment.data).toEqual(new Uint8Array([0xde, 0xad]))
3595
+ })
3596
+ })
3597
+
3598
+ describe('processMMTPWithReassembly', () => {
3599
+ test('should skip FEC repair packets', () => {
3600
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
3601
+ const packet = new Uint8Array(12 + 8 + 14 + 4)
3602
+ packet[0] = 0x10 // fecType=2 (repair)
3603
+ const result = processMMTPWithReassembly(packet, reassembler)
3604
+ expect(result).toBeNull()
3605
+ })
3606
+
3607
+ test('should return reassembled MFU for complete packet', () => {
3608
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
3609
+ const packet = new Uint8Array(12 + 8 + 14 + 4)
3610
+ packet[0] = 0x01 // RAP
3611
+ packet[1] = 0x00 // MPU
3612
+ const view = new DataView(packet.buffer)
3613
+ view.setUint16(2, 1, false) // packetId
3614
+ view.setUint32(4, 100, false) // timestamp
3615
+ view.setUint32(8, 1, false) // seq
3616
+ // MPU: payloadLength=24 (6 + 14 + 4), FT=2 T=1 FI=0 A=0
3617
+ view.setUint16(12, 24, false)
3618
+ packet[14] = 0x28
3619
+ view.setUint32(16, 42, false) // mpuSeq
3620
+ view.setUint32(20, 7, false) // movie_fragment_sequence_number
3621
+ view.setUint32(24, 1, false) // sample_number
3622
+
3623
+ packet[34] = 0x00
3624
+ packet[35] = 0x00
3625
+ packet[36] = 0x00
3626
+ packet[37] = 0x01
3627
+
3628
+ const result = processMMTPWithReassembly(packet, reassembler)
3629
+ expect(result).not.toBeNull()
3630
+ expect(result!.mpuSequenceNumber).toBe(42)
3631
+ expect(result!.rapFlag).toBe(true)
3632
+ })
3633
+ })
3634
+
3635
+ describe('strict delivery provenance', () => {
3636
+ test('folds to multicast only when every fragment is explicitly multicast', () => {
3637
+ expect(deliveryProvenanceOf([{ source: 'multicast' }, { source: 'multicast' }])).toBe('multicast')
3638
+ })
3639
+
3640
+ test('a single moq fragment makes the whole frame mixed', () => {
3641
+ expect(deliveryProvenanceOf([{ source: 'multicast' }, { source: 'moq' }])).toBe('mixed')
3642
+ // ...and it does not matter where in the run it lands.
3643
+ expect(deliveryProvenanceOf([{ source: 'moq' }, { source: 'multicast' }])).toBe('mixed')
3644
+ })
3645
+
3646
+ test('an unattributed fragment degrades multicast to unknown, never to proven', () => {
3647
+ expect(deliveryProvenanceOf([{ source: 'multicast' }, {}])).toBe('unknown')
3648
+ expect(deliveryProvenanceOf([{}])).toBe('unknown')
3649
+ })
3650
+
3651
+ test('mixed absorbs unknown — a moq byte is worse news than a missing label', () => {
3652
+ expect(deliveryProvenanceOf([{ source: 'moq' }, {}])).toBe('mixed')
3653
+ expect(deliveryProvenanceOf([{}, { source: 'moq' }])).toBe('mixed')
3654
+ })
3655
+
3656
+ test('an empty fragment set proves nothing', () => {
3657
+ expect(deliveryProvenanceOf([])).toBe('unknown')
3658
+ })
3659
+
3660
+ test('the fold never upgrades a verdict', () => {
3661
+ expect(foldDeliveryProvenance('mixed', 'multicast')).toBe('mixed')
3662
+ expect(foldDeliveryProvenance('unknown', 'multicast')).toBe('unknown')
3663
+ expect(foldDeliveryProvenance('unknown', 'moq')).toBe('mixed')
3664
+ })
3665
+
3666
+ test('a multicast head with a non-multicast tail is NOT multicast-exclusive', () => {
3667
+ // This is the leak this field exists to close. `source` legitimately
3668
+ // reports 'multicast' — head-fragment-wins is the routing contract —
3669
+ // but the frame contains bytes that never crossed a multicast socket,
3670
+ // so it must never be counted as physical-delivery evidence.
3671
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
3672
+ reassembler.addFragment({
3673
+ fragmentationIndicator: 1,
3674
+ mpuSequenceNumber: 1,
3675
+ fragmentCounter: 1,
3676
+ itemId: 1,
3677
+ data: new Uint8Array([0xaa]),
3678
+ timestamp: 1000n,
3679
+ rapFlag: true,
3680
+ source: 'multicast',
3681
+ packetSequenceNumber: testPacketSequence(1, 1),
3682
+ })
3683
+ const result = reassembler.addFragment({
3684
+ fragmentationIndicator: 3,
3685
+ mpuSequenceNumber: 1,
3686
+ fragmentCounter: 0,
3687
+ data: new Uint8Array([0xcc]),
3688
+ timestamp: 1000n,
3689
+ rapFlag: false,
3690
+ source: 'moq',
3691
+ packetSequenceNumber: testPacketSequence(1, 0),
3692
+ })
3693
+ expect(result).not.toBeNull()
3694
+ expect(result!.fragmentCount).toBe(2)
3695
+ expect(result!.source).toBe('multicast')
3696
+ expect(result!.deliveryProvenance).toBe('mixed')
3697
+ })
3698
+
3699
+ test('an all-multicast fragmented frame is multicast-exclusive', () => {
3700
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
3701
+ reassembler.addFragment({
3702
+ fragmentationIndicator: 1,
3703
+ mpuSequenceNumber: 2,
3704
+ fragmentCounter: 1,
3705
+ itemId: 1,
3706
+ data: new Uint8Array([0xaa]),
3707
+ timestamp: 2000n,
3708
+ rapFlag: true,
3709
+ source: 'multicast',
3710
+ packetSequenceNumber: testPacketSequence(2, 1),
3711
+ })
3712
+ const result = reassembler.addFragment({
3713
+ fragmentationIndicator: 3,
3714
+ mpuSequenceNumber: 2,
3715
+ fragmentCounter: 0,
3716
+ data: new Uint8Array([0xcc]),
3717
+ timestamp: 2000n,
3718
+ rapFlag: false,
3719
+ source: 'multicast',
3720
+ packetSequenceNumber: testPacketSequence(2, 0),
3721
+ })
3722
+ expect(result).not.toBeNull()
3723
+ expect(result!.deliveryProvenance).toBe('multicast')
3724
+ })
3725
+
3726
+ test('an unlabelled complete frame proves nothing', () => {
3727
+ const reassembler = new MFUReassembler({ timeoutMs: 1000 })
3728
+ const result = reassembler.addFragment({
3729
+ fragmentationIndicator: 0,
3730
+ mpuSequenceNumber: 3,
3731
+ fragmentCounter: 0,
3732
+ itemId: 1,
3733
+ data: new Uint8Array([1]),
3734
+ timestamp: 3000n,
3735
+ rapFlag: true,
3736
+ })
3737
+ expect(result).not.toBeNull()
3738
+ expect(result!.deliveryProvenance).toBe('unknown')
3739
+ })
3740
+ })
3741
+ }