@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.
Files changed (99) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +1271 -247
  3. package/dist/browser-runtime.js.map +4 -4
  4. package/dist/carp-catalog.js.map +1 -1
  5. package/dist/catalog-fec-params.d.ts +17 -1
  6. package/dist/catalog-fec-params.d.ts.map +1 -1
  7. package/dist/catalog-fec-params.js +15 -0
  8. package/dist/catalog-fec-params.js.map +2 -2
  9. package/dist/catalog-network-source.js.map +1 -1
  10. package/dist/cmcd-producer.d.ts +65 -47
  11. package/dist/cmcd-producer.d.ts.map +1 -1
  12. package/dist/cmcd-producer.js +138 -109
  13. package/dist/cmcd-producer.js.map +2 -2
  14. package/dist/delivery-catalog.js +26 -16
  15. package/dist/delivery-catalog.js.map +2 -2
  16. package/dist/delta-fallback-config.js.map +1 -1
  17. package/dist/fec-manager.d.ts +33 -1
  18. package/dist/fec-manager.d.ts.map +1 -1
  19. package/dist/fec-manager.js +213 -6
  20. package/dist/fec-manager.js.map +2 -2
  21. package/dist/fec-state.js.map +1 -1
  22. package/dist/fec-worker-setup.d.ts +1 -1
  23. package/dist/fec-worker-setup.js +1 -1
  24. package/dist/fec-worker-setup.js.map +1 -1
  25. package/dist/held-layer-admission.js.map +1 -1
  26. package/dist/index.d.ts +5 -4
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +9 -1
  29. package/dist/index.js.map +2 -2
  30. package/dist/iso-block-locator.d.ts +9 -0
  31. package/dist/iso-block-locator.d.ts.map +1 -1
  32. package/dist/iso-block-locator.js +17 -0
  33. package/dist/iso-block-locator.js.map +2 -2
  34. package/dist/iso-bmff-timing.d.ts +10 -1
  35. package/dist/iso-bmff-timing.d.ts.map +1 -1
  36. package/dist/iso-bmff-timing.js +36 -2
  37. package/dist/iso-bmff-timing.js.map +2 -2
  38. package/dist/media-router.js.map +1 -1
  39. package/dist/mfu-reassembler.js +12 -19
  40. package/dist/mfu-reassembler.js.map +2 -2
  41. package/dist/mmtp-container-parser.d.ts +37 -2
  42. package/dist/mmtp-container-parser.d.ts.map +1 -1
  43. package/dist/mmtp-container-parser.js +98 -28
  44. package/dist/mmtp-container-parser.js.map +2 -2
  45. package/dist/mmtp-container-parser.no-modules.js +222 -45
  46. package/dist/mmtp-container-parser.no-modules.js.map +3 -3
  47. package/dist/mmtp-decode-pipeline.js +97 -28
  48. package/dist/mmtp-decode-pipeline.js.map +2 -2
  49. package/dist/mmtp-fec-session.d.ts +41 -3
  50. package/dist/mmtp-fec-session.d.ts.map +1 -1
  51. package/dist/mmtp-fec-session.js +118 -13
  52. package/dist/mmtp-fec-session.js.map +3 -3
  53. package/dist/mmtp-router.d.ts +25 -2
  54. package/dist/mmtp-router.d.ts.map +1 -1
  55. package/dist/mmtp-router.js +1718 -54
  56. package/dist/mmtp-router.js.map +4 -4
  57. package/dist/multicast-auth-catalog.js.map +1 -1
  58. package/dist/multicast-input.js.map +1 -1
  59. package/dist/multicast-manager.d.ts +15 -2
  60. package/dist/multicast-manager.d.ts.map +1 -1
  61. package/dist/multicast-manager.js +15652 -14939
  62. package/dist/multicast-manager.js.map +4 -4
  63. package/dist/multicast-transport.d.ts +11 -3
  64. package/dist/multicast-transport.d.ts.map +1 -1
  65. package/dist/multicast-transport.js +281 -35
  66. package/dist/multicast-transport.js.map +4 -4
  67. package/dist/multicast-unwrap.js +12 -19
  68. package/dist/multicast-unwrap.js.map +2 -2
  69. package/dist/no-modules.js +215 -42
  70. package/dist/no-modules.js.map +3 -3
  71. package/dist/repair-fetch.js.map +1 -1
  72. package/dist/repair-layer-subscriptions.d.ts +82 -0
  73. package/dist/repair-layer-subscriptions.d.ts.map +1 -0
  74. package/dist/repair-layer-subscriptions.js +192 -0
  75. package/dist/repair-layer-subscriptions.js.map +7 -0
  76. package/dist/signaling.js.map +2 -2
  77. package/dist/timestamp-codec.js +12 -19
  78. package/dist/timestamp-codec.js.map +2 -2
  79. package/dist/track-session.d.ts +1 -1
  80. package/dist/track-session.js.map +1 -1
  81. package/dist/wasm-mfu-reassembler.js.map +1 -1
  82. package/dist/wasm-mmtp-container-parser.js +97 -28
  83. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  84. package/package.json +8 -5
  85. package/schemas/catalog.schema.json +4 -0
  86. package/src/catalog-fec-params.ts +36 -1
  87. package/src/cmcd-producer.ts +194 -179
  88. package/src/fec-manager.ts +256 -6
  89. package/src/fec-worker-setup.ts +2 -2
  90. package/src/index.ts +18 -2
  91. package/src/iso-block-locator.ts +19 -0
  92. package/src/iso-bmff-timing.ts +40 -1
  93. package/src/mmtp-container-parser.ts +96 -8
  94. package/src/mmtp-fec-session.ts +115 -15
  95. package/src/mmtp-router.ts +344 -27
  96. package/src/multicast-manager.ts +26 -5
  97. package/src/multicast-transport.ts +44 -20
  98. package/src/repair-layer-subscriptions.ts +261 -0
  99. 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.1.0",
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": "^2.1.0",
173
- "@blockcast/mmt-base": "^2.1.0",
174
- "@blockcast/mmt-fec": "^2.1.0",
175
- "@blockcast/mmt-manifest-verify": "^2.1.0",
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 `x-bc-fec-p`.
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
  }
@@ -1,26 +1,36 @@
1
1
  /**
2
- * CmcdProducer — transport-agnostic CMCD telemetry producer.
2
+ * CmcdProducer — CMCD v2 Event-mode reports (CTA-5004-B §2.2) from a player.
3
3
  *
4
- * Maps FecTrackStats snapshots and TransportSnapshot state to x-bc-*
5
- * CMCD vendor keys (proto tags 70-99). Fires callbacks for two delivery
6
- * channels: MoQ SUBSCRIBE params and multicast HTTP POST.
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
- * Does NOT call fetch() or write to QUIC directly — the player wiring
9
- * layer is responsible for attaching params to SUBSCRIBE or POSTing
10
- * the payload to the CDN CMCD endpoint.
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
- * @see docs/superpowers/specs/2026-04-15-iwa-multicast-stack-design.md §3.3
13
- * @see CDN+ Track D BLO-1044
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 for CMCD: records verified, and drops per reason. */
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 x-bc-ct. */
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
- * Phase 14 play-mode state (D-12, x-bc-play-mode):
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
- /** CMCD vendor key map. Values are string, number, or boolean. */
54
- export type CmcdParams = Record<string, string | number | boolean>
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 on each snapshot and on transport transitions. Params for MoQ SUBSCRIBE. */
59
- onCmcdParams?: (params: CmcdParams) => void
60
- /** Fired on each snapshot and on transport transitions. RFC 9559 query-string for HTTP POST body. */
61
- onCmcdPost?: (payload: string) => void
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
- // Value sanitization (T-06-01: header injection mitigation)
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
- /** Strip commas, newlines, and carriage returns from string values. */
83
- function sanitize(value: string): string {
84
- return value.replace(/[,\r\n]/g, '')
110
+ const TRANSPORT_TOKEN: Record<TransportSnapshot['transport'], string> = {
111
+ native: 'ssm',
112
+ amt: 'amt',
113
+ moq: 'moq',
85
114
  }
86
115
 
87
116
  // ---------------------------------------------------------------------------
88
- // Key building (pure functions, no side effects)
117
+ // Encoding (RFC 8941 dictionary, keys sorted — CTA-5004-B §3)
89
118
  // ---------------------------------------------------------------------------
90
119
 
91
- /** Build FEC vendor keys from a FecTrackStats snapshot. */
92
- function buildFecParams(stats: FecTrackStats): CmcdParams {
93
- const params: CmcdParams = {}
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
- /** Build transport vendor keys from a TransportSnapshot. */
110
- function buildTransportParams(snap: TransportSnapshot | undefined): CmcdParams {
111
- if (!snap) return {}
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 v = params[key]
142
- if (typeof v === 'string') return `${key}=${sanitize(v)}`
143
- return `${key}=${v}`
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
- #onCmcdParams: ((params: CmcdParams) => void) | undefined
154
- #onCmcdPost: ((payload: string) => void) | undefined
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. Emitted as `x-bc-play-mode` on subsequent
176
- * snapshots. Throws on any value outside {'full','base-only','single-track'}
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). Emitted as
190
- * `x-bc-delta-fail-rate` on snapshots when play-mode is 'full' or 'base-only'.
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). Every later
212
- * snapshot carries `x-bc-fec-fail-closed` (the reason code) and
213
- * `x-bc-fec-fail-layer` (the held layer i whose total B_i was undercut), so a
214
- * stream playing without FEC is told apart from one with no loss.
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
- * One-shot emit of `x-bc-switch-drift-us` for a single misalignment event.
226
- * Does NOT mutate snapshot state — subsequent onSnapshot() calls will not
227
- * include the drift key until the next emitSwitchDrift(). Mirrors the
228
- * existing onTransition() pattern.
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 params: CmcdParams = {}
241
- if (this.#lastStats) Object.assign(params, buildFecParams(this.#lastStats))
242
- Object.assign(params, buildTransportParams(this.#transport))
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
- * Emit transport state without inventing FEC metrics. Cadence is owned by
296
- * the caller because only the transport integration knows when state changed.
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 !== undefined &&
305
- (!Number.isSafeInteger(data.switchMiscount) || data.switchMiscount < 0)
306
- ) {
307
- throw new Error('[CmcdProducer] transport snapshot switch-miscount must be a non-negative safe integer')
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
- const params = buildTransportParams(data.transport)
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 params: CmcdParams = {}
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
- // Include last known FEC stats if available
327
- if (this.#lastStats) {
328
- Object.assign(params, buildFecParams(this.#lastStats))
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
- // Include transport state if available
332
- Object.assign(params, buildTransportParams(this.#transport))
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
- // Add the transition event key (one-shot)
335
- params['x-bc-ct'] = event
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
- this.#emit(params)
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
- /** Fire both delivery callbacks with the given params. */
341
- #emit(params: CmcdParams): void {
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
  }