@blockcast/fec-worker 0.1.0-main.64a6b356bf43 → 0.1.0-main.6598eedfb039

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.
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type {
12
12
  FecWorkerCommand,
13
+ FecSymbolCommand,
13
14
  FecWorkerEvent,
14
15
  FecTrackConfig,
15
16
  FecTrackStats,
@@ -23,7 +24,6 @@ export class FecWorkerClient {
23
24
  #onFrame: ((data: ArrayBuffer, meta: FrameMeta) => void) | null = null;
24
25
  #onSnapshot: ((stats: FecTrackStats) => void) | null = null;
25
26
  #onBlockUpdate: ((block: FecBlockSnapshot) => void) | null = null;
26
- #onGreenFill: ((sbn: number, data: ArrayBuffer) => void) | null = null;
27
27
  #onCleanupComplete: ((sbn: number) => void) | null = null;
28
28
  #onError: ((message: string, code?: FecWorkerErrorCode) => void) | null = null;
29
29
  #cleanupInFlight: Set<number> | null = null;
@@ -31,6 +31,20 @@ export class FecWorkerClient {
31
31
  #pendingResets = 0;
32
32
  #resetFailed = false;
33
33
  #disposed = false;
34
+ // Symbols fed during the current task, sent as one feedSymbols message
35
+ // when the task's microtasks run (BLO-36599). A packet stream delivers many
36
+ // symbols per task, and each postMessage is a structured-clone round trip
37
+ // on the page thread; the batch boundary is the task, not a size or timer.
38
+ #pendingSymbols: FecSymbolCommand[] = [];
39
+ #pendingTransfer: Transferable[] = [];
40
+ #flushQueued = false;
41
+ // Batching starts only once the Worker announced it accepts feedSymbols.
42
+ #batchSymbols = false;
43
+ // The Worker posts blockUpdate events only to a client that subscribed,
44
+ // which this client does once a callback is registered and the Worker
45
+ // announced the command (BLO-36599).
46
+ #blockUpdatesAnnounced = false;
47
+ #blockUpdatesSubscribed = false;
34
48
 
35
49
  /**
36
50
  * Create a FecWorkerClient.
@@ -66,6 +80,7 @@ export class FecWorkerClient {
66
80
  altaWasmModule?: WebAssembly.Module,
67
81
  ): void {
68
82
  if (this.#disposed) throw new Error("[FecWorkerClient] disposed");
83
+ this.#flushSymbols();
69
84
  const cmd: FecWorkerCommand = {
70
85
  type: "configure",
71
86
  config,
@@ -84,7 +99,8 @@ export class FecWorkerClient {
84
99
 
85
100
  /**
86
101
  * Feed a source symbol to the Worker. data ArrayBuffer is transferred (zero-copy).
87
- * Caller must not access data after this call — it is detached.
102
+ * Caller must not access data after this call — it is transferred with the
103
+ * task's symbol batch, before any later command of this client.
88
104
  *
89
105
  * Optional `alta` carries the detached ALTA authenticator bytes and the
90
106
  * byte offset in `data` where the signed payload ends. The auth buffer is
@@ -98,18 +114,22 @@ export class FecWorkerClient {
98
114
  alta?: { auth: ArrayBuffer; payloadEnd: number },
99
115
  ): void {
100
116
  if (this.#disposed) throw new Error("[FecWorkerClient] feedSource on a disposed client");
101
- const cmd: FecWorkerCommand = alta
102
- ? {
117
+ if (alta) {
118
+ this.#queueSymbol(
119
+ {
103
120
  type: "feedSource",
104
121
  ssId,
105
122
  data,
106
123
  ts,
107
124
  altaAuth: alta.auth,
108
125
  altaPayloadEnd: alta.payloadEnd,
109
- }
110
- : { type: "feedSource", ssId, data, ts };
111
- const transfer: Transferable[] = alta ? [data, alta.auth] : [data];
112
- this.#worker.postMessage(cmd, transfer);
126
+ },
127
+ data,
128
+ alta.auth,
129
+ );
130
+ } else {
131
+ this.#queueSymbol({ type: "feedSource", ssId, data, ts }, data);
132
+ }
113
133
  }
114
134
 
115
135
  /**
@@ -117,8 +137,8 @@ export class FecWorkerClient {
117
137
  * Caller must not access data after this call — it is detached.
118
138
  *
119
139
  * Carries the wire-level Repair FEC Payload ID fields (ISO 23008-1 §C.5.3):
120
- * - ssStart: SS_ID of first source symbol in the block (= SBN * SSB_length)
121
- * - ssbLength: K for this block
140
+ * - ssStart: SS_ID of first source symbol in the block (= SBN * configured K)
141
+ * - ssbLength: authored source-symbol count (1..configured K)
122
142
  * - rsId: repair symbol index within the block
123
143
  */
124
144
  feedRepair(
@@ -129,16 +149,38 @@ export class FecWorkerClient {
129
149
  ts: number,
130
150
  ): void {
131
151
  if (this.#disposed) throw new Error("[FecWorkerClient] feedRepair on a disposed client");
152
+ this.#queueSymbol({ type: "feedRepair", ssStart, ssbLength, rsId, data, ts }, data);
153
+ }
154
+
155
+ #queueSymbol(symbol: FecSymbolCommand, data: ArrayBuffer, auth?: ArrayBuffer): void {
156
+ if (!this.#batchSymbols) {
157
+ this.#worker.postMessage(symbol, auth ? [data, auth] : [data]);
158
+ return;
159
+ }
160
+ this.#pendingSymbols.push(symbol);
161
+ this.#pendingTransfer.push(data);
162
+ if (auth) this.#pendingTransfer.push(auth);
163
+ if (this.#flushQueued) return;
164
+ this.#flushQueued = true;
165
+ queueMicrotask(() => {
166
+ this.#flushQueued = false;
167
+ this.#flushSymbols();
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Send the symbols fed so far. Every other command calls this first, so a
173
+ * cleanup barrier, reset or signature still follows all earlier symbols.
174
+ */
175
+ #flushSymbols(): void {
176
+ if (this.#pendingSymbols.length === 0 || this.#disposed) return;
177
+ const symbols = this.#pendingSymbols;
178
+ const transfer = this.#pendingTransfer;
179
+ this.#pendingSymbols = [];
180
+ this.#pendingTransfer = [];
132
181
  this.#worker.postMessage(
133
- {
134
- type: "feedRepair",
135
- ssStart,
136
- ssbLength,
137
- rsId,
138
- data,
139
- ts,
140
- } satisfies FecWorkerCommand,
141
- [data],
182
+ { type: "feedSymbols", symbols } satisfies FecWorkerCommand,
183
+ transfer,
142
184
  );
143
185
  }
144
186
 
@@ -147,6 +189,7 @@ export class FecWorkerClient {
147
189
  */
148
190
  relayBlockSig(sbn: number, sig: ArrayBuffer, hash: ArrayBuffer): void {
149
191
  if (this.#disposed) throw new Error("[FecWorkerClient] relayBlockSig on a disposed client");
192
+ this.#flushSymbols();
150
193
  this.#worker.postMessage(
151
194
  { type: "relayBlockSig", sbn, sig, hash } satisfies FecWorkerCommand,
152
195
  [sig, hash],
@@ -178,6 +221,7 @@ export class FecWorkerClient {
178
221
  /** Reset this track's decoder epoch while preserving Worker configuration. */
179
222
  reset(): void {
180
223
  if (this.#disposed) throw new Error("[FecWorkerClient] reset on a disposed client");
224
+ this.#flushSymbols();
181
225
  this.#pendingResets++;
182
226
  this.#cleanupInFlight = null;
183
227
  this.#queuedCleanupSbns.clear();
@@ -185,6 +229,9 @@ export class FecWorkerClient {
185
229
  }
186
230
 
187
231
  #postCleanup(sbns: Set<number>): void {
232
+ // The cleanup is a queue-drain barrier: every symbol fed before it is
233
+ // posted must reach the Worker first.
234
+ this.#flushSymbols();
188
235
  this.#cleanupInFlight = sbns;
189
236
  const orderedSbns = [...sbns].sort((a, b) => a - b);
190
237
  this.#worker.postMessage(
@@ -202,14 +249,28 @@ export class FecWorkerClient {
202
249
  this.#onSnapshot = cb;
203
250
  }
204
251
 
205
- /** Register callback for individual block state changes. */
252
+ /**
253
+ * Register callback for individual block state changes. The Worker posts
254
+ * them only once asked, so registering subscribes (after the Worker
255
+ * announces `subscribeBlockUpdates`). An older Worker that does not
256
+ * announce it posts them unasked.
257
+ */
206
258
  onBlockUpdate(cb: (block: FecBlockSnapshot) => void): void {
207
259
  this.#onBlockUpdate = cb;
260
+ this.#subscribeBlockUpdates();
208
261
  }
209
262
 
210
- /** Register callback for green-fill frames. */
211
- onGreenFill(cb: (sbn: number, data: ArrayBuffer) => void): void {
212
- this.#onGreenFill = cb;
263
+ #subscribeBlockUpdates(): void {
264
+ if (
265
+ this.#disposed ||
266
+ this.#blockUpdatesSubscribed ||
267
+ !this.#blockUpdatesAnnounced ||
268
+ !this.#onBlockUpdate
269
+ ) return;
270
+ this.#blockUpdatesSubscribed = true;
271
+ this.#worker.postMessage(
272
+ { type: "subscribeBlockUpdates" } satisfies FecWorkerCommand,
273
+ );
213
274
  }
214
275
 
215
276
  /** Register callback fired after the Worker drains all commands queued before cleanup. */
@@ -239,6 +300,9 @@ export class FecWorkerClient {
239
300
  dispose(): void {
240
301
  if (this.#disposed) return;
241
302
  this.#disposed = true;
303
+ // A terminated Worker decodes nothing; symbols still batched are dropped.
304
+ this.#pendingSymbols = [];
305
+ this.#pendingTransfer = [];
242
306
  this.#cleanupInFlight = null;
243
307
  this.#queuedCleanupSbns.clear();
244
308
  this.#worker.postMessage({ type: "dispose" } satisfies FecWorkerCommand);
@@ -246,6 +310,16 @@ export class FecWorkerClient {
246
310
  }
247
311
 
248
312
  #handleEvent(event: FecWorkerEvent): void {
313
+ if (event.type === "capabilities") {
314
+ if (Array.isArray(event.commands) && event.commands.includes("feedSymbols")) {
315
+ this.#batchSymbols = true;
316
+ }
317
+ if (Array.isArray(event.commands) && event.commands.includes("subscribeBlockUpdates")) {
318
+ this.#blockUpdatesAnnounced = true;
319
+ this.#subscribeBlockUpdates();
320
+ }
321
+ return;
322
+ }
249
323
  if (event.type === "resetComplete") {
250
324
  this.#pendingResets = Math.max(0, this.#pendingResets - 1);
251
325
  this.#resetFailed = false;
@@ -271,9 +345,6 @@ export class FecWorkerClient {
271
345
  case "blockUpdate":
272
346
  this.#onBlockUpdate?.(event.block);
273
347
  break;
274
- case "greenFill":
275
- this.#onGreenFill?.(event.sbn, event.data);
276
- break;
277
348
  case "cleanupComplete":
278
349
  for (const sbn of event.sbns) {
279
350
  this.#onCleanupComplete?.(sbn);
@@ -57,13 +57,15 @@ export interface FecBlockSnapshot {
57
57
  /** Source block number */
58
58
  sbn: number;
59
59
  /** Current block state */
60
- state: "collecting" | "complete" | "recovered" | "failed" | "green-fill";
60
+ state: "collecting" | "complete" | "recovered" | "failed";
61
61
  /** Number of source symbols received */
62
62
  sourceReceived: number;
63
63
  /** Number of repair symbols received */
64
64
  repairReceived: number;
65
- /** Expected source symbols (K) */
65
+ /** Configured source-block geometry (K) */
66
66
  k: number;
67
+ /** Authored source symbols (SSB_length) once a repair symbol declared it */
68
+ ssbLength?: number;
67
69
  /** Expected repair symbols (P) */
68
70
  repairCount: number;
69
71
  /** Timestamp of first symbol arrival (ms) */
@@ -97,8 +99,6 @@ export interface FecTrackStats {
97
99
  blocksRecovered: number;
98
100
  /** Blocks that failed (unrecoverable) */
99
101
  blocksFailed: number;
100
- /** Frames replaced with green-fill */
101
- greenFillFrames: number;
102
102
  /** Recovery success rate 0-100 */
103
103
  recoveryRate: number;
104
104
 
@@ -143,7 +143,7 @@ export interface FecTrackStats {
143
143
  liveSbn: number;
144
144
  /** Active block snapshots */
145
145
  blocks: FecBlockSnapshot[];
146
- /** Number of blocks in the interleave window */
146
+ /** Number of blocks in the FEC block span */
147
147
  interleaveWindowBlocks: number;
148
148
 
149
149
  /** Average recovery latency (ms) */
@@ -168,7 +168,9 @@ export interface FecTrackStats {
168
168
  export type FecWorkerErrorCode =
169
169
  | "recovered-symbol-size-unconfigured"
170
170
  | "recovered-block-too-short"
171
+ | "recovered-symbol-ssb-length-invalid"
171
172
  | "repair-symbol-size-mismatch"
173
+ | "repair-ssb-length-conflict"
172
174
  | "reset-failed"
173
175
  | "unknown-command"
174
176
  | "command-failed"
@@ -185,8 +187,14 @@ export interface FrameMeta {
185
187
  recovered: boolean;
186
188
  /** Source block number this frame belongs to */
187
189
  sbn: number;
188
- /** Encoding Symbol ID within the recovered source block (0..K-1). */
190
+ /** Encoding Symbol ID within the recovered source block (0..ssbLength-1). */
189
191
  esi?: number;
192
+ /**
193
+ * Authored source-symbol count of the recovered block (the repair
194
+ * symbols' SSB_length, 1..K). Recovered frames are ESI 0..ssbLength-1;
195
+ * ESIs ssbLength..K-1 are encoder zero padding and are never emitted.
196
+ */
197
+ ssbLength?: number;
190
198
  }
191
199
 
192
200
  // --- Worker commands (main -> worker) ---
@@ -230,17 +238,28 @@ export type FecWorkerCommand =
230
238
  | {
231
239
  type: "feedRepair";
232
240
  /** SS_Start: SS_ID of the first source symbol in the block
233
- * (= SBN * SSB_length), from the MMTP Repair FEC Payload ID
241
+ * (= SBN * configured K), from the MMTP Repair FEC Payload ID
234
242
  * (ISO 23008-1 §C.5.3). */
235
243
  ssStart: number;
236
- /** SSB_length: K for this block (variable-K allowed per spec). */
244
+ /** Authored source-symbol count for this block; 1..configured K. */
237
245
  ssbLength: number;
238
246
  /** RS_ID: repair symbol index within the block; WASM maps to
239
- * repair ESI = SSB_length + RS_ID. */
247
+ * repair ESI = configured K + RS_ID. */
240
248
  rsId: number;
241
249
  data: ArrayBuffer;
242
250
  ts: number;
243
251
  }
252
+ | {
253
+ /**
254
+ * Every source and repair symbol the page fed in one task, in feed
255
+ * order, sharing one transfer list. The client sends symbols this
256
+ * way instead of one message per symbol (BLO-36599); the Worker
257
+ * handles each record exactly like the single-symbol command of the
258
+ * same `type`, and a record that fails does not stop the others.
259
+ */
260
+ type: "feedSymbols";
261
+ symbols: FecSymbolCommand[];
262
+ }
244
263
  | {
245
264
  type: "relayBlockSig";
246
265
  sbn: number;
@@ -253,8 +272,21 @@ export type FecWorkerCommand =
253
272
  sbns: number[];
254
273
  }
255
274
  | { type: "reset" }
275
+ /**
276
+ * Ask the Worker to post a blockUpdate on every block transition. Without
277
+ * it the Worker posts none: each one is a structured clone and a task on
278
+ * the page thread, and the player does not consume them (BLO-36599).
279
+ * Sent only to a Worker that announced it in `capabilities`.
280
+ */
281
+ | { type: "subscribeBlockUpdates" }
256
282
  | { type: "dispose" };
257
283
 
284
+ /** One symbol record of a `feedSymbols` batch. */
285
+ export type FecSymbolCommand = Extract<
286
+ FecWorkerCommand,
287
+ { type: "feedSource" } | { type: "feedRepair" }
288
+ >;
289
+
258
290
  // --- Worker events (worker -> main) ---
259
291
 
260
292
  /** Discriminated union of all events emitted from Worker to main thread. */
@@ -262,7 +294,16 @@ export type FecWorkerEvent =
262
294
  | { type: "frame"; data: ArrayBuffer; meta: FrameMeta }
263
295
  | { type: "snapshot"; stats: FecTrackStats }
264
296
  | { type: "blockUpdate"; block: FecBlockSnapshot }
265
- | { type: "greenFill"; sbn: number; data: ArrayBuffer }
266
297
  | { type: "cleanupComplete"; sbns: number[] }
267
298
  | { type: "resetComplete" }
299
+ /**
300
+ * Optional commands this Worker accepts, posted once it is configured.
301
+ * The client uses `feedSymbols` only after the Worker names it, so a page
302
+ * that meets an older cached Worker bundle keeps sending one command per
303
+ * symbol instead of a batch that Worker would reject (BLO-36599).
304
+ */
305
+ | { type: "capabilities"; commands: FecWorkerOptionalCommand[] }
268
306
  | { type: "error"; message: string; code?: FecWorkerErrorCode };
307
+
308
+ /** Commands a client may send only after the Worker announced them. */
309
+ export type FecWorkerOptionalCommand = "feedSymbols" | "subscribeBlockUpdates";