@blockcast/mmt-container 2.2.0-main.73af0eedcbc8 → 2.2.0-main.75b39a2cdddf

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 (93) hide show
  1. package/README.md +29 -17
  2. package/dist/browser-runtime.js +1001 -215
  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 +60 -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 +222 -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 +58 -10
  42. package/dist/mmtp-container-parser.d.ts.map +1 -1
  43. package/dist/mmtp-container-parser.js +105 -34
  44. package/dist/mmtp-container-parser.js.map +2 -2
  45. package/dist/mmtp-container-parser.no-modules.js +229 -51
  46. package/dist/mmtp-container-parser.no-modules.js.map +3 -3
  47. package/dist/mmtp-decode-pipeline.js +104 -34
  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 +18 -1
  54. package/dist/mmtp-router.d.ts.map +1 -1
  55. package/dist/mmtp-router.js +323 -64
  56. package/dist/mmtp-router.js.map +2 -2
  57. package/dist/multicast-auth-catalog.js.map +1 -1
  58. package/dist/multicast-input.js.map +1 -1
  59. package/dist/multicast-manager.js +547 -72
  60. package/dist/multicast-manager.js.map +2 -2
  61. package/dist/multicast-transport.js +28 -18
  62. package/dist/multicast-transport.js.map +2 -2
  63. package/dist/multicast-unwrap.js +12 -19
  64. package/dist/multicast-unwrap.js.map +2 -2
  65. package/dist/no-modules.js +222 -48
  66. package/dist/no-modules.js.map +3 -3
  67. package/dist/repair-fetch.js.map +1 -1
  68. package/dist/repair-layer-subscriptions.d.ts +82 -0
  69. package/dist/repair-layer-subscriptions.d.ts.map +1 -0
  70. package/dist/repair-layer-subscriptions.js +192 -0
  71. package/dist/repair-layer-subscriptions.js.map +7 -0
  72. package/dist/signaling.js.map +1 -1
  73. package/dist/timestamp-codec.js +12 -19
  74. package/dist/timestamp-codec.js.map +2 -2
  75. package/dist/track-session.d.ts +1 -1
  76. package/dist/track-session.js.map +1 -1
  77. package/dist/wasm-mfu-reassembler.js.map +1 -1
  78. package/dist/wasm-mmtp-container-parser.js +104 -34
  79. package/dist/wasm-mmtp-container-parser.js.map +2 -2
  80. package/package.json +8 -5
  81. package/schemas/catalog.schema.json +4 -0
  82. package/src/catalog-fec-params.ts +36 -1
  83. package/src/cmcd-producer.ts +189 -179
  84. package/src/fec-manager.ts +277 -6
  85. package/src/fec-worker-setup.ts +2 -2
  86. package/src/index.ts +18 -2
  87. package/src/iso-block-locator.ts +19 -0
  88. package/src/iso-bmff-timing.ts +40 -1
  89. package/src/mmtp-container-parser.ts +125 -19
  90. package/src/mmtp-fec-session.ts +115 -15
  91. package/src/mmtp-router.ts +350 -13
  92. package/src/repair-layer-subscriptions.ts +261 -0
  93. 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.73af0eedcbc8",
3
+ "version": "2.2.0-main.75b39a2cdddf",
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.73af0eedcbc8",
173
- "@blockcast/mmt-base": "2.2.0-main.73af0eedcbc8",
174
- "@blockcast/mmt-fec": "2.2.0-main.73af0eedcbc8",
175
- "@blockcast/mmt-manifest-verify": "2.2.0-main.73af0eedcbc8",
172
+ "@blockcast/fec-worker": "2.2.0-main.75b39a2cdddf",
173
+ "@blockcast/mmt-base": "2.2.0-main.75b39a2cdddf",
174
+ "@blockcast/mmt-fec": "2.2.0-main.75b39a2cdddf",
175
+ "@blockcast/mmt-manifest-verify": "2.2.0-main.75b39a2cdddf",
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>
@@ -42,11 +52,11 @@ export interface TransportSnapshot {
42
52
  amtRelay?: string
43
53
  }
44
54
 
45
- /** Transport transition event identifiers for x-bc-ct. */
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
- * Phase 14 play-mode state (D-12, x-bc-play-mode):
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
- /** CMCD vendor key map. Values are string, number, or boolean. */
59
- 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>
60
78
 
61
79
  /** Options for CmcdProducer construction. */
62
80
  export interface CmcdProducerOptions {
63
- /** Fired on each snapshot and on transport transitions. Params for MoQ SUBSCRIBE. */
64
- onCmcdParams?: (params: CmcdParams) => void
65
- /** Fired on each snapshot and on transport transitions. RFC 9559 query-string for HTTP POST body. */
66
- 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
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
- // Value sanitization (T-06-01: header injection mitigation)
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
- /** Strip commas, newlines, and carriage returns from string values. */
88
- function sanitize(value: string): string {
89
- return value.replace(/[,\r\n]/g, '')
110
+ const TRANSPORT_TOKEN: Record<TransportSnapshot['transport'], string> = {
111
+ native: 'ssm',
112
+ amt: 'amt',
113
+ moq: 'moq',
90
114
  }
91
115
 
92
116
  // ---------------------------------------------------------------------------
93
- // Key building (pure functions, no side effects)
117
+ // Encoding (RFC 8941 dictionary, keys sorted — CTA-5004-B §3)
94
118
  // ---------------------------------------------------------------------------
95
119
 
96
- /** Build FEC vendor keys from a FecTrackStats snapshot. */
97
- function buildFecParams(stats: FecTrackStats): CmcdParams {
98
- const params: CmcdParams = {}
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
- // Query-string serialization (RFC 9559 §2)
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 v = params[key]
147
- if (typeof v === 'string') return `${key}=${sanitize(v)}`
148
- 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}`
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
- #onCmcdParams: ((params: CmcdParams) => void) | undefined
159
- #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
+
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. Emitted as `x-bc-play-mode` on subsequent
181
- * snapshots. Throws on any value outside {'full','base-only','single-track'}
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). Emitted as
195
- * `x-bc-delta-fail-rate` on snapshots when play-mode is 'full' or 'base-only'.
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). Every later
217
- * snapshot carries `x-bc-fec-fail-closed` (the reason code) and
218
- * `x-bc-fec-fail-layer` (the held layer i whose total B_i was undercut), so a
219
- * 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.
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
- * One-shot emit of `x-bc-switch-drift-us` for a single misalignment event.
231
- * Does NOT mutate snapshot state — subsequent onSnapshot() calls will not
232
- * include the drift key until the next emitSwitchDrift(). Mirrors the
233
- * 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.
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 params: CmcdParams = {}
246
- if (this.#lastStats) Object.assign(params, buildFecParams(this.#lastStats))
247
- Object.assign(params, buildTransportParams(this.#transport))
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
- * Emit transport state without inventing FEC metrics. Cadence is owned by
301
- * 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.
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 !== undefined &&
310
- (!Number.isSafeInteger(data.switchMiscount) || data.switchMiscount < 0)
311
- ) {
312
- 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
313
284
  }
314
-
315
285
  this.#transport = data.transport
316
- const params = buildTransportParams(data.transport)
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 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
+ }
330
296
 
331
- // Include last known FEC stats if available
332
- if (this.#lastStats) {
333
- Object.assign(params, buildFecParams(this.#lastStats))
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
- // Include transport state if available
337
- 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
+ }
338
307
 
339
- // Add the transition event key (one-shot)
340
- 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
+ }
341
317
 
342
- 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
343
355
  }
344
356
 
345
- /** Fire both delivery callbacks with the given params. */
346
- #emit(params: CmcdParams): void {
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
  }