@blockcast/mmt-container 2.2.0-main.c4fa878f4ff4 → 2.2.0-main.da2e7cd1dd5d
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 +241 -149
- package/dist/browser-runtime.js.map +3 -3
- 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/cmcd-producer.d.ts +60 -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 +24 -15
- package/dist/delivery-catalog.js.map +2 -2
- package/dist/delta-fallback-config.js.map +1 -1
- package/dist/fec-manager.d.ts +3 -1
- package/dist/fec-manager.d.ts.map +1 -1
- package/dist/fec-manager.js +26 -4
- package/dist/fec-manager.js.map +2 -2
- 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/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +2 -2
- package/dist/media-router.js.map +1 -1
- package/dist/mmtp-container-parser.d.ts +2 -0
- package/dist/mmtp-container-parser.d.ts.map +1 -1
- package/dist/mmtp-container-parser.js +4 -0
- package/dist/mmtp-container-parser.js.map +2 -2
- package/dist/mmtp-container-parser.no-modules.js +4 -0
- package/dist/mmtp-container-parser.no-modules.js.map +2 -2
- package/dist/mmtp-decode-pipeline.js +4 -0
- package/dist/mmtp-decode-pipeline.js.map +2 -2
- package/dist/mmtp-router.d.ts.map +1 -1
- package/dist/mmtp-router.js +38 -15
- package/dist/mmtp-router.js.map +2 -2
- package/dist/multicast-manager.js +64 -19
- package/dist/multicast-manager.js.map +2 -2
- package/dist/multicast-transport.js +24 -15
- package/dist/multicast-transport.js.map +2 -2
- package/dist/no-modules.js +4 -0
- package/dist/no-modules.js.map +2 -2
- package/dist/repair-layer-subscriptions.d.ts +6 -2
- package/dist/repair-layer-subscriptions.d.ts.map +1 -1
- package/dist/repair-layer-subscriptions.js +6 -3
- package/dist/repair-layer-subscriptions.js.map +2 -2
- package/dist/track-session.d.ts +1 -1
- package/dist/track-session.js.map +1 -1
- package/dist/wasm-mmtp-container-parser.js +4 -0
- 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 +189 -179
- package/src/fec-manager.ts +26 -4
- package/src/fec-worker-setup.ts +2 -2
- package/src/index.ts +6 -2
- package/src/mmtp-container-parser.ts +12 -0
- package/src/mmtp-router.ts +21 -0
- package/src/repair-layer-subscriptions.ts +13 -5
- 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.2.0-main.
|
|
3
|
+
"version": "2.2.0-main.da2e7cd1dd5d",
|
|
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": "2.2.0-main.
|
|
173
|
-
"@blockcast/mmt-base": "2.2.0-main.
|
|
174
|
-
"@blockcast/mmt-fec": "2.2.0-main.
|
|
175
|
-
"@blockcast/mmt-manifest-verify": "2.2.0-main.
|
|
172
|
+
"@blockcast/fec-worker": "2.2.0-main.da2e7cd1dd5d",
|
|
173
|
+
"@blockcast/mmt-base": "2.2.0-main.da2e7cd1dd5d",
|
|
174
|
+
"@blockcast/mmt-fec": "2.2.0-main.da2e7cd1dd5d",
|
|
175
|
+
"@blockcast/mmt-manifest-verify": "2.2.0-main.da2e7cd1dd5d",
|
|
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>
|
|
@@ -42,11 +52,11 @@ export interface TransportSnapshot {
|
|
|
42
52
|
amtRelay?: string
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
/** Transport transition event identifiers for
|
|
55
|
+
/** Transport transition event identifiers for `com.blockcast-ct`. */
|
|
46
56
|
export type TransportTransitionEvent = 'mc-start' | 'mc-to-uc' | 'amt-start'
|
|
47
57
|
|
|
48
58
|
/**
|
|
49
|
-
*
|
|
59
|
+
* Play-mode state (`com.blockcast-pm`):
|
|
50
60
|
* - `full` — base + delta both active
|
|
51
61
|
* - `base-only` — delta fell back; base carries playback
|
|
52
62
|
* - `single-track` — stream is not a multi-track switching set (legacy)
|
|
@@ -55,15 +65,27 @@ export type PlayMode = 'full' | 'base-only' | 'single-track'
|
|
|
55
65
|
|
|
56
66
|
const PLAY_MODES: readonly PlayMode[] = ['full', 'base-only', 'single-track']
|
|
57
67
|
|
|
58
|
-
/**
|
|
59
|
-
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>
|
|
60
78
|
|
|
61
79
|
/** Options for CmcdProducer construction. */
|
|
62
80
|
export interface CmcdProducerOptions {
|
|
63
|
-
/** Fired
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
|
|
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
|
|
67
89
|
}
|
|
68
90
|
|
|
69
91
|
/** Snapshot data passed to onSnapshot(). */
|
|
@@ -80,95 +102,80 @@ export interface TransportOnlySnapshotData {
|
|
|
80
102
|
switchMiscount?: number
|
|
81
103
|
}
|
|
82
104
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
86
109
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
110
|
+
const TRANSPORT_TOKEN: Record<TransportSnapshot['transport'], string> = {
|
|
111
|
+
native: 'ssm',
|
|
112
|
+
amt: 'amt',
|
|
113
|
+
moq: 'moq',
|
|
90
114
|
}
|
|
91
115
|
|
|
92
116
|
// ---------------------------------------------------------------------------
|
|
93
|
-
//
|
|
117
|
+
// Encoding (RFC 8941 dictionary, keys sorted — CTA-5004-B §3)
|
|
94
118
|
// ---------------------------------------------------------------------------
|
|
95
119
|
|
|
96
|
-
/**
|
|
97
|
-
function
|
|
98
|
-
|
|
99
|
-
const cfg = stats.config
|
|
100
|
-
|
|
101
|
-
params['x-bc-fec-algo'] = cfg.algorithm
|
|
102
|
-
params['x-bc-fec-k'] = cfg.k
|
|
103
|
-
// P as this receiver JOINED it: the worker is configured from
|
|
104
|
-
// TrackSessionConfig.joinedRepairCount, the sum of repairSymbols over the
|
|
105
|
-
// joined base layers. Not the catalog total, which a receiver on a clean path
|
|
106
|
-
// does not receive, and never including a keyframe overlay (separate FEC
|
|
107
|
-
// instance with its own RSB_length).
|
|
108
|
-
params['x-bc-fec-p'] = cfg.repairCount
|
|
109
|
-
params['x-bc-fec-recovery'] = parseFloat((stats.recoveryRate / 100).toFixed(4))
|
|
110
|
-
|
|
111
|
-
return params
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Build transport vendor keys from a TransportSnapshot. */
|
|
115
|
-
function buildTransportParams(snap: TransportSnapshot | undefined): CmcdParams {
|
|
116
|
-
if (!snap) return {}
|
|
117
|
-
|
|
118
|
-
const params: CmcdParams = {}
|
|
119
|
-
params['x-bc-transport'] = snap.transport
|
|
120
|
-
|
|
121
|
-
if (snap.multicastGroup != null) {
|
|
122
|
-
params['x-bc-multicast-group'] = snap.multicastGroup
|
|
123
|
-
}
|
|
124
|
-
if (snap.multicastSource != null) {
|
|
125
|
-
params['x-bc-multicast-source'] = snap.multicastSource
|
|
126
|
-
}
|
|
127
|
-
if (snap.amtRelay != null) {
|
|
128
|
-
params['x-bc-amt-relay'] = snap.amtRelay
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
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, '\\$&')}"`
|
|
132
123
|
}
|
|
133
124
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Serialize CmcdParams to RFC 9559 query-string format.
|
|
140
|
-
* Keys sorted for deterministic output. String values sanitized.
|
|
141
|
-
*/
|
|
142
|
-
function formatQueryString(params: CmcdParams): string {
|
|
143
|
-
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)
|
|
144
128
|
.sort()
|
|
145
129
|
.map(key => {
|
|
146
|
-
const
|
|
147
|
-
if (typeof
|
|
148
|
-
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}`
|
|
149
135
|
})
|
|
150
136
|
.join(',')
|
|
151
137
|
}
|
|
152
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
|
+
|
|
153
149
|
// ---------------------------------------------------------------------------
|
|
154
150
|
// CmcdProducer
|
|
155
151
|
// ---------------------------------------------------------------------------
|
|
156
152
|
|
|
157
153
|
export class CmcdProducer {
|
|
158
|
-
#
|
|
159
|
-
#
|
|
154
|
+
#onCmcdPost: ((body: string) => void) | undefined
|
|
155
|
+
#intervalMs: number
|
|
156
|
+
#sessionId: string
|
|
157
|
+
#now: () => number
|
|
158
|
+
#sn = 0
|
|
159
|
+
#lastHeartbeat: number | null = null
|
|
160
|
+
|
|
160
161
|
#transport: TransportSnapshot | undefined
|
|
161
162
|
#lastStats: FecTrackStats | undefined
|
|
162
|
-
// Phase 14 state — Plan 03 Task 2 (D-12, D-15).
|
|
163
163
|
#playMode: PlayMode | null = null
|
|
164
164
|
#deltaFailRate: number | null = null
|
|
165
165
|
#switchMiscount = 0
|
|
166
166
|
#manifestStats: ManifestVerificationStats | null = null
|
|
167
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
|
|
168
170
|
|
|
169
171
|
constructor(options: CmcdProducerOptions) {
|
|
170
|
-
this.#onCmcdParams = options.onCmcdParams
|
|
171
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
|
|
172
179
|
}
|
|
173
180
|
|
|
174
181
|
/** Update the stored transport state. Called by player wiring layer. */
|
|
@@ -177,9 +184,8 @@ export class CmcdProducer {
|
|
|
177
184
|
}
|
|
178
185
|
|
|
179
186
|
/**
|
|
180
|
-
* Set the current play-mode
|
|
181
|
-
*
|
|
182
|
-
* (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).
|
|
183
189
|
*/
|
|
184
190
|
updatePlayMode(mode: PlayMode): void {
|
|
185
191
|
if (typeof mode !== 'string' || !PLAY_MODES.includes(mode as PlayMode)) {
|
|
@@ -191,9 +197,8 @@ export class CmcdProducer {
|
|
|
191
197
|
}
|
|
192
198
|
|
|
193
199
|
/**
|
|
194
|
-
* Set the delta-track FEC block-failure rate (0..1 inclusive)
|
|
195
|
-
* `
|
|
196
|
-
* 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'.
|
|
197
202
|
*/
|
|
198
203
|
updateDeltaFailRate(rate: number): void {
|
|
199
204
|
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0 || rate > 1) {
|
|
@@ -204,22 +209,22 @@ export class CmcdProducer {
|
|
|
204
209
|
this.#deltaFailRate = rate
|
|
205
210
|
}
|
|
206
211
|
|
|
207
|
-
/**
|
|
208
|
-
* Bump the cumulative switching-set misalignment counter. Emitted as
|
|
209
|
-
* `x-bc-switch-miscount` on snapshots when the counter is > 0.
|
|
210
|
-
*/
|
|
212
|
+
/** Bump the cumulative switching-set misalignment counter (`com.blockcast-sm`). */
|
|
211
213
|
incrementSwitchMiscount(): void {
|
|
212
214
|
this.#switchMiscount += 1
|
|
213
215
|
}
|
|
214
216
|
|
|
215
217
|
/**
|
|
216
|
-
* Record that FEC failed closed at runtime (fec-01 6.3.9).
|
|
217
|
-
*
|
|
218
|
-
* `
|
|
219
|
-
* 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.
|
|
220
223
|
*/
|
|
221
224
|
updateFecFailClosed(reason: FecFailClosedReason): void {
|
|
225
|
+
const changed = this.#fecFailClosed?.code !== reason.code || this.#fecFailClosed?.layer !== reason.layer
|
|
222
226
|
this.#fecFailClosed = reason
|
|
227
|
+
if (changed) this.#post([{ ...this.#base('e'), ec: [reason.code] }])
|
|
223
228
|
}
|
|
224
229
|
|
|
225
230
|
updateManifestVerification(stats: ManifestVerificationStats): void {
|
|
@@ -227,124 +232,129 @@ export class CmcdProducer {
|
|
|
227
232
|
}
|
|
228
233
|
|
|
229
234
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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.
|
|
234
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. */
|
|
235
251
|
emitSwitchDrift(driftUs: number): void {
|
|
236
|
-
if (
|
|
237
|
-
typeof driftUs !== 'number' ||
|
|
238
|
-
!Number.isFinite(driftUs) ||
|
|
239
|
-
driftUs < 0
|
|
240
|
-
) {
|
|
252
|
+
if (typeof driftUs !== 'number' || !Number.isFinite(driftUs) || driftUs < 0) {
|
|
241
253
|
throw new Error(
|
|
242
254
|
`[CmcdProducer] invalid drift-us "${String(driftUs)}"; expected a non-negative finite number`,
|
|
243
255
|
)
|
|
244
256
|
}
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (this.#switchMiscount > 0) params['x-bc-switch-miscount'] = this.#switchMiscount
|
|
249
|
-
params['x-bc-switch-drift-us'] = driftUs
|
|
250
|
-
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])
|
|
251
260
|
}
|
|
252
261
|
|
|
253
|
-
/**
|
|
254
|
-
* Process a FecTrackStats snapshot. Finds the video track, builds
|
|
255
|
-
* x-bc-* vendor keys, and fires both delivery callbacks.
|
|
256
|
-
*/
|
|
262
|
+
/** Take a FecTrackStats snapshot (first video track) and send the heartbeat when due. */
|
|
257
263
|
onSnapshot(data: SnapshotData): void {
|
|
258
|
-
// Find first video track; skip if none
|
|
259
264
|
const videoTrack = data.tracks.find(t => t.config.trackType === 'video')
|
|
260
265
|
if (!videoTrack) return
|
|
261
|
-
|
|
262
266
|
this.#lastStats = videoTrack
|
|
263
|
-
|
|
264
|
-
const params: CmcdParams = {
|
|
265
|
-
...buildFecParams(videoTrack),
|
|
266
|
-
...buildTransportParams(this.#transport),
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (this.#playMode !== null) {
|
|
270
|
-
params['x-bc-play-mode'] = this.#playMode
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if (this.#fecFailClosed) {
|
|
274
|
-
params['x-bc-fec-fail-closed'] = this.#fecFailClosed.code
|
|
275
|
-
params['x-bc-fec-fail-layer'] = this.#fecFailClosed.layer
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Delta-fail-rate is only meaningful when a delta track exists.
|
|
279
|
-
if (
|
|
280
|
-
this.#playMode !== null &&
|
|
281
|
-
this.#playMode !== 'single-track' &&
|
|
282
|
-
this.#deltaFailRate !== null
|
|
283
|
-
) {
|
|
284
|
-
params['x-bc-delta-fail-rate'] = parseFloat(this.#deltaFailRate.toFixed(4))
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
if (this.#switchMiscount > 0) {
|
|
288
|
-
params['x-bc-switch-miscount'] = this.#switchMiscount
|
|
289
|
-
}
|
|
290
|
-
if (this.#manifestStats) {
|
|
291
|
-
params['x-bc-manifest-verified'] = this.#manifestStats.verified > 0
|
|
292
|
-
params['x-bc-manifest-failures'] = Object.values(this.#manifestStats.failures)
|
|
293
|
-
.reduce((total, count) => total + count, 0)
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
this.#emit(params)
|
|
267
|
+
this.#heartbeat()
|
|
297
268
|
}
|
|
298
269
|
|
|
299
270
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
271
|
+
* Take transport state for receivers that do not own FEC stats, and send the
|
|
272
|
+
* heartbeat when due.
|
|
302
273
|
*/
|
|
303
274
|
onTransportSnapshot(data: TransportOnlySnapshotData): void {
|
|
304
275
|
if (!Number.isFinite(data.timestampMs) || data.timestampMs < 0) {
|
|
305
276
|
throw new Error('[CmcdProducer] transport snapshot timestamp must be a non-negative finite number')
|
|
306
277
|
}
|
|
307
278
|
if (data.playMode !== undefined) this.updatePlayMode(data.playMode)
|
|
308
|
-
if (
|
|
309
|
-
data.switchMiscount
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
313
284
|
}
|
|
314
|
-
|
|
315
285
|
this.#transport = data.transport
|
|
316
|
-
|
|
317
|
-
if (this.#playMode !== null) params['x-bc-play-mode'] = this.#playMode
|
|
318
|
-
const switchMiscount = data.switchMiscount ?? this.#switchMiscount
|
|
319
|
-
if (switchMiscount > 0) params['x-bc-switch-miscount'] = switchMiscount
|
|
320
|
-
this.#emit(params)
|
|
286
|
+
this.#heartbeat()
|
|
321
287
|
}
|
|
322
288
|
|
|
323
|
-
/**
|
|
324
|
-
* Signal a transport transition event. Fires an immediate out-of-band
|
|
325
|
-
* emit with x-bc-ct set. The subsequent onSnapshot() will NOT include
|
|
326
|
-
* x-bc-ct (one-shot).
|
|
327
|
-
*/
|
|
289
|
+
/** Report a transport transition at once, as custom event `com.blockcast-ct`. */
|
|
328
290
|
onTransition(event: TransportTransitionEvent): void {
|
|
329
|
-
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
|
+
}
|
|
330
296
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}
|
|
297
|
+
#base(event: string): CmcdRecord {
|
|
298
|
+
return { e: token(event), sid: this.#sessionId, sn: this.#sn++, ts: Math.floor(this.#now()), v: 2 }
|
|
299
|
+
}
|
|
335
300
|
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
}
|
|
338
307
|
|
|
339
|
-
|
|
340
|
-
|
|
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
|
+
}
|
|
341
317
|
|
|
342
|
-
|
|
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
|
|
343
355
|
}
|
|
344
356
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
this.#onCmcdParams?.(params)
|
|
348
|
-
this.#onCmcdPost?.(formatQueryString(params))
|
|
357
|
+
#post(records: CmcdRecord[]): void {
|
|
358
|
+
this.#onCmcdPost?.(records.map(encodeCmcdRecord).join('\n'))
|
|
349
359
|
}
|
|
350
360
|
}
|