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