@blockcast/mmt-fec 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +4 -4
  2. package/dist/block-processor.d.ts +19 -20
  3. package/dist/block-processor.d.ts.map +1 -1
  4. package/dist/block-processor.js +27 -93
  5. package/dist/block-processor.js.map +1 -1
  6. package/dist/fec-block-mapper.d.ts +16 -72
  7. package/dist/fec-block-mapper.d.ts.map +1 -1
  8. package/dist/fec-block-mapper.js +17 -96
  9. package/dist/fec-block-mapper.js.map +1 -1
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +3 -3
  13. package/dist/index.js.map +1 -1
  14. package/dist/oti.d.ts +17 -8
  15. package/dist/oti.d.ts.map +1 -1
  16. package/dist/oti.js +9 -1
  17. package/dist/oti.js.map +1 -1
  18. package/dist/repair-group.d.ts +86 -0
  19. package/dist/repair-group.d.ts.map +1 -1
  20. package/dist/repair-group.js +135 -0
  21. package/dist/repair-group.js.map +1 -1
  22. package/package.json +5 -6
  23. package/src/block-processor.ts +31 -110
  24. package/src/fec-block-mapper.ts +23 -108
  25. package/src/index.ts +6 -1
  26. package/src/oti.ts +20 -9
  27. package/src/repair-group.ts +176 -0
  28. package/test-vectors/repair-epoch.json +119 -0
  29. package/node_modules/@blockcast/mmt-alta-parse/README.md +0 -181
  30. package/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
  31. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +0 -57
  32. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +0 -1
  33. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js +0 -115
  34. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +0 -1
  35. package/node_modules/@blockcast/mmt-alta-parse/package.json +0 -50
  36. package/node_modules/@blockcast/mmt-alta-parse/src/index.ts +0 -136
  37. package/node_modules/@blockcast/mmt-base/README.md +0 -134
  38. package/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
  39. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts +0 -55
  40. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts.map +0 -1
  41. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js +0 -128
  42. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js.map +0 -1
  43. package/node_modules/@blockcast/mmt-base/dist/index.d.ts +0 -7
  44. package/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +0 -1
  45. package/node_modules/@blockcast/mmt-base/dist/index.js +0 -7
  46. package/node_modules/@blockcast/mmt-base/dist/index.js.map +0 -1
  47. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +0 -292
  48. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +0 -1
  49. package/node_modules/@blockcast/mmt-base/dist/mmtp.js +0 -612
  50. package/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +0 -1
  51. package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts +0 -18
  52. package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts.map +0 -1
  53. package/node_modules/@blockcast/mmt-base/dist/net-constants.js +0 -18
  54. package/node_modules/@blockcast/mmt-base/dist/net-constants.js.map +0 -1
  55. package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts +0 -47
  56. package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts.map +0 -1
  57. package/node_modules/@blockcast/mmt-base/dist/ntp-short.js +0 -54
  58. package/node_modules/@blockcast/mmt-base/dist/ntp-short.js.map +0 -1
  59. package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts +0 -57
  60. package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts.map +0 -1
  61. package/node_modules/@blockcast/mmt-base/dist/serial-number.js +0 -98
  62. package/node_modules/@blockcast/mmt-base/dist/serial-number.js.map +0 -1
  63. package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts +0 -312
  64. package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts.map +0 -1
  65. package/node_modules/@blockcast/mmt-base/dist/signalling.js +0 -637
  66. package/node_modules/@blockcast/mmt-base/dist/signalling.js.map +0 -1
  67. package/node_modules/@blockcast/mmt-base/package.json +0 -49
  68. package/node_modules/@blockcast/mmt-base/src/fec-block-profile.ts +0 -145
  69. package/node_modules/@blockcast/mmt-base/src/index.ts +0 -6
  70. package/node_modules/@blockcast/mmt-base/src/mmtp.ts +0 -892
  71. package/node_modules/@blockcast/mmt-base/src/net-constants.ts +0 -17
  72. package/node_modules/@blockcast/mmt-base/src/ntp-short.ts +0 -67
  73. package/node_modules/@blockcast/mmt-base/src/serial-number.ts +0 -107
  74. package/node_modules/@blockcast/mmt-base/src/signalling.ts +0 -862
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockcast/mmt-fec",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Container-agnostic FEC client for RaptorQ (RFC 6330) decoding via WASM",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -33,11 +33,13 @@
33
33
  "./loc": {
34
34
  "import": "./dist/loc-symbols.js",
35
35
  "types": "./dist/loc-symbols.d.ts"
36
- }
36
+ },
37
+ "./test-vectors/repair-epoch.json": "./test-vectors/repair-epoch.json"
37
38
  },
38
39
  "files": [
39
40
  "dist",
40
41
  "src",
42
+ "test-vectors",
41
43
  "!src/**/*.test.ts",
42
44
  "!src/**/*.spec.ts",
43
45
  "!src/**/__tests__",
@@ -53,11 +55,8 @@
53
55
  "test:watch": "vitest"
54
56
  },
55
57
  "dependencies": {
56
- "@blockcast/mmt-base": "^0.3.0"
58
+ "@blockcast/mmt-base": "^0.4.0"
57
59
  },
58
- "bundledDependencies": [
59
- "@blockcast/mmt-base"
60
- ],
61
60
  "devDependencies": {
62
61
  "typescript": "^5.3.0",
63
62
  "vitest": "^1.0.0"
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * FEC Block Processor
3
3
  *
4
- * Splits decoded FEC blocks (K symbols concatenated by WASM RaptorQ decoder)
4
+ * Splits decoded FEC blocks (SSB_length symbols concatenated by the WASM decoder)
5
5
  * into individual MFU fragments ready for reassembly and decoding.
6
6
  *
7
7
  * Extracted from moqtail.html's inline processFecBlock() for reuse across
@@ -9,14 +9,10 @@
9
9
  */
10
10
 
11
11
  import {
12
- MMTP_EXT_TRAILER_HEADER_SIZE,
13
- MMTP_EXT_TYPE_ALTA_AUTH,
14
12
  MMTP_HEADER_SIZE,
15
- MPU_HEADER_SIZE,
16
13
  SSBG1_LENGTH_FIELD_BYTES,
17
14
  extractMFUPayload,
18
15
  parseFecBlockProfile,
19
- parseMPUHeader,
20
16
  parseMMTPHeader,
21
17
  trimSsbg1SourceSymbol,
22
18
  type FecBlockProfile,
@@ -66,9 +62,9 @@ export interface ProcessFecBlockResult {
66
62
  /** Options for {@link processFecSymbol}. */
67
63
  export interface ProcessFecSymbolOptions {
68
64
  /**
69
- * The stream's catalog `fec.blockProfile`. Required, never defaulted: the
70
- * two profiles lay a packet into a symbol differently, so the same
71
- * recovered bytes trim to different packets under each.
65
+ * The stream's catalog `fec.blockProfile`. Required, never defaulted:
66
+ * the profile fixes how a packet is laid into a symbol, and the only one
67
+ * implemented is `iso-ssbg1-v1`.
72
68
  */
73
69
  blockProfile: FecBlockProfile;
74
70
  /** Symbol index within the block, used only for diagnostics. */
@@ -84,62 +80,15 @@ export interface ProcessFecBlockOptions {
84
80
  /** Symbol size T in bytes (catalog `fec.symbolSize`). Required: there is no default T. */
85
81
  symbolSize: number;
86
82
  /**
87
- * Source symbols in the decoded block: K under `k-padded-v1`, SSB_length
88
- * under `iso-ssbg1-v1`. When omitted the block must be a whole number of
89
- * T-byte symbols and K is that number; a partial symbol throws.
83
+ * Source symbols in the decoded block: its SSB_length (K'). When omitted
84
+ * the block must be a whole number of T-byte symbols and K' is that
85
+ * number; a partial symbol throws.
90
86
  */
91
87
  k?: number;
92
88
  /** Keep only symbols for this packetId. */
93
89
  ownerPacketId?: number;
94
90
  }
95
91
 
96
- function hasUniformPaddingFrom(data: Uint8Array, start: number): boolean {
97
- if (start >= data.length) return false;
98
- const pad = data[start]!;
99
- if (pad !== 0x00 && pad !== 0xee) return false;
100
- for (let i = start + 1; i < data.length; i++) {
101
- if (data[i] !== pad) return false;
102
- }
103
- return true;
104
- }
105
-
106
- function maybeExtendAltaTrailer(symbolData: Uint8Array, packetEnd: number): number {
107
- if (packetEnd + MMTP_EXT_TRAILER_HEADER_SIZE > symbolData.length) return packetEnd;
108
- const view = new DataView(
109
- symbolData.buffer,
110
- symbolData.byteOffset + packetEnd,
111
- symbolData.byteLength - packetEnd,
112
- );
113
- const extType = view.getUint16(0, false);
114
- if (extType !== MMTP_EXT_TYPE_ALTA_AUTH) return packetEnd;
115
-
116
- const extLen = view.getUint16(2, false);
117
- const trailerEnd = packetEnd + MMTP_EXT_TRAILER_HEADER_SIZE + extLen;
118
- return trailerEnd <= symbolData.length ? trailerEnd : packetEnd;
119
- }
120
-
121
- function trimDecodedSourceSymbol(symbolData: Uint8Array): Uint8Array | null {
122
- if (symbolData.length < MMTP_HEADER_SIZE + MPU_HEADER_SIZE) return null;
123
-
124
- const headerLength = parseMMTPHeader(symbolData).headerLength;
125
- if (symbolData.length < headerLength + MPU_HEADER_SIZE) return null;
126
- const payload = symbolData.subarray(headerLength);
127
- const mpu = parseMPUHeader(payload);
128
- const trailingMpuHeaderSize = MPU_HEADER_SIZE - 2;
129
- if (mpu.payloadLength < trailingMpuHeaderSize) return null;
130
-
131
- // Recovered source symbols are fixed-width and may be padded, but their wire
132
- // boundary is still the ISO length-after-field coordinate. ALTA may extend the
133
- // packet from exactly that point; no MFU-content or alternate-length guessing.
134
- const declaredEnd = headerLength + 2 + mpu.payloadLength;
135
- if (declaredEnd > symbolData.length) return null;
136
- const packetEnd = maybeExtendAltaTrailer(symbolData, declaredEnd);
137
- if (packetEnd < symbolData.length && !hasUniformPaddingFrom(symbolData, packetEnd)) {
138
- return null;
139
- }
140
- return symbolData.subarray(0, packetEnd);
141
- }
142
-
143
92
  function emptyProcessResult(): ProcessFecBlockResult {
144
93
  return {
145
94
  symbols: [],
@@ -172,8 +121,8 @@ function resultFromSymbols(
172
121
  parsed.sort(comparePacketSequence);
173
122
 
174
123
  // When ownerPacketId is set, only keep symbols matching the track that owns
175
- // this FEC block. Other packetIds (including 0 from zero-padded recovery slots)
176
- // are garbage and must be rejected to prevent decoder crashes.
124
+ // this FEC block. Other packetIds are garbage and must be rejected to
125
+ // prevent decoder crashes.
177
126
  const symbols = ownerPacketId != null
178
127
  ? parsed.filter((s) => s.packetId === ownerPacketId)
179
128
  : parsed;
@@ -186,9 +135,8 @@ type FailureCounter = (reason: string) => void;
186
135
  /**
187
136
  * Gate one candidate MMTP packet on its fixed header: long enough, version 0,
188
137
  * parseable, and an MPU packet. Returns the parsed header, or null after
189
- * counting why it was refused. Both profiles run this exact gate; they differ
190
- * only in which bytes they hand it (the symbol under k-padded-v1, the
191
- * length-delimited packet under iso-ssbg1-v1).
138
+ * counting why it was refused. It sees the length-delimited protected packet,
139
+ * never the padded symbol.
192
140
  */
193
141
  function gateMpuHeader(data: Uint8Array, bump: FailureCounter): MMTPHeader | null {
194
142
  if (data.length < MMTP_HEADER_SIZE) {
@@ -223,7 +171,7 @@ function parseProtectedPacket(
223
171
  // packet (fecType=1 per draft-ramadan-moq-mmt-00 §3.1), but the 4-byte
224
172
  // Source FEC Payload ID (ISO 23008-1 §C.5.2) is never part of the protected
225
173
  // packet (C.4.2). Tell extractMFUPayload to skip FEC PID subtraction so it
226
- // does not truncate video data; any ALTA trailer must then consume the
174
+ // does not truncate video data; any legacy authenticator trailer must then consume the
227
175
  // exact suffix after the declared payload.
228
176
  const extraction = extractMFUPayload(packetData, { skipFecId: true });
229
177
  if (!extraction) {
@@ -256,26 +204,6 @@ function parseProtectedPacket(
256
204
  };
257
205
  }
258
206
 
259
- /**
260
- * k-padded-v1: the symbol is the protected packet zero-padded to T with no
261
- * length field, so the packet ends at its ISO-declared MPU boundary (plus an
262
- * ALTA trailer starting exactly there) and the rest must be uniform padding.
263
- */
264
- function parseKPaddedSymbol(
265
- symbolData: Uint8Array,
266
- index: number,
267
- bump: FailureCounter,
268
- ): ParsedFecSymbol | null {
269
- const hdr = gateMpuHeader(symbolData, bump);
270
- if (!hdr) return null;
271
- const packetData = trimDecodedSourceSymbol(symbolData);
272
- if (!packetData) {
273
- bump("bad_length");
274
- return null;
275
- }
276
- return parseProtectedPacket(packetData, hdr, index, bump);
277
- }
278
-
279
207
  /**
280
208
  * iso-ssbg1-v1 (ISO/IEC 23008-1 C.3.3 ssbg_mode1): the symbol is
281
209
  * `u16be(length) || protected packet || 00h padding`. The length field alone
@@ -300,22 +228,19 @@ function parseSsbg1Symbol(
300
228
  /**
301
229
  * Parse one recovered FEC source symbol into an MFU fragment.
302
230
  *
303
- * The symbol is laid out by the stream's block profile: under `k-padded-v1`
304
- * it is the protected MMTP packet zero-padded to T; under `iso-ssbg1-v1` it is
305
- * the ssbg_mode1 `u16 length || packet || zero padding`. Either way the
306
- * recovered packet is then parsed identically. Failures are counted by reason
307
- * (`bad_ssbg1_length` is the ISO length/padding refusal), never thrown.
231
+ * The symbol is the `iso-ssbg1-v1` ssbg_mode1 `u16 length || packet || zero
232
+ * padding` (ISO/IEC 23008-1 C.3.3): the length field alone delimits the
233
+ * protected packet, which is then parsed as an MMTP MPU packet. Failures are
234
+ * counted by reason (`bad_ssbg1_length` is the length/padding refusal), never
235
+ * thrown.
308
236
  *
309
- * @throws when `options.blockProfile` is absent or not a known profile token.
237
+ * @throws when `options.blockProfile` is absent or not `iso-ssbg1-v1`.
310
238
  */
311
239
  export function processFecSymbol(
312
240
  symbolData: Uint8Array,
313
241
  options: ProcessFecSymbolOptions,
314
242
  ): ProcessFecBlockResult {
315
- const blockProfile = parseFecBlockProfile(
316
- options?.blockProfile,
317
- "processFecSymbol options.blockProfile",
318
- );
243
+ parseFecBlockProfile(options?.blockProfile, "processFecSymbol options.blockProfile");
319
244
  const failReasons: Record<string, number> = {};
320
245
  let failedCount = 0;
321
246
  const bump: FailureCounter = (reason) => {
@@ -328,9 +253,7 @@ export function processFecSymbol(
328
253
  const index = options.index ?? 0;
329
254
  let parsed: ParsedFecSymbol | null;
330
255
  try {
331
- parsed = blockProfile === "iso-ssbg1-v1"
332
- ? parseSsbg1Symbol(symbolData, index, bump)
333
- : parseKPaddedSymbol(symbolData, index, bump);
256
+ parsed = parseSsbg1Symbol(symbolData, index, bump);
334
257
  } catch {
335
258
  bump("exception");
336
259
  parsed = null;
@@ -343,14 +266,14 @@ export function processFecSymbol(
343
266
  );
344
267
  }
345
268
 
346
- function requireSymbolSize(symbolSize: unknown, blockProfile: FecBlockProfile): number {
269
+ function requireSymbolSize(symbolSize: unknown): number {
347
270
  if (symbolSize === undefined) {
348
271
  throw new Error("processFecBlock options.symbolSize is required: name the catalog fec.symbolSize (T)");
349
272
  }
350
273
  if (typeof symbolSize !== "number" || !Number.isSafeInteger(symbolSize) || symbolSize <= 0) {
351
274
  throw new Error(`processFecBlock options.symbolSize must be a positive integer (got ${String(symbolSize)})`);
352
275
  }
353
- if (blockProfile === "iso-ssbg1-v1" && symbolSize <= SSBG1_LENGTH_FIELD_BYTES) {
276
+ if (symbolSize <= SSBG1_LENGTH_FIELD_BYTES) {
354
277
  throw new Error(
355
278
  `processFecBlock options.symbolSize must exceed the ${SSBG1_LENGTH_FIELD_BYTES}-byte ssbg_mode1 length field (got ${symbolSize})`,
356
279
  );
@@ -362,7 +285,6 @@ function requireSourceSymbolCount(
362
285
  k: number | undefined,
363
286
  blockLength: number,
364
287
  symbolSize: number,
365
- blockProfile: FecBlockProfile,
366
288
  ): number {
367
289
  if (k === undefined) {
368
290
  if (blockLength % symbolSize !== 0) {
@@ -377,7 +299,7 @@ function requireSourceSymbolCount(
377
299
  }
378
300
  // An iso-ssbg1-v1 block is exactly K' = SSB_length symbols of T bytes
379
301
  // (C.5.3); any other length is not the block the repair symbols described.
380
- if (blockProfile === "iso-ssbg1-v1" && blockLength !== k * symbolSize) {
302
+ if (blockLength !== k * symbolSize) {
381
303
  throw new Error(
382
304
  `processFecBlock: an iso-ssbg1-v1 block of ${k} symbols of ${symbolSize} bytes is ${k * symbolSize} bytes (got ${blockLength})`,
383
305
  );
@@ -389,17 +311,16 @@ function requireSourceSymbolCount(
389
311
  * Process a decoded FEC block into individual MFU fragments.
390
312
  *
391
313
  * The WASM RaptorQ decoder returns every source symbol of the block
392
- * concatenated (K x T bytes under `k-padded-v1`, SSB_length x T bytes under
393
- * `iso-ssbg1-v1`). This function splits them apart, recovers each protected
394
- * packet by the stream's block profile (see {@link processFecSymbol}),
395
- * extracts the MFU payload via shared extractMFUPayload, and returns
396
- * structured results sorted for reassembly.
314
+ * concatenated (SSB_length x T bytes). This function splits them apart,
315
+ * recovers each protected packet from its ssbg_mode1 length field (see
316
+ * {@link processFecSymbol}), extracts the MFU payload via shared
317
+ * extractMFUPayload, and returns structured results sorted for reassembly.
397
318
  *
398
319
  * @param blockData - Concatenated source symbols from the WASM decoder
399
320
  * @param options - Block profile and symbol size T are required; see {@link ProcessFecBlockOptions}
400
321
  * @throws when the block profile or symbol size is absent or invalid, when an
401
- * omitted `k` cannot be read exactly from the block length, or when an
402
- * `iso-ssbg1-v1` block is not exactly `k` symbols long.
322
+ * omitted `k` cannot be read exactly from the block length, or when the
323
+ * block is not exactly `k` symbols long.
403
324
  */
404
325
  export function processFecBlock(
405
326
  blockData: Uint8Array,
@@ -409,11 +330,11 @@ export function processFecBlock(
409
330
  options?.blockProfile,
410
331
  "processFecBlock options.blockProfile",
411
332
  );
412
- const symbolSize = requireSymbolSize(options.symbolSize, blockProfile);
333
+ const symbolSize = requireSymbolSize(options.symbolSize);
413
334
 
414
335
  if (!blockData || blockData.length === 0) return emptyProcessResult();
415
336
 
416
- const k = requireSourceSymbolCount(options.k, blockData.length, symbolSize, blockProfile);
337
+ const k = requireSourceSymbolCount(options.k, blockData.length, symbolSize);
417
338
 
418
339
  const failReasons: Record<string, number> = {};
419
340
  let failedCount = 0;
@@ -8,17 +8,10 @@
8
8
  * It deliberately does NOT map MoQ media groups to FEC blocks. A recovered
9
9
  * packet's media group is read from its own MMTP/MPU header
10
10
  * ({@link recoveredPacketMediaGroup}), never from SBN arithmetic, and a source
11
- * symbol's block comes from the wire:
12
- *
13
- * - `k-padded-v1`: `SBN = floor(SS_ID / K)`, `ESI = SS_ID mod K`
14
- * ({@link FecBlockMapper.fromSsId}).
15
- * - `iso-ssbg1-v1`: a block is the SS_ID range `[SS_Start, SS_Start +
16
- * SSB_length)` its Repair FEC Payload ID states (ISO/IEC 23008-1 C.5.3), so
17
- * SS_ID alone locates nothing and `fromSsId` throws.
18
- *
19
- * Object FEC (draft-ramadan-moq-fec §9.1) is the one exception: there each MoQ
20
- * group IS its own FEC block, so {@link ObjectBlockMapper} keeps its identity
21
- * `derive`.
11
+ * symbol's block comes from the wire: under `iso-ssbg1-v1` a block is the SS_ID
12
+ * range `[SS_Start, SS_Start + SSB_length)` its Repair FEC Payload ID states
13
+ * (ISO/IEC 23008-1 C.5.3), so SS_ID alone locates nothing and `fromSsId`
14
+ * throws for every stream with an FEC instance.
22
15
  */
23
16
 
24
17
  import {
@@ -36,10 +29,7 @@ export interface FecBlockConfig {
36
29
  interleaveDepthMs: number
37
30
  /** GOP/frame duration (milliseconds). Measured from actual MMTP timestamps. */
38
31
  gopDurationMs: number
39
- /**
40
- * K from catalog fec.sourceSymbols: the source symbols per block under
41
- * `k-padded-v1`, the maximum SSB_length under `iso-ssbg1-v1`.
42
- */
32
+ /** K from catalog fec.sourceSymbols: the maximum SSB_length of a block. */
43
33
  k: number
44
34
  /**
45
35
  * The catalog `fec.blockProfile`. Required and validated with
@@ -48,7 +38,7 @@ export interface FecBlockConfig {
48
38
  blockProfile: FecBlockProfile
49
39
  }
50
40
 
51
- /** FEC coordinates of one MoQ object, for the mappers whose groups are blocks. */
41
+ /** FEC coordinates of one MoQ object, for the mapper whose groups are blocks. */
52
42
  export interface FecCoordinates {
53
43
  /** Source Block Number */
54
44
  sbn: number
@@ -98,7 +88,7 @@ interface NoFecInstanceConfig {
98
88
  export class FecBlockMapper {
99
89
  /** Media units (GOPs/frames) per FEC block, recovered with positive round-half-up semantics. */
100
90
  readonly d: number
101
- /** K: source symbols per block (`k-padded-v1`) or the maximum SSB_length (`iso-ssbg1-v1`). */
91
+ /** K: the maximum SSB_length of a block (catalog `fec.sourceSymbols`). */
102
92
  readonly k: number
103
93
  /** Block time span (ms) — used as pacing threshold */
104
94
  readonly interleaveDepthMs: number
@@ -127,24 +117,17 @@ export class FecBlockMapper {
127
117
  }
128
118
 
129
119
  /**
130
- * The `k-padded-v1` locator: SS_ID → (SBN, ESI) with `SBN = floor(SS_ID / K)`
131
- * and `ESI = SS_ID mod K`, for MMTP over multicast UDP where the Source FEC
132
- * Payload ID carries SS_ID directly.
120
+ * SS_ID → (SBN, ESI). Only {@link NullBlockMapper} answers: a stream with an
121
+ * FEC instance is `iso-ssbg1-v1`, whose blocks are located by
122
+ * `(SS_Start, SSB_length)` range membership and never from SS_ID alone.
133
123
  *
134
- * @throws under `iso-ssbg1-v1`, where a block is located by
135
- * `(SS_Start, SSB_length)` range membership and never from SS_ID alone
124
+ * @throws for every stream with an FEC instance
136
125
  */
137
- fromSsId(ssId: number): { sbn: number; esi: number } {
138
- if (this.blockProfile !== 'k-padded-v1') {
139
- throw new Error(
140
- `fromSsId is the k-padded-v1 locator; a ${this.blockProfile ?? 'non-FEC'} stream locates a block by ` +
141
- '(SS_Start, SSB_length) range membership, never from SS_ID alone',
142
- )
143
- }
144
- return {
145
- sbn: Math.floor(ssId / this.k),
146
- esi: ssId % this.k,
147
- }
126
+ fromSsId(_ssId: number): { sbn: number; esi: number } {
127
+ throw new Error(
128
+ `fromSsId: a ${this.blockProfile ?? 'non-FEC'} stream locates a block by ` +
129
+ '(SS_Start, SSB_length) range membership, never from SS_ID alone',
130
+ )
148
131
  }
149
132
 
150
133
  /**
@@ -200,77 +183,6 @@ export class NullBlockMapper extends FecBlockMapper {
200
183
  }
201
184
  }
202
185
 
203
- /**
204
- * Object-level FEC (§9.1): each MoQ group (frame) is its own FEC block.
205
- * K_actual = ceil(frame_bytes / symbolSize) — varies per block.
206
- *
207
- * SBN = group_id, ESI = object_id.
208
- * SS_ID = Group_ID * sourceSymbols + Object_ID (§9.1.2).
209
- *
210
- * That SS_ID is the K-stride numbering of `k-padded-v1`; `iso-ssbg1-v1`
211
- * numbers SS_IDs consecutively per source packet (ISO/IEC 23008-1 C.5.2), so
212
- * §9.1 object FEC is defined only under `k-padded-v1` and the constructor
213
- * refuses any other profile.
214
- *
215
- * The stride of that numbering is the catalog `sourceSymbols`, never a
216
- * block's own K: a short block (K_actual < sourceSymbols) still starts at
217
- * `Group_ID * sourceSymbols` (its repair's SS_Start) and leaves the rest of
218
- * its stride unused, which is exactly `k-padded-v1` with `SSB_length =
219
- * K_actual`. So the SS_ID locator is the inherited
220
- * {@link FecBlockMapper.fromSsId} over `k = sourceSymbols`, the exact inverse
221
- * of {@link ObjectBlockMapper.derive}; it needs no per-block K and infers
222
- * nothing.
223
- *
224
- * This mapper does NOT carry per-block K. Each block discovers its own K from:
225
- * 1. Clean reception: counting source objects per group
226
- * 2. Repair packets: SSB_length in the Repair FEC Payload ID (ISO 23008-1 C.5.3)
227
- *
228
- * Callers MUST pass per-block K to FecBlockManager.addSourcePacket/addRepairPacket.
229
- * Created once per stream, GC'd on stream close (Effect cleanup).
230
- */
231
- export class ObjectBlockMapper extends FecBlockMapper {
232
- /** Catalog sourceSymbols (max K) — used ONLY for SS_ID encoding (§9.1.2).
233
- * Actual per-block K varies: K_actual = ceil(frame_bytes / symbolSize). */
234
- readonly sourceSymbolsMax: number
235
-
236
- /**
237
- * @param gopDurationMs - GOP/frame duration (ms)
238
- * @param sourceSymbolsMax - Catalog `fec.sourceSymbols`
239
- * @param blockProfile - Catalog `fec.blockProfile`; must be `k-padded-v1`
240
- */
241
- constructor(gopDurationMs: number, sourceSymbolsMax: number, blockProfile: FecBlockProfile) {
242
- if (sourceSymbolsMax <= 0) throw new Error('ObjectBlockMapper: sourceSymbolsMax must be > 0 (from catalog)')
243
- const profile = parseFecBlockProfile(blockProfile, 'ObjectBlockMapper fec.blockProfile')
244
- if (profile !== 'k-padded-v1') {
245
- throw new Error(
246
- `ObjectBlockMapper: §9.1 object FEC numbers SS_ID = Group_ID * K + Object_ID, the k-padded-v1 stride; ` +
247
- `it is not defined under ${profile}`,
248
- )
249
- }
250
- // D=1, K=sourceSymbolsMax for SS_ID math only — actual block K varies
251
- super({ interleaveDepthMs: gopDurationMs, gopDurationMs, k: sourceSymbolsMax, blockProfile: profile })
252
- this.sourceSymbolsMax = sourceSymbolsMax
253
- }
254
-
255
- /**
256
- * SBN = group_id, ESI = object_id. SS_ID per §9.1.2. Its inverse is the
257
- * inherited `fromSsId` (SBN = floor(SS_ID / sourceSymbols), ESI = SS_ID mod
258
- * sourceSymbols).
259
- */
260
- derive(groupId: number, objectId: number): FecCoordinates {
261
- return {
262
- sbn: groupId,
263
- esi: objectId,
264
- ssId: groupId * this.sourceSymbolsMax + objectId,
265
- }
266
- }
267
-
268
- /** One frame of latency: each frame is its own block. */
269
- override get pacingThresholdMs(): number {
270
- return this.gopDurationMs // one frame latency
271
- }
272
- }
273
-
274
186
  /**
275
187
  * Create a FecBlockMapper from catalog FEC config + measured GOP duration.
276
188
  *
@@ -278,8 +190,10 @@ export class ObjectBlockMapper extends FecBlockMapper {
278
190
  * {@link NullBlockMapper}; `fec.blockProfile` is not read, since a stream
279
191
  * without FEC names none.
280
192
  * - Otherwise `fec.blockProfile` is required (validated with
281
- * `parseFecBlockProfile`, throwing when absent or unknown), and
282
- * `mode: 'object'` → {@link ObjectBlockMapper}, else {@link FecBlockMapper}.
193
+ * `parseFecBlockProfile`, throwing unless it is `iso-ssbg1-v1`) and the
194
+ * result is a {@link FecBlockMapper}. `mode: 'object'` (per-group object
195
+ * FEC, SS_ID = Group_ID * K + Object_ID) throws: that numbering is the
196
+ * retired `k-padded-v1` stride, which `iso-ssbg1-v1` does not define.
283
197
  */
284
198
  export function createMapper(
285
199
  fecConfig: {
@@ -295,9 +209,10 @@ export function createMapper(
295
209
  return new NullBlockMapper(gopDurationMs)
296
210
  }
297
211
  const blockProfile = parseFecBlockProfile(fecConfig.blockProfile, 'fec.blockProfile')
298
- // extensions feature — see moqcast-draft docs/fec-extensions-material.md, not in public -00
299
212
  if (fecConfig.mode === 'object') {
300
- return new ObjectBlockMapper(gopDurationMs, fecConfig.sourceSymbols, blockProfile)
213
+ throw new Error(
214
+ `createMapper: object FEC numbers SS_ID = Group_ID * K + Object_ID, which ${blockProfile} does not define`,
215
+ )
301
216
  }
302
217
  return new FecBlockMapper({
303
218
  interleaveDepthMs: fecConfig.interleaveDepthMs && fecConfig.interleaveDepthMs > 0
package/src/index.ts CHANGED
@@ -66,13 +66,14 @@ export {
66
66
  deriveFecRetentionBlocks,
67
67
  tryDeriveFecRetentionBlocks,
68
68
  MAX_FEC_RETENTION_BLOCKS,
69
+ LOCAL_FEC_RETENTION_BLOCKS,
70
+ LOCAL_FEC_TIMEOUT_MS,
69
71
  playbackLatencyMs,
70
72
  } from "./oti.js";
71
73
 
72
74
  export {
73
75
  FecBlockMapper,
74
76
  NullBlockMapper,
75
- ObjectBlockMapper,
76
77
  createMapper,
77
78
  recoveredPacketMediaGroup,
78
79
  type FecBlockConfig,
@@ -89,6 +90,10 @@ export {
89
90
  extendSsId,
90
91
  repairFetchRange,
91
92
  repairGroupIdMatches,
93
+ RepairEpochReferences,
94
+ type RepairEpochStats,
92
95
  type RepairFetchAnchor,
93
96
  type RepairFetchRange,
97
+ type RepairGroupPlan,
98
+ type RepairLargestOutcome,
94
99
  } from "./repair-group.js";
package/src/oti.ts CHANGED
@@ -118,15 +118,16 @@ function validateOTIFields(F: number, T: number, Z: number, N: number, Al: numbe
118
118
  * Resolve a catalog FEC block's span in milliseconds.
119
119
  *
120
120
  * The catalog carries exactly ONE interleave field: `interleaveDepthMs`, the
121
- * full FEC block span in MILLISECONDS (draft-ramadan-moq-fec §5.1). Receivers
122
- * derive D -- the MEDIA UNITS per block, not a block count -- with
123
- * `deriveFecInterleaveDepth`. Reading D as a block count is BLO-35597.
121
+ * full FEC block send span in MILLISECONDS (draft-ramadan-moq-fec §5.1).
122
+ * Block boundaries still come from the wire's SS_Start and SSB_length; this
123
+ * field never supplies a group count or replaces those coordinates.
124
124
  *
125
- * Absent, non-finite or non-positive → undefined; callers treat that as
126
- * unconfigured and error rather than guess. Finiteness is checked separately
127
- * from `> 0` because `Infinity` satisfies `> 0`: a JSON catalog cannot spell
128
- * `Infinity`, but any overflowing literal (`1e999`) parses to it, and it would
129
- * then reach `fecBlockBudgetMs` as an unbounded span. The legacy
125
+ * Absent → undefined. An explicit zero is returned as zero: it is the
126
+ * catalog's no-elapsed-deadline sentinel, not an invitation to infer a span.
127
+ * Positive values must be safe integer milliseconds. Finiteness is checked
128
+ * separately from the range because `Infinity` satisfies `> 0`: a JSON catalog
129
+ * cannot spell `Infinity`, but any overflowing literal (`1e999`) parses to it,
130
+ * and it would then reach `fecBlockBudgetMs` as an unbounded span. The legacy
130
131
  * `interleaveDepth` frame-count key and
131
132
  * the `interleaveMs` alias were removed in the BLO-14526 clean cut — producers
132
133
  * emit milliseconds only, and there is deliberately NO fallback read.
@@ -139,7 +140,7 @@ export function catalogFecInterleaveMs(
139
140
  fec: FecInterleaveCatalog | undefined,
140
141
  ): number | undefined {
141
142
  const ms = fec?.interleaveDepthMs;
142
- return typeof ms === "number" && ms > 0 && Number.isFinite(ms) ? ms : undefined;
143
+ return typeof ms === "number" && Number.isSafeInteger(ms) && ms >= 0 ? ms : undefined;
143
144
  }
144
145
 
145
146
  /**
@@ -390,6 +391,16 @@ export function deriveFecRetentionBlocks(
390
391
  */
391
392
  export const MAX_FEC_RETENTION_BLOCKS = 0xff;
392
393
 
394
+ /**
395
+ * Receiver-local block retention used when the catalog carries no elapsed-time
396
+ * FEC window. This is a memory/cleanup bound only; it is never derived from
397
+ * media rate and never treated as a catalog deadline.
398
+ */
399
+ export const LOCAL_FEC_RETENTION_BLOCKS = 2;
400
+
401
+ /** Receiver-local cleanup budget used when the catalog has no elapsed span. */
402
+ export const LOCAL_FEC_TIMEOUT_MS = 500;
403
+
393
404
  /**
394
405
  * `deriveFecRetentionBlocks` without the throw: the block count, or `undefined`
395
406
  * when the geometry cannot define one.