@blockcast/mmt-container 2.1.0 → 2.2.0-main.079cd7664c0b
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 +29 -17
- package/dist/browser-runtime.js +1271 -247
- package/dist/browser-runtime.js.map +4 -4
- package/dist/carp-catalog.js.map +1 -1
- package/dist/catalog-fec-params.d.ts +17 -1
- package/dist/catalog-fec-params.d.ts.map +1 -1
- package/dist/catalog-fec-params.js +15 -0
- package/dist/catalog-fec-params.js.map +2 -2
- package/dist/catalog-network-source.js.map +1 -1
- package/dist/cmcd-producer.d.ts +65 -47
- package/dist/cmcd-producer.d.ts.map +1 -1
- package/dist/cmcd-producer.js +138 -109
- package/dist/cmcd-producer.js.map +2 -2
- package/dist/delivery-catalog.js +26 -16
- package/dist/delivery-catalog.js.map +2 -2
- package/dist/delta-fallback-config.js.map +1 -1
- package/dist/fec-manager.d.ts +33 -1
- package/dist/fec-manager.d.ts.map +1 -1
- package/dist/fec-manager.js +213 -6
- package/dist/fec-manager.js.map +2 -2
- package/dist/fec-state.js.map +1 -1
- package/dist/fec-worker-setup.d.ts +1 -1
- package/dist/fec-worker-setup.js +1 -1
- package/dist/fec-worker-setup.js.map +1 -1
- package/dist/held-layer-admission.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +2 -2
- package/dist/iso-block-locator.d.ts +9 -0
- package/dist/iso-block-locator.d.ts.map +1 -1
- package/dist/iso-block-locator.js +17 -0
- package/dist/iso-block-locator.js.map +2 -2
- package/dist/iso-bmff-timing.d.ts +10 -1
- package/dist/iso-bmff-timing.d.ts.map +1 -1
- package/dist/iso-bmff-timing.js +36 -2
- package/dist/iso-bmff-timing.js.map +2 -2
- package/dist/media-router.js.map +1 -1
- package/dist/mfu-reassembler.js +12 -19
- package/dist/mfu-reassembler.js.map +2 -2
- package/dist/mmtp-container-parser.d.ts +37 -2
- package/dist/mmtp-container-parser.d.ts.map +1 -1
- package/dist/mmtp-container-parser.js +98 -28
- package/dist/mmtp-container-parser.js.map +2 -2
- package/dist/mmtp-container-parser.no-modules.js +222 -45
- package/dist/mmtp-container-parser.no-modules.js.map +3 -3
- package/dist/mmtp-decode-pipeline.js +97 -28
- package/dist/mmtp-decode-pipeline.js.map +2 -2
- package/dist/mmtp-fec-session.d.ts +41 -3
- package/dist/mmtp-fec-session.d.ts.map +1 -1
- package/dist/mmtp-fec-session.js +118 -13
- package/dist/mmtp-fec-session.js.map +3 -3
- package/dist/mmtp-router.d.ts +25 -2
- package/dist/mmtp-router.d.ts.map +1 -1
- package/dist/mmtp-router.js +1718 -54
- package/dist/mmtp-router.js.map +4 -4
- package/dist/multicast-auth-catalog.js.map +1 -1
- package/dist/multicast-input.js.map +1 -1
- package/dist/multicast-manager.d.ts +15 -2
- package/dist/multicast-manager.d.ts.map +1 -1
- package/dist/multicast-manager.js +15652 -14939
- package/dist/multicast-manager.js.map +4 -4
- package/dist/multicast-transport.d.ts +11 -3
- package/dist/multicast-transport.d.ts.map +1 -1
- package/dist/multicast-transport.js +281 -35
- package/dist/multicast-transport.js.map +4 -4
- package/dist/multicast-unwrap.js +12 -19
- package/dist/multicast-unwrap.js.map +2 -2
- package/dist/no-modules.js +215 -42
- package/dist/no-modules.js.map +3 -3
- package/dist/repair-fetch.js.map +1 -1
- package/dist/repair-layer-subscriptions.d.ts +82 -0
- package/dist/repair-layer-subscriptions.d.ts.map +1 -0
- package/dist/repair-layer-subscriptions.js +192 -0
- package/dist/repair-layer-subscriptions.js.map +7 -0
- package/dist/signaling.js.map +2 -2
- package/dist/timestamp-codec.js +12 -19
- package/dist/timestamp-codec.js.map +2 -2
- package/dist/track-session.d.ts +1 -1
- package/dist/track-session.js.map +1 -1
- package/dist/wasm-mfu-reassembler.js.map +1 -1
- package/dist/wasm-mmtp-container-parser.js +97 -28
- package/dist/wasm-mmtp-container-parser.js.map +2 -2
- package/package.json +8 -5
- package/schemas/catalog.schema.json +4 -0
- package/src/catalog-fec-params.ts +36 -1
- package/src/cmcd-producer.ts +194 -179
- package/src/fec-manager.ts +256 -6
- package/src/fec-worker-setup.ts +2 -2
- package/src/index.ts +18 -2
- package/src/iso-block-locator.ts +19 -0
- package/src/iso-bmff-timing.ts +40 -1
- package/src/mmtp-container-parser.ts +96 -8
- package/src/mmtp-fec-session.ts +115 -15
- package/src/mmtp-router.ts +344 -27
- package/src/multicast-manager.ts +26 -5
- package/src/multicast-transport.ts +44 -20
- package/src/repair-layer-subscriptions.ts +261 -0
- package/src/track-session.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blockcast/mmt-container",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0-main.079cd7664c0b",
|
|
4
4
|
"description": "MMTP/LOC container parsing for MMT over MoQ and SSM",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -169,10 +169,10 @@
|
|
|
169
169
|
"test:watch": "vitest"
|
|
170
170
|
},
|
|
171
171
|
"dependencies": {
|
|
172
|
-
"@blockcast/fec-worker": "
|
|
173
|
-
"@blockcast/mmt-base": "
|
|
174
|
-
"@blockcast/mmt-fec": "
|
|
175
|
-
"@blockcast/mmt-manifest-verify": "
|
|
172
|
+
"@blockcast/fec-worker": "2.2.0-main.079cd7664c0b",
|
|
173
|
+
"@blockcast/mmt-base": "2.2.0-main.079cd7664c0b",
|
|
174
|
+
"@blockcast/mmt-fec": "2.2.0-main.079cd7664c0b",
|
|
175
|
+
"@blockcast/mmt-manifest-verify": "2.2.0-main.079cd7664c0b",
|
|
176
176
|
"@noble/curves": "^1.9.7",
|
|
177
177
|
"@noble/hashes": "^1.8.0",
|
|
178
178
|
"ajv": "^8.17.1",
|
|
@@ -180,6 +180,9 @@
|
|
|
180
180
|
},
|
|
181
181
|
"devDependencies": {
|
|
182
182
|
"@blockcast/mmt-transport": "file:../transport",
|
|
183
|
+
"@svta/cml-cmcd": "^2.8.4",
|
|
184
|
+
"@svta/cml-structured-field-values": "^1.1.7",
|
|
185
|
+
"@svta/cml-utils": "^1.7.0",
|
|
183
186
|
"esbuild": "^0.28.0",
|
|
184
187
|
"hash-wasm": "^4.12.0",
|
|
185
188
|
"typescript": "^5.3.0",
|
|
@@ -138,6 +138,10 @@
|
|
|
138
138
|
"$ref": "#/$defs/positiveInteger",
|
|
139
139
|
"description": "Keyframe interval in the track's timescale ticks (moqcast-draft §4.4.3). Exact where keyframeIntervalMs rounds (e.g. 30000/1001 video); when both are present, keyframeIntervalTicks wins."
|
|
140
140
|
},
|
|
141
|
+
"deliveryMode": {
|
|
142
|
+
"type": "string",
|
|
143
|
+
"description": "How the publisher sends this track's objects (draft-ramadan-moq-fec-catalog-00 §3.3, proposed in moqcast-draft#26): \"datagram\" or \"stream\". A receiver treats a missing or unrecognized value as \"datagram\", so any string is admitted here rather than rejected. Allowed on source, repair and symbol tracks."
|
|
144
|
+
},
|
|
141
145
|
"fec": { "$ref": "#/$defs/fec" }
|
|
142
146
|
},
|
|
143
147
|
"allOf": [
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
RepairOverlayParams,
|
|
31
31
|
} from "./repair-layers.js";
|
|
32
32
|
import type { RepairTarget } from "./repair-fetch.js";
|
|
33
|
+
import type { MoqRepairDelivery } from "./repair-layer-subscriptions.js";
|
|
33
34
|
|
|
34
35
|
export { catalogFecInterleaveMs, type FecInterleaveCatalog };
|
|
35
36
|
export { FEC_BLOCK_PROFILES, isFecBlockProfile, parseFecBlockProfile, type FecBlockProfile };
|
|
@@ -273,6 +274,9 @@ export type TrackFecParams = {
|
|
|
273
274
|
/** Keyframe overlay instances declared for this track. Present (possibly
|
|
274
275
|
* empty) whenever repairLayers is. */
|
|
275
276
|
repairOverlays?: readonly RepairOverlayParams[];
|
|
277
|
+
/** The publisher's MoQ delivery mode (`catalogTrackDeliveryMode`).
|
|
278
|
+
* Present whenever repairLayers is; `RepairLayerSubscriptions` reads it. */
|
|
279
|
+
deliveryMode?: MoqRepairDelivery;
|
|
276
280
|
fps?: number;
|
|
277
281
|
sampleRate?: number;
|
|
278
282
|
};
|
|
@@ -293,7 +297,7 @@ const NO_FEC: TrackFecParams = {
|
|
|
293
297
|
* A keyframe overlay is a separate FEC instance with its own K, its own
|
|
294
298
|
* RSB_length and its own RS_ID space, so it is never added here however it was
|
|
295
299
|
* joined. Counting it would overstate the base instance's P and misreport
|
|
296
|
-
* CMCD `
|
|
300
|
+
* CMCD `com.blockcast-fp`.
|
|
297
301
|
*
|
|
298
302
|
* Falls back to P_0 when the track declares no layers, which is the
|
|
299
303
|
* single-layer identity rather than a default: with one layer, P_0 is the
|
|
@@ -368,6 +372,36 @@ export function resolveRepairLayers(
|
|
|
368
372
|
};
|
|
369
373
|
}
|
|
370
374
|
|
|
375
|
+
/** The catalog value a JSON Pointer (RFC 6901) names, or undefined. */
|
|
376
|
+
function valueAt(catalog: unknown, pointer: string): unknown {
|
|
377
|
+
let node = catalog;
|
|
378
|
+
for (const segment of pointer.split("/").slice(1)) {
|
|
379
|
+
if (typeof node !== "object" || node === null) return undefined;
|
|
380
|
+
node = (node as Record<string, unknown>)[segment.replace(/~1/g, "/").replace(/~0/g, "~")];
|
|
381
|
+
}
|
|
382
|
+
return node;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The publisher's MoQ delivery mode for one source track: `deliveryMode` on
|
|
387
|
+
* its base track entry (the hang rendition or MSF `tracks[]` entry its FEC
|
|
388
|
+
* binds to). `stream` means QUIC retransmits, so the receiver subscribes no
|
|
389
|
+
* repair track. Absent or any other value is `datagram`, FFmpeg's
|
|
390
|
+
* `moq_delivery_mode` default: a receiver never silently drops repair on a
|
|
391
|
+
* lossy path. When a track is declared both as a hang rendition and as an
|
|
392
|
+
* MSF `tracks[]` entry, the rendition is authoritative: a `deliveryMode` on
|
|
393
|
+
* the shadowed `tracks[]` entry is not read (fails safe to `datagram`).
|
|
394
|
+
* Never throws.
|
|
395
|
+
*/
|
|
396
|
+
export function catalogTrackDeliveryMode(
|
|
397
|
+
catalog: RepairLayerCatalog | undefined,
|
|
398
|
+
source: string | CatalogTrackRef,
|
|
399
|
+
): MoqRepairDelivery {
|
|
400
|
+
const binding = bindingOf(catalog, source);
|
|
401
|
+
const entry = binding ? valueAt(catalog, binding.pointer) : undefined;
|
|
402
|
+
return (entry as { deliveryMode?: unknown } | undefined)?.deliveryMode === "stream" ? "stream" : "datagram";
|
|
403
|
+
}
|
|
404
|
+
|
|
371
405
|
/** A MoQ Transport priority (0..255, lower first), or null. */
|
|
372
406
|
function moqtPriority(value: unknown): number | null {
|
|
373
407
|
return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= 255 ? (value as number) : null;
|
|
@@ -444,6 +478,7 @@ function fecParams(
|
|
|
444
478
|
repairTrackName: base.repairTrack,
|
|
445
479
|
repairLayers: repair.baseLayers,
|
|
446
480
|
repairOverlays: repair.overlays,
|
|
481
|
+
deliveryMode: catalogTrackDeliveryMode(catalog, track!),
|
|
447
482
|
...mediaRate,
|
|
448
483
|
};
|
|
449
484
|
}
|
package/src/cmcd-producer.ts
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CmcdProducer —
|
|
2
|
+
* CmcdProducer — CMCD v2 Event-mode reports (CTA-5004-B §2.2) from a player.
|
|
3
3
|
*
|
|
4
|
-
* Maps FecTrackStats snapshots and
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Maps FecTrackStats snapshots, repair lifecycle events and transport state to
|
|
5
|
+
* newline-separated `application/cmcd` records (CTA-5004-B §3.3), each an
|
|
6
|
+
* RFC 8941 dictionary carrying `v=2`, `ts`, `e`, `sn` and `sid`:
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
8
|
+
* - `e=t` — time-interval heartbeat, Table 1 keys only;
|
|
9
|
+
* - `e=ce` — custom event `cen="com.blockcast-fec"` carrying the
|
|
10
|
+
* `com.blockcast-*` counters at the heartbeat cadence. Custom keys ride
|
|
11
|
+
* only on `e=ce`: §2.2 admits on Event reports only Table 1 keys, and the
|
|
12
|
+
* Table 1 `cen` row admits a custom key-value pair with a custom event;
|
|
13
|
+
* - `e=e` — FEC failed closed, with `ec`.
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
15
|
+
* Custom values are STRING or TOKEN of at most 64 characters (§4.1): numbers
|
|
16
|
+
* go out as strings. A value that is unknown is omitted, never sent as zero
|
|
17
|
+
* (§3). Counters are absolute since session start, so a lost report loses
|
|
18
|
+
* nothing.
|
|
19
|
+
*
|
|
20
|
+
* Does NOT call fetch(): the player POSTs the body handed to `onCmcdPost` with
|
|
21
|
+
* `Content-Type: application/cmcd`. The heartbeat is driven by the snapshots
|
|
22
|
+
* the player already feeds; there is no timer here.
|
|
14
23
|
*/
|
|
15
24
|
|
|
16
25
|
import type { FecTrackStats } from '@blockcast/fec-worker'
|
|
17
26
|
import type { FecFailClosedReason } from './fec-manager.js'
|
|
27
|
+
import type { RepairLifecycleEvent } from './multicast-manager.js'
|
|
18
28
|
|
|
19
29
|
// ---------------------------------------------------------------------------
|
|
20
30
|
// Types
|
|
21
31
|
// ---------------------------------------------------------------------------
|
|
22
32
|
|
|
23
|
-
/** Source-auth counters
|
|
33
|
+
/** Source-auth counters: records verified, and drops per reason. */
|
|
24
34
|
export interface ManifestVerificationStats {
|
|
25
35
|
verified: number
|
|
26
36
|
failures: Record<string, number>
|
|
@@ -28,6 +38,11 @@ export interface ManifestVerificationStats {
|
|
|
28
38
|
|
|
29
39
|
/** Transport state snapshot, built by the player wiring layer from SocketPool. */
|
|
30
40
|
export interface TransportSnapshot {
|
|
41
|
+
/**
|
|
42
|
+
* CMCD has no user-agent value: report a `'user-agent'` join
|
|
43
|
+
* (`ResolvedMulticastTransport`, the browser's own MulticastReceiver) as
|
|
44
|
+
* `'native'`, since the browser does not say which leg it chose.
|
|
45
|
+
*/
|
|
31
46
|
transport: 'native' | 'amt' | 'moq'
|
|
32
47
|
/** IPv4/IPv6 multicast group address */
|
|
33
48
|
multicastGroup?: string
|
|
@@ -37,11 +52,11 @@ export interface TransportSnapshot {
|
|
|
37
52
|
amtRelay?: string
|
|
38
53
|
}
|
|
39
54
|
|
|
40
|
-
/** Transport transition event identifiers for
|
|
55
|
+
/** Transport transition event identifiers for `com.blockcast-ct`. */
|
|
41
56
|
export type TransportTransitionEvent = 'mc-start' | 'mc-to-uc' | 'amt-start'
|
|
42
57
|
|
|
43
58
|
/**
|
|
44
|
-
*
|
|
59
|
+
* Play-mode state (`com.blockcast-pm`):
|
|
45
60
|
* - `full` — base + delta both active
|
|
46
61
|
* - `base-only` — delta fell back; base carries playback
|
|
47
62
|
* - `single-track` — stream is not a multi-track switching set (legacy)
|
|
@@ -50,15 +65,27 @@ export type PlayMode = 'full' | 'base-only' | 'single-track'
|
|
|
50
65
|
|
|
51
66
|
const PLAY_MODES: readonly PlayMode[] = ['full', 'base-only', 'single-track']
|
|
52
67
|
|
|
53
|
-
/**
|
|
54
|
-
export
|
|
68
|
+
/** An RFC 8941 token (bare, unquoted). */
|
|
69
|
+
export interface CmcdToken {
|
|
70
|
+
readonly token: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A record value: integer, string, token, or inner list of strings. */
|
|
74
|
+
export type CmcdValue = number | string | CmcdToken | readonly string[]
|
|
75
|
+
|
|
76
|
+
/** One CMCD record, before encoding. */
|
|
77
|
+
export type CmcdRecord = Record<string, CmcdValue>
|
|
55
78
|
|
|
56
79
|
/** Options for CmcdProducer construction. */
|
|
57
80
|
export interface CmcdProducerOptions {
|
|
58
|
-
/** Fired
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
|
|
81
|
+
/** Fired with an `application/cmcd` body: one record per line. */
|
|
82
|
+
onCmcdPost?: (body: string) => void
|
|
83
|
+
/** Heartbeat period for the `e=t` and counter reports. Default 30 s (CTA-5004-B §5). */
|
|
84
|
+
intervalMs?: number
|
|
85
|
+
/** CTA-5004-B `sid`. Default: a random UUID per producer. */
|
|
86
|
+
sessionId?: string
|
|
87
|
+
/** Wall clock in ms since the epoch, for `ts` and the heartbeat. Default `Date.now`. */
|
|
88
|
+
now?: () => number
|
|
62
89
|
}
|
|
63
90
|
|
|
64
91
|
/** Snapshot data passed to onSnapshot(). */
|
|
@@ -75,95 +102,80 @@ export interface TransportOnlySnapshotData {
|
|
|
75
102
|
switchMiscount?: number
|
|
76
103
|
}
|
|
77
104
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
105
|
+
const DEFAULT_INTERVAL_MS = 30_000
|
|
106
|
+
const CUSTOM_PREFIX = 'com.blockcast-'
|
|
107
|
+
/** CTA-5004-B §4.1: a custom value is at most 64 characters. */
|
|
108
|
+
const MAX_CUSTOM_VALUE = 64
|
|
81
109
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
110
|
+
const TRANSPORT_TOKEN: Record<TransportSnapshot['transport'], string> = {
|
|
111
|
+
native: 'ssm',
|
|
112
|
+
amt: 'amt',
|
|
113
|
+
moq: 'moq',
|
|
85
114
|
}
|
|
86
115
|
|
|
87
116
|
// ---------------------------------------------------------------------------
|
|
88
|
-
//
|
|
117
|
+
// Encoding (RFC 8941 dictionary, keys sorted — CTA-5004-B §3)
|
|
89
118
|
// ---------------------------------------------------------------------------
|
|
90
119
|
|
|
91
|
-
/**
|
|
92
|
-
function
|
|
93
|
-
|
|
94
|
-
const cfg = stats.config
|
|
95
|
-
|
|
96
|
-
params['x-bc-fec-algo'] = cfg.algorithm
|
|
97
|
-
params['x-bc-fec-k'] = cfg.k
|
|
98
|
-
// P as this receiver JOINED it: the worker is configured from
|
|
99
|
-
// TrackSessionConfig.joinedRepairCount, the sum of repairSymbols over the
|
|
100
|
-
// joined base layers. Not the catalog total, which a receiver on a clean path
|
|
101
|
-
// does not receive, and never including a keyframe overlay (separate FEC
|
|
102
|
-
// instance with its own RSB_length).
|
|
103
|
-
params['x-bc-fec-p'] = cfg.repairCount
|
|
104
|
-
params['x-bc-fec-recovery'] = parseFloat((stats.recoveryRate / 100).toFixed(4))
|
|
105
|
-
|
|
106
|
-
return params
|
|
120
|
+
/** RFC 8941 §3.3.3 string: printable ASCII, `\` and `"` escaped. */
|
|
121
|
+
function sfString(value: string): string {
|
|
122
|
+
return `"${value.replace(/[^\x20-\x7e]/g, '').replace(/[\\"]/g, '\\$&')}"`
|
|
107
123
|
}
|
|
108
124
|
|
|
109
|
-
/**
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const params: CmcdParams = {}
|
|
114
|
-
params['x-bc-transport'] = snap.transport
|
|
115
|
-
|
|
116
|
-
if (snap.multicastGroup != null) {
|
|
117
|
-
params['x-bc-multicast-group'] = snap.multicastGroup
|
|
118
|
-
}
|
|
119
|
-
if (snap.multicastSource != null) {
|
|
120
|
-
params['x-bc-multicast-source'] = snap.multicastSource
|
|
121
|
-
}
|
|
122
|
-
if (snap.amtRelay != null) {
|
|
123
|
-
params['x-bc-amt-relay'] = snap.amtRelay
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return params
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// ---------------------------------------------------------------------------
|
|
130
|
-
// Query-string serialization (RFC 9559 §2)
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Serialize CmcdParams to RFC 9559 query-string format.
|
|
135
|
-
* Keys sorted for deterministic output. String values sanitized.
|
|
136
|
-
*/
|
|
137
|
-
function formatQueryString(params: CmcdParams): string {
|
|
138
|
-
return Object.keys(params)
|
|
125
|
+
/** Encode one record as an RFC 8941 dictionary with keys in alphabetical order. */
|
|
126
|
+
export function encodeCmcdRecord(record: CmcdRecord): string {
|
|
127
|
+
return Object.keys(record)
|
|
139
128
|
.sort()
|
|
140
129
|
.map(key => {
|
|
141
|
-
const
|
|
142
|
-
if (typeof
|
|
143
|
-
return `${key}=${
|
|
130
|
+
const value = record[key]
|
|
131
|
+
if (typeof value === 'number') return `${key}=${value}`
|
|
132
|
+
if (typeof value === 'string') return `${key}=${sfString(value)}`
|
|
133
|
+
if (Array.isArray(value)) return `${key}=(${value.map(sfString).join(' ')})`
|
|
134
|
+
return `${key}=${(value as CmcdToken).token}`
|
|
144
135
|
})
|
|
145
136
|
.join(',')
|
|
146
137
|
}
|
|
147
138
|
|
|
139
|
+
const token = (value: string): CmcdToken => ({ token: value })
|
|
140
|
+
|
|
141
|
+
/** Set a custom key, numbers as strings; drop anything absent or over 64 characters. */
|
|
142
|
+
function putCustom(record: CmcdRecord, name: string, value: string | number | CmcdToken | undefined): void {
|
|
143
|
+
if (value === undefined) return
|
|
144
|
+
if (typeof value === 'number') value = String(value)
|
|
145
|
+
if (typeof value === 'string' && value.length > MAX_CUSTOM_VALUE) return
|
|
146
|
+
record[CUSTOM_PREFIX + name] = value
|
|
147
|
+
}
|
|
148
|
+
|
|
148
149
|
// ---------------------------------------------------------------------------
|
|
149
150
|
// CmcdProducer
|
|
150
151
|
// ---------------------------------------------------------------------------
|
|
151
152
|
|
|
152
153
|
export class CmcdProducer {
|
|
153
|
-
#
|
|
154
|
-
#
|
|
154
|
+
#onCmcdPost: ((body: string) => void) | undefined
|
|
155
|
+
#intervalMs: number
|
|
156
|
+
#sessionId: string
|
|
157
|
+
#now: () => number
|
|
158
|
+
#sn = 0
|
|
159
|
+
#lastHeartbeat: number | null = null
|
|
160
|
+
|
|
155
161
|
#transport: TransportSnapshot | undefined
|
|
156
162
|
#lastStats: FecTrackStats | undefined
|
|
157
|
-
// Phase 14 state — Plan 03 Task 2 (D-12, D-15).
|
|
158
163
|
#playMode: PlayMode | null = null
|
|
159
164
|
#deltaFailRate: number | null = null
|
|
160
165
|
#switchMiscount = 0
|
|
161
166
|
#manifestStats: ManifestVerificationStats | null = null
|
|
162
167
|
#fecFailClosed: FecFailClosedReason | null = null
|
|
168
|
+
/** Video-block resolutions from the repair lifecycle; null until the player feeds one. */
|
|
169
|
+
#lifecycle: { inband: number; fetch: number; failedKeyframe: number } | null = null
|
|
163
170
|
|
|
164
171
|
constructor(options: CmcdProducerOptions) {
|
|
165
|
-
this.#onCmcdParams = options.onCmcdParams
|
|
166
172
|
this.#onCmcdPost = options.onCmcdPost
|
|
173
|
+
this.#intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS
|
|
174
|
+
if (!Number.isFinite(this.#intervalMs) || this.#intervalMs <= 0) {
|
|
175
|
+
throw new Error(`[CmcdProducer] invalid intervalMs "${String(options.intervalMs)}"; expected a positive number`)
|
|
176
|
+
}
|
|
177
|
+
this.#sessionId = options.sessionId ?? globalThis.crypto.randomUUID()
|
|
178
|
+
this.#now = options.now ?? Date.now
|
|
167
179
|
}
|
|
168
180
|
|
|
169
181
|
/** Update the stored transport state. Called by player wiring layer. */
|
|
@@ -172,9 +184,8 @@ export class CmcdProducer {
|
|
|
172
184
|
}
|
|
173
185
|
|
|
174
186
|
/**
|
|
175
|
-
* Set the current play-mode
|
|
176
|
-
*
|
|
177
|
-
* (CLAUDE.md: error on bad init).
|
|
187
|
+
* Set the current play-mode, reported as `com.blockcast-pm`. Throws on any
|
|
188
|
+
* value outside {'full','base-only','single-track'} (CLAUDE.md: error on bad init).
|
|
178
189
|
*/
|
|
179
190
|
updatePlayMode(mode: PlayMode): void {
|
|
180
191
|
if (typeof mode !== 'string' || !PLAY_MODES.includes(mode as PlayMode)) {
|
|
@@ -186,9 +197,8 @@ export class CmcdProducer {
|
|
|
186
197
|
}
|
|
187
198
|
|
|
188
199
|
/**
|
|
189
|
-
* Set the delta-track FEC block-failure rate (0..1 inclusive)
|
|
190
|
-
* `
|
|
191
|
-
* Omitted on single-track streams.
|
|
200
|
+
* Set the delta-track FEC block-failure rate (0..1 inclusive), reported as
|
|
201
|
+
* `com.blockcast-dfr` when play-mode is 'full' or 'base-only'.
|
|
192
202
|
*/
|
|
193
203
|
updateDeltaFailRate(rate: number): void {
|
|
194
204
|
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0 || rate > 1) {
|
|
@@ -199,22 +209,22 @@ export class CmcdProducer {
|
|
|
199
209
|
this.#deltaFailRate = rate
|
|
200
210
|
}
|
|
201
211
|
|
|
202
|
-
/**
|
|
203
|
-
* Bump the cumulative switching-set misalignment counter. Emitted as
|
|
204
|
-
* `x-bc-switch-miscount` on snapshots when the counter is > 0.
|
|
205
|
-
*/
|
|
212
|
+
/** Bump the cumulative switching-set misalignment counter (`com.blockcast-sm`). */
|
|
206
213
|
incrementSwitchMiscount(): void {
|
|
207
214
|
this.#switchMiscount += 1
|
|
208
215
|
}
|
|
209
216
|
|
|
210
217
|
/**
|
|
211
|
-
* Record that FEC failed closed at runtime (fec-01 6.3.9).
|
|
212
|
-
*
|
|
213
|
-
* `
|
|
214
|
-
* stream playing without FEC is told apart from one
|
|
218
|
+
* Record that FEC failed closed at runtime (fec-01 6.3.9). Reported at once
|
|
219
|
+
* as `e=e` with the reason code in `ec`, and on every later counter report
|
|
220
|
+
* as `com.blockcast-ffc` / `com.blockcast-ffl` (the held layer whose total
|
|
221
|
+
* B_i was undercut), so a stream playing without FEC is told apart from one
|
|
222
|
+
* with no loss.
|
|
215
223
|
*/
|
|
216
224
|
updateFecFailClosed(reason: FecFailClosedReason): void {
|
|
225
|
+
const changed = this.#fecFailClosed?.code !== reason.code || this.#fecFailClosed?.layer !== reason.layer
|
|
217
226
|
this.#fecFailClosed = reason
|
|
227
|
+
if (changed) this.#post([{ ...this.#base('e'), ec: [reason.code] }])
|
|
218
228
|
}
|
|
219
229
|
|
|
220
230
|
updateManifestVerification(stats: ManifestVerificationStats): void {
|
|
@@ -222,124 +232,129 @@ export class CmcdProducer {
|
|
|
222
232
|
}
|
|
223
233
|
|
|
224
234
|
/**
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
235
|
+
* Count a video block's resolution from the repair lifecycle: recovered in
|
|
236
|
+
* band (`com.blockcast-fbr`), recovered by repair FETCH (`-fbq`), or failed
|
|
237
|
+
* as a keyframe-class block (`-fbfk`). Audio blocks are not counted, matching
|
|
238
|
+
* the video-track `-fbf`. Until the player feeds this, the three are absent.
|
|
229
239
|
*/
|
|
240
|
+
onRepairLifecycle(event: RepairLifecycleEvent): void {
|
|
241
|
+
this.#lifecycle ??= { inband: 0, fetch: 0, failedKeyframe: 0 }
|
|
242
|
+
if (event.type === 'recovered' && event.blockClass !== 'audio') {
|
|
243
|
+
if (event.via === 'inband') this.#lifecycle.inband++
|
|
244
|
+
else this.#lifecycle.fetch++
|
|
245
|
+
} else if (event.type === 'failed' && event.blockClass === 'keyframe') {
|
|
246
|
+
this.#lifecycle.failedKeyframe++
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** One-shot custom event `com.blockcast-switch-drift` for a single misalignment. */
|
|
230
251
|
emitSwitchDrift(driftUs: number): void {
|
|
231
|
-
if (
|
|
232
|
-
typeof driftUs !== 'number' ||
|
|
233
|
-
!Number.isFinite(driftUs) ||
|
|
234
|
-
driftUs < 0
|
|
235
|
-
) {
|
|
252
|
+
if (typeof driftUs !== 'number' || !Number.isFinite(driftUs) || driftUs < 0) {
|
|
236
253
|
throw new Error(
|
|
237
254
|
`[CmcdProducer] invalid drift-us "${String(driftUs)}"; expected a non-negative finite number`,
|
|
238
255
|
)
|
|
239
256
|
}
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (this.#switchMiscount > 0) params['x-bc-switch-miscount'] = this.#switchMiscount
|
|
244
|
-
params['x-bc-switch-drift-us'] = driftUs
|
|
245
|
-
this.#emit(params)
|
|
257
|
+
const record: CmcdRecord = { ...this.#base('ce'), cen: CUSTOM_PREFIX + 'switch-drift' }
|
|
258
|
+
putCustom(record, 'sdu', Math.round(driftUs))
|
|
259
|
+
this.#post([record])
|
|
246
260
|
}
|
|
247
261
|
|
|
248
|
-
/**
|
|
249
|
-
* Process a FecTrackStats snapshot. Finds the video track, builds
|
|
250
|
-
* x-bc-* vendor keys, and fires both delivery callbacks.
|
|
251
|
-
*/
|
|
262
|
+
/** Take a FecTrackStats snapshot (first video track) and send the heartbeat when due. */
|
|
252
263
|
onSnapshot(data: SnapshotData): void {
|
|
253
|
-
// Find first video track; skip if none
|
|
254
264
|
const videoTrack = data.tracks.find(t => t.config.trackType === 'video')
|
|
255
265
|
if (!videoTrack) return
|
|
256
|
-
|
|
257
266
|
this.#lastStats = videoTrack
|
|
258
|
-
|
|
259
|
-
const params: CmcdParams = {
|
|
260
|
-
...buildFecParams(videoTrack),
|
|
261
|
-
...buildTransportParams(this.#transport),
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
if (this.#playMode !== null) {
|
|
265
|
-
params['x-bc-play-mode'] = this.#playMode
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (this.#fecFailClosed) {
|
|
269
|
-
params['x-bc-fec-fail-closed'] = this.#fecFailClosed.code
|
|
270
|
-
params['x-bc-fec-fail-layer'] = this.#fecFailClosed.layer
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Delta-fail-rate is only meaningful when a delta track exists.
|
|
274
|
-
if (
|
|
275
|
-
this.#playMode !== null &&
|
|
276
|
-
this.#playMode !== 'single-track' &&
|
|
277
|
-
this.#deltaFailRate !== null
|
|
278
|
-
) {
|
|
279
|
-
params['x-bc-delta-fail-rate'] = parseFloat(this.#deltaFailRate.toFixed(4))
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (this.#switchMiscount > 0) {
|
|
283
|
-
params['x-bc-switch-miscount'] = this.#switchMiscount
|
|
284
|
-
}
|
|
285
|
-
if (this.#manifestStats) {
|
|
286
|
-
params['x-bc-manifest-verified'] = this.#manifestStats.verified > 0
|
|
287
|
-
params['x-bc-manifest-failures'] = Object.values(this.#manifestStats.failures)
|
|
288
|
-
.reduce((total, count) => total + count, 0)
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
this.#emit(params)
|
|
267
|
+
this.#heartbeat()
|
|
292
268
|
}
|
|
293
269
|
|
|
294
270
|
/**
|
|
295
|
-
*
|
|
296
|
-
*
|
|
271
|
+
* Take transport state for receivers that do not own FEC stats, and send the
|
|
272
|
+
* heartbeat when due.
|
|
297
273
|
*/
|
|
298
274
|
onTransportSnapshot(data: TransportOnlySnapshotData): void {
|
|
299
275
|
if (!Number.isFinite(data.timestampMs) || data.timestampMs < 0) {
|
|
300
276
|
throw new Error('[CmcdProducer] transport snapshot timestamp must be a non-negative finite number')
|
|
301
277
|
}
|
|
302
278
|
if (data.playMode !== undefined) this.updatePlayMode(data.playMode)
|
|
303
|
-
if (
|
|
304
|
-
data.switchMiscount
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
279
|
+
if (data.switchMiscount !== undefined) {
|
|
280
|
+
if (!Number.isSafeInteger(data.switchMiscount) || data.switchMiscount < 0) {
|
|
281
|
+
throw new Error('[CmcdProducer] transport snapshot switch-miscount must be a non-negative safe integer')
|
|
282
|
+
}
|
|
283
|
+
this.#switchMiscount = data.switchMiscount
|
|
308
284
|
}
|
|
309
|
-
|
|
310
285
|
this.#transport = data.transport
|
|
311
|
-
|
|
312
|
-
if (this.#playMode !== null) params['x-bc-play-mode'] = this.#playMode
|
|
313
|
-
const switchMiscount = data.switchMiscount ?? this.#switchMiscount
|
|
314
|
-
if (switchMiscount > 0) params['x-bc-switch-miscount'] = switchMiscount
|
|
315
|
-
this.#emit(params)
|
|
286
|
+
this.#heartbeat()
|
|
316
287
|
}
|
|
317
288
|
|
|
318
|
-
/**
|
|
319
|
-
* Signal a transport transition event. Fires an immediate out-of-band
|
|
320
|
-
* emit with x-bc-ct set. The subsequent onSnapshot() will NOT include
|
|
321
|
-
* x-bc-ct (one-shot).
|
|
322
|
-
*/
|
|
289
|
+
/** Report a transport transition at once, as custom event `com.blockcast-ct`. */
|
|
323
290
|
onTransition(event: TransportTransitionEvent): void {
|
|
324
|
-
const
|
|
291
|
+
const record: CmcdRecord = { ...this.#base('ce'), cen: CUSTOM_PREFIX + 'ct' }
|
|
292
|
+
putCustom(record, 'ct', token(event))
|
|
293
|
+
this.#putTransport(record)
|
|
294
|
+
this.#post([record])
|
|
295
|
+
}
|
|
325
296
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
}
|
|
297
|
+
#base(event: string): CmcdRecord {
|
|
298
|
+
return { e: token(event), sid: this.#sessionId, sn: this.#sn++, ts: Math.floor(this.#now()), v: 2 }
|
|
299
|
+
}
|
|
330
300
|
|
|
331
|
-
|
|
332
|
-
|
|
301
|
+
#heartbeat(): void {
|
|
302
|
+
const now = this.#now()
|
|
303
|
+
if (this.#lastHeartbeat !== null && now - this.#lastHeartbeat < this.#intervalMs) return
|
|
304
|
+
this.#lastHeartbeat = now
|
|
305
|
+
this.#post([this.#base('t'), this.#counters()])
|
|
306
|
+
}
|
|
333
307
|
|
|
334
|
-
|
|
335
|
-
|
|
308
|
+
#putTransport(record: CmcdRecord): void {
|
|
309
|
+
const snap = this.#transport
|
|
310
|
+
if (!snap) return
|
|
311
|
+
putCustom(record, 'tr', token(TRANSPORT_TOKEN[snap.transport]))
|
|
312
|
+
if (snap.multicastGroup != null && snap.multicastSource != null) {
|
|
313
|
+
putCustom(record, 'sg', `${snap.multicastGroup}@${snap.multicastSource}`)
|
|
314
|
+
}
|
|
315
|
+
putCustom(record, 'ar', snap.amtRelay)
|
|
316
|
+
}
|
|
336
317
|
|
|
337
|
-
|
|
318
|
+
/** The `e=ce` counter report. */
|
|
319
|
+
#counters(): CmcdRecord {
|
|
320
|
+
const record: CmcdRecord = { ...this.#base('ce'), cen: CUSTOM_PREFIX + 'fec' }
|
|
321
|
+
const stats = this.#lastStats
|
|
322
|
+
if (stats) {
|
|
323
|
+
putCustom(record, 'fa', stats.config.algorithm)
|
|
324
|
+
putCustom(record, 'fk', stats.config.k)
|
|
325
|
+
// P as this receiver JOINED it: the sum of repairSymbols over the joined
|
|
326
|
+
// base layers, never including a keyframe overlay.
|
|
327
|
+
putCustom(record, 'fp', stats.config.repairCount)
|
|
328
|
+
putCustom(record, 'pr', stats.sourceSymbols)
|
|
329
|
+
putCustom(record, 'pl', stats.sourcePacketsMissing)
|
|
330
|
+
putCustom(record, 'pu', stats.sourcePacketsUnrecovered)
|
|
331
|
+
putCustom(record, 'fbf', stats.blocksFailed)
|
|
332
|
+
}
|
|
333
|
+
if (this.#lifecycle) {
|
|
334
|
+
putCustom(record, 'fbr', this.#lifecycle.inband)
|
|
335
|
+
putCustom(record, 'fbq', this.#lifecycle.fetch)
|
|
336
|
+
putCustom(record, 'fbfk', this.#lifecycle.failedKeyframe)
|
|
337
|
+
}
|
|
338
|
+
this.#putTransport(record)
|
|
339
|
+
if (this.#playMode !== null) {
|
|
340
|
+
putCustom(record, 'pm', token(this.#playMode))
|
|
341
|
+
if (this.#playMode !== 'single-track' && this.#deltaFailRate !== null) {
|
|
342
|
+
putCustom(record, 'dfr', this.#deltaFailRate.toFixed(3))
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (this.#fecFailClosed) {
|
|
346
|
+
putCustom(record, 'ffc', token(this.#fecFailClosed.code))
|
|
347
|
+
putCustom(record, 'ffl', this.#fecFailClosed.layer)
|
|
348
|
+
}
|
|
349
|
+
if (this.#switchMiscount > 0) putCustom(record, 'sm', this.#switchMiscount)
|
|
350
|
+
if (this.#manifestStats) {
|
|
351
|
+
putCustom(record, 'mv', this.#manifestStats.verified)
|
|
352
|
+
putCustom(record, 'mf', Object.values(this.#manifestStats.failures).reduce((a, b) => a + b, 0))
|
|
353
|
+
}
|
|
354
|
+
return record
|
|
338
355
|
}
|
|
339
356
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
this.#onCmcdParams?.(params)
|
|
343
|
-
this.#onCmcdPost?.(formatQueryString(params))
|
|
357
|
+
#post(records: CmcdRecord[]): void {
|
|
358
|
+
this.#onCmcdPost?.(records.map(encodeCmcdRecord).join('\n'))
|
|
344
359
|
}
|
|
345
360
|
}
|