@openmoq/playback 0.0.0-stage → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +202 -0
- package/README.md +18 -2
- package/dist/adaptive-tolerance.d.ts +128 -0
- package/dist/adaptive-tolerance.d.ts.map +1 -0
- package/dist/adaptive-tolerance.js +339 -0
- package/dist/adaptive-tolerance.js.map +1 -0
- package/dist/bandwidth-estimator.d.ts +49 -0
- package/dist/bandwidth-estimator.d.ts.map +1 -0
- package/dist/bandwidth-estimator.js +77 -0
- package/dist/bandwidth-estimator.js.map +1 -0
- package/dist/browser/index.global.js +2813 -0
- package/dist/browser/index.global.js.map +1 -0
- package/dist/browser/index.js +2788 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/buffer-based-controller.d.ts +87 -0
- package/dist/buffer-based-controller.d.ts.map +1 -0
- package/dist/buffer-based-controller.js +85 -0
- package/dist/buffer-based-controller.js.map +1 -0
- package/dist/decoder-state.d.ts +103 -0
- package/dist/decoder-state.d.ts.map +1 -0
- package/dist/decoder-state.js +145 -0
- package/dist/decoder-state.js.map +1 -0
- package/dist/gap-detector.d.ts +83 -0
- package/dist/gap-detector.d.ts.map +1 -0
- package/dist/gap-detector.js +129 -0
- package/dist/gap-detector.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/jitter-buffer.d.ts +73 -0
- package/dist/jitter-buffer.d.ts.map +1 -0
- package/dist/jitter-buffer.js +216 -0
- package/dist/jitter-buffer.js.map +1 -0
- package/dist/keyframe-validator.d.ts +26 -0
- package/dist/keyframe-validator.d.ts.map +1 -0
- package/dist/keyframe-validator.js +170 -0
- package/dist/keyframe-validator.js.map +1 -0
- package/dist/pipeline.d.ts +215 -0
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +852 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/recovery.d.ts +166 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +176 -0
- package/dist/recovery.js.map +1 -0
- package/dist/render-scheduler.d.ts +50 -0
- package/dist/render-scheduler.d.ts.map +1 -0
- package/dist/render-scheduler.js +84 -0
- package/dist/render-scheduler.js.map +1 -0
- package/dist/startup-buffer.d.ts +68 -0
- package/dist/startup-buffer.d.ts.map +1 -0
- package/dist/startup-buffer.js +107 -0
- package/dist/startup-buffer.js.map +1 -0
- package/dist/sync.d.ts +234 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +343 -0
- package/dist/sync.js.map +1 -0
- package/dist/types.d.ts +229 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/package.json +56 -3
|
@@ -0,0 +1,2788 @@
|
|
|
1
|
+
// src/jitter-buffer.ts
|
|
2
|
+
function compare(a, b) {
|
|
3
|
+
if (a.groupId < b.groupId) return -1;
|
|
4
|
+
if (a.groupId > b.groupId) return 1;
|
|
5
|
+
if (a.objectId < b.objectId) return -1;
|
|
6
|
+
if (a.objectId > b.objectId) return 1;
|
|
7
|
+
return 0;
|
|
8
|
+
}
|
|
9
|
+
var JitterBuffer = class {
|
|
10
|
+
heap = [];
|
|
11
|
+
maxDepth;
|
|
12
|
+
constructor(maxDepth) {
|
|
13
|
+
this.maxDepth = maxDepth;
|
|
14
|
+
}
|
|
15
|
+
/** Number of objects currently buffered. */
|
|
16
|
+
get size() {
|
|
17
|
+
return this.heap.length;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Insert an object into the buffer.
|
|
21
|
+
* @returns true if accepted, false if buffer is full.
|
|
22
|
+
*/
|
|
23
|
+
insert(obj) {
|
|
24
|
+
if (this.heap.length >= this.maxDepth) return false;
|
|
25
|
+
this.heap.push(obj);
|
|
26
|
+
this.bubbleUp(this.heap.length - 1);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Peek at the minimum object without removing it.
|
|
31
|
+
*/
|
|
32
|
+
peek() {
|
|
33
|
+
return this.heap[0];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Remove and return the minimum object.
|
|
37
|
+
*/
|
|
38
|
+
extract() {
|
|
39
|
+
if (this.heap.length === 0) return void 0;
|
|
40
|
+
const min = this.heap[0];
|
|
41
|
+
const last = this.heap.pop();
|
|
42
|
+
if (this.heap.length > 0) {
|
|
43
|
+
this.heap[0] = last;
|
|
44
|
+
this.sinkDown(0);
|
|
45
|
+
}
|
|
46
|
+
return min;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Check if the object at (groupId, objectId) is the minimum in the buffer.
|
|
50
|
+
*/
|
|
51
|
+
hasContiguous(groupId, objectId) {
|
|
52
|
+
if (this.heap.length === 0) return false;
|
|
53
|
+
const top = this.heap[0];
|
|
54
|
+
return top.groupId === groupId && top.objectId === objectId;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Remove and return all objects for a given group, in sorted order.
|
|
58
|
+
*/
|
|
59
|
+
drainGroup(groupId) {
|
|
60
|
+
const result = [];
|
|
61
|
+
const remaining = [];
|
|
62
|
+
for (const obj of this.heap) {
|
|
63
|
+
if (obj.groupId === groupId) {
|
|
64
|
+
result.push(obj);
|
|
65
|
+
} else {
|
|
66
|
+
remaining.push(obj);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
result.sort(compare);
|
|
70
|
+
this.heap.length = 0;
|
|
71
|
+
for (const obj of remaining) {
|
|
72
|
+
this.heap.push(obj);
|
|
73
|
+
}
|
|
74
|
+
this.rebuild();
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Remove all objects with groupId less than the given threshold.
|
|
79
|
+
* @returns Number of objects removed.
|
|
80
|
+
*/
|
|
81
|
+
discardBefore(groupId) {
|
|
82
|
+
const remaining = [];
|
|
83
|
+
let removed = 0;
|
|
84
|
+
for (const obj of this.heap) {
|
|
85
|
+
if (obj.groupId < groupId) {
|
|
86
|
+
removed++;
|
|
87
|
+
} else {
|
|
88
|
+
remaining.push(obj);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
this.heap.length = 0;
|
|
92
|
+
for (const obj of remaining) {
|
|
93
|
+
this.heap.push(obj);
|
|
94
|
+
}
|
|
95
|
+
this.rebuild();
|
|
96
|
+
return removed;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Evict the least-important object from the buffer.
|
|
100
|
+
*
|
|
101
|
+
* Finds the data object with the highest publisherPriority number
|
|
102
|
+
* (= lowest importance per §7: "lower priority number indicates higher
|
|
103
|
+
* priority"). Tiebreaker: oldest group (lowest groupId).
|
|
104
|
+
*
|
|
105
|
+
* Gap objects and objects with undefined priority are not evictable.
|
|
106
|
+
*
|
|
107
|
+
* @returns The evicted object, or undefined if no candidate exists.
|
|
108
|
+
* @see draft-ietf-moq-transport-16 §7 (Priority-based dropping)
|
|
109
|
+
*/
|
|
110
|
+
evictLowestImportance() {
|
|
111
|
+
if (this.heap.length === 0) return void 0;
|
|
112
|
+
let worstIdx = -1;
|
|
113
|
+
let worstPriority = -1;
|
|
114
|
+
let worstGroupId = -1n;
|
|
115
|
+
for (let i = 0; i < this.heap.length; i++) {
|
|
116
|
+
const obj = this.heap[i];
|
|
117
|
+
if (obj.kind !== "data" || obj.publisherPriority === void 0) continue;
|
|
118
|
+
const p = obj.publisherPriority;
|
|
119
|
+
if (p > worstPriority || p === worstPriority && obj.groupId < worstGroupId) {
|
|
120
|
+
worstIdx = i;
|
|
121
|
+
worstPriority = p;
|
|
122
|
+
worstGroupId = obj.groupId;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (worstIdx === -1) return void 0;
|
|
126
|
+
const evicted = this.heap[worstIdx];
|
|
127
|
+
const last = this.heap.pop();
|
|
128
|
+
if (worstIdx < this.heap.length) {
|
|
129
|
+
this.heap[worstIdx] = last;
|
|
130
|
+
this.rebuild();
|
|
131
|
+
}
|
|
132
|
+
return evicted;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Clear all buffered objects.
|
|
136
|
+
*/
|
|
137
|
+
clear() {
|
|
138
|
+
this.heap.length = 0;
|
|
139
|
+
}
|
|
140
|
+
// ─── Heap internals ─────────────────────────────────────────────
|
|
141
|
+
bubbleUp(index) {
|
|
142
|
+
while (index > 0) {
|
|
143
|
+
const parentIdx = index - 1 >> 1;
|
|
144
|
+
if (compare(this.heap[index], this.heap[parentIdx]) >= 0) break;
|
|
145
|
+
this.swap(index, parentIdx);
|
|
146
|
+
index = parentIdx;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
sinkDown(index) {
|
|
150
|
+
const length = this.heap.length;
|
|
151
|
+
while (true) {
|
|
152
|
+
let smallest = index;
|
|
153
|
+
const left = 2 * index + 1;
|
|
154
|
+
const right = 2 * index + 2;
|
|
155
|
+
if (left < length && compare(this.heap[left], this.heap[smallest]) < 0) {
|
|
156
|
+
smallest = left;
|
|
157
|
+
}
|
|
158
|
+
if (right < length && compare(this.heap[right], this.heap[smallest]) < 0) {
|
|
159
|
+
smallest = right;
|
|
160
|
+
}
|
|
161
|
+
if (smallest === index) break;
|
|
162
|
+
this.swap(index, smallest);
|
|
163
|
+
index = smallest;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
swap(i, j) {
|
|
167
|
+
const tmp = this.heap[i];
|
|
168
|
+
this.heap[i] = this.heap[j];
|
|
169
|
+
this.heap[j] = tmp;
|
|
170
|
+
}
|
|
171
|
+
/** Rebuild the heap from an unordered array (O(n)). */
|
|
172
|
+
rebuild() {
|
|
173
|
+
for (let i = (this.heap.length >> 1) - 1; i >= 0; i--) {
|
|
174
|
+
this.sinkDown(i);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// src/gap-detector.ts
|
|
180
|
+
var GapAction = {
|
|
181
|
+
/** Continue waiting for the missing group. */
|
|
182
|
+
WAIT: "wait",
|
|
183
|
+
/** Skip forward to the next available group with a keyframe. */
|
|
184
|
+
SKIP_FORWARD: "skip_forward",
|
|
185
|
+
/** Track has ended. */
|
|
186
|
+
TRACK_ENDED: "track_ended"
|
|
187
|
+
};
|
|
188
|
+
var GapDetector = class {
|
|
189
|
+
_gapTimeoutUs;
|
|
190
|
+
clock;
|
|
191
|
+
/** Set of group IDs explicitly terminated via END_OF_GROUP. */
|
|
192
|
+
endedGroups = /* @__PURE__ */ new Set();
|
|
193
|
+
/** Whether END_OF_TRACK has been received. */
|
|
194
|
+
trackEnded = false;
|
|
195
|
+
/** Earliest observation time for each group. */
|
|
196
|
+
groupFirstSeenUs = /* @__PURE__ */ new Map();
|
|
197
|
+
constructor(config) {
|
|
198
|
+
this._gapTimeoutUs = config.gapTimeoutUs;
|
|
199
|
+
this.clock = config.clock;
|
|
200
|
+
}
|
|
201
|
+
/** Current gap timeout in microseconds. Mutable for adaptive tolerance. */
|
|
202
|
+
get gapTimeoutUs() {
|
|
203
|
+
return this._gapTimeoutUs;
|
|
204
|
+
}
|
|
205
|
+
set gapTimeoutUs(value) {
|
|
206
|
+
this._gapTimeoutUs = value;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Notify that an object has been received for a group.
|
|
210
|
+
* Records the observation time for timeout calculations.
|
|
211
|
+
*/
|
|
212
|
+
observeGroup(groupId) {
|
|
213
|
+
if (!this.groupFirstSeenUs.has(groupId)) {
|
|
214
|
+
this.groupFirstSeenUs.set(groupId, this.clock.now());
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Notify that an END_OF_GROUP status was received.
|
|
219
|
+
* @see draft-ietf-moq-transport-16 §10.2.1.1 — ObjectStatus 0x3
|
|
220
|
+
*/
|
|
221
|
+
observeEndOfGroup(groupId) {
|
|
222
|
+
this.endedGroups.add(groupId);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Notify that an END_OF_TRACK status was received.
|
|
226
|
+
* @see draft-ietf-moq-transport-16 §10.2.1.1 — ObjectStatus 0x4
|
|
227
|
+
*/
|
|
228
|
+
observeEndOfTrack() {
|
|
229
|
+
this.trackEnded = true;
|
|
230
|
+
}
|
|
231
|
+
/** Whether the track has ended. */
|
|
232
|
+
get isTrackEnded() {
|
|
233
|
+
return this.trackEnded;
|
|
234
|
+
}
|
|
235
|
+
/** Reset all state — used when resuming after pause on a live stream. */
|
|
236
|
+
reset() {
|
|
237
|
+
this.endedGroups.clear();
|
|
238
|
+
this.trackEnded = false;
|
|
239
|
+
this.groupFirstSeenUs.clear();
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Evaluate the current gap state.
|
|
243
|
+
*
|
|
244
|
+
* @param lastConsumedGroupId The last group fully consumed by the decoder
|
|
245
|
+
* @param availableGroupIds Groups currently buffered (sorted ascending)
|
|
246
|
+
* @returns Decision: WAIT, SKIP_FORWARD, or TRACK_ENDED
|
|
247
|
+
*/
|
|
248
|
+
evaluate(lastConsumedGroupId, availableGroupIds) {
|
|
249
|
+
if (this.trackEnded) {
|
|
250
|
+
return { action: GapAction.TRACK_ENDED };
|
|
251
|
+
}
|
|
252
|
+
if (availableGroupIds.length === 0) {
|
|
253
|
+
return { action: GapAction.WAIT };
|
|
254
|
+
}
|
|
255
|
+
const nextExpected = lastConsumedGroupId + 1n;
|
|
256
|
+
const sorted = [...availableGroupIds].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
257
|
+
const firstAvailable = sorted[0];
|
|
258
|
+
if (firstAvailable <= nextExpected) {
|
|
259
|
+
return { action: GapAction.WAIT };
|
|
260
|
+
}
|
|
261
|
+
let allEnded = true;
|
|
262
|
+
for (let g = nextExpected; g < firstAvailable; g++) {
|
|
263
|
+
if (!this.endedGroups.has(g)) {
|
|
264
|
+
allEnded = false;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (allEnded) {
|
|
269
|
+
return { action: GapAction.SKIP_FORWARD, targetGroupId: firstAvailable };
|
|
270
|
+
}
|
|
271
|
+
const now = this.clock.now();
|
|
272
|
+
const firstSeenAt = this.groupFirstSeenUs.get(firstAvailable);
|
|
273
|
+
if (firstSeenAt !== void 0) {
|
|
274
|
+
const elapsed = now - firstSeenAt;
|
|
275
|
+
if (elapsed >= this.gapTimeoutUs) {
|
|
276
|
+
return { action: GapAction.SKIP_FORWARD, targetGroupId: firstAvailable };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { action: GapAction.WAIT };
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
// src/decoder-state.ts
|
|
284
|
+
var DecoderState = {
|
|
285
|
+
/** Not yet configured. */
|
|
286
|
+
IDLE: "idle",
|
|
287
|
+
/** Configured but waiting for a keyframe. */
|
|
288
|
+
NEEDS_KEYFRAME: "needs_keyframe",
|
|
289
|
+
/** Actively decoding. */
|
|
290
|
+
DECODING: "decoding",
|
|
291
|
+
/** Track ended. */
|
|
292
|
+
ENDED: "ended"
|
|
293
|
+
};
|
|
294
|
+
var DecoderStateMachine = class {
|
|
295
|
+
_state = DecoderState.IDLE;
|
|
296
|
+
_mediaType;
|
|
297
|
+
lastConfig = null;
|
|
298
|
+
constructor(mediaType) {
|
|
299
|
+
this._mediaType = mediaType;
|
|
300
|
+
}
|
|
301
|
+
get state() {
|
|
302
|
+
return this._state;
|
|
303
|
+
}
|
|
304
|
+
get mediaType() {
|
|
305
|
+
return this._mediaType;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Provide codec configuration.
|
|
309
|
+
*
|
|
310
|
+
* If the config is identical to the last one, skip reconfiguration
|
|
311
|
+
* (stay in current state). This avoids resetting to NEEDS_KEYFRAME
|
|
312
|
+
* on every group boundary when the server sends videoConfig on
|
|
313
|
+
* every group's first object with unchanged codec parameters.
|
|
314
|
+
*
|
|
315
|
+
* Transitions IDLE/DECODING → NEEDS_KEYFRAME (video) or DECODING (audio)
|
|
316
|
+
* only when the config actually changes.
|
|
317
|
+
*/
|
|
318
|
+
configure(config) {
|
|
319
|
+
if (this.lastConfig !== null && this.configEquals(this.lastConfig, config)) {
|
|
320
|
+
return { action: "skip", reason: "config unchanged" };
|
|
321
|
+
}
|
|
322
|
+
this.lastConfig = config;
|
|
323
|
+
if (this._mediaType === "audio") {
|
|
324
|
+
this._state = DecoderState.DECODING;
|
|
325
|
+
} else {
|
|
326
|
+
this._state = DecoderState.NEEDS_KEYFRAME;
|
|
327
|
+
}
|
|
328
|
+
return { action: "configure", config };
|
|
329
|
+
}
|
|
330
|
+
/** Byte-level config comparison. */
|
|
331
|
+
configEquals(a, b) {
|
|
332
|
+
if (a.length !== b.length) return false;
|
|
333
|
+
for (let i = 0; i < a.length; i++) {
|
|
334
|
+
if (a[i] !== b[i]) return false;
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Process a video chunk.
|
|
340
|
+
* @see draft-ietf-moq-loc-01 §2.3.2.2 (VideoFrameMarking)
|
|
341
|
+
*/
|
|
342
|
+
processVideoChunk(chunk, marking) {
|
|
343
|
+
if (this._state === DecoderState.IDLE || this._state === DecoderState.ENDED) {
|
|
344
|
+
return { action: "skip", reason: `decoder is ${this._state}` };
|
|
345
|
+
}
|
|
346
|
+
if (this._state === DecoderState.NEEDS_KEYFRAME) {
|
|
347
|
+
const isKeyframe = marking?.independent === true;
|
|
348
|
+
if (!isKeyframe) {
|
|
349
|
+
return { action: "skip", reason: "waiting for keyframe" };
|
|
350
|
+
}
|
|
351
|
+
this._state = DecoderState.DECODING;
|
|
352
|
+
return { action: "decode", chunk };
|
|
353
|
+
}
|
|
354
|
+
return { action: "decode", chunk };
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Process an audio chunk. Audio codecs (Opus, AAC-LC) produce
|
|
358
|
+
* independently decodable frames — no keyframe gating needed.
|
|
359
|
+
*/
|
|
360
|
+
processAudioChunk(chunk) {
|
|
361
|
+
if (this._state === DecoderState.IDLE || this._state === DecoderState.ENDED) {
|
|
362
|
+
return { action: "skip", reason: `decoder is ${this._state}` };
|
|
363
|
+
}
|
|
364
|
+
this._state = DecoderState.DECODING;
|
|
365
|
+
return { action: "decode", chunk };
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Signal a gap — decoder needs to wait for next keyframe (video only).
|
|
369
|
+
* Audio: stays in DECODING since all chunks are independently decodable.
|
|
370
|
+
*/
|
|
371
|
+
notifyGap() {
|
|
372
|
+
if (this._mediaType === "audio") {
|
|
373
|
+
return { action: "reset", reason: "gap detected (audio will resume immediately)" };
|
|
374
|
+
}
|
|
375
|
+
this._state = DecoderState.NEEDS_KEYFRAME;
|
|
376
|
+
return { action: "reset", reason: "gap detected" };
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Signal track ended.
|
|
380
|
+
* @see draft-ietf-moq-transport-16 §10.2.1.1
|
|
381
|
+
*/
|
|
382
|
+
notifyTrackEnded() {
|
|
383
|
+
this._state = DecoderState.ENDED;
|
|
384
|
+
return { action: "flush" };
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// src/sync.ts
|
|
389
|
+
var DEFAULT_DROP_THRESHOLD_US = 5e5;
|
|
390
|
+
var DEFAULT_WALL_CLOCK = { now: () => Date.now() * 1e3 };
|
|
391
|
+
var SyncController = class {
|
|
392
|
+
_driftThresholdUs;
|
|
393
|
+
dropThresholdUs;
|
|
394
|
+
clock;
|
|
395
|
+
/** Local clock time when audio reference was set. */
|
|
396
|
+
localBaselineUs;
|
|
397
|
+
/** CaptureTimestamp of the audio reference sample. */
|
|
398
|
+
captureBaselineUs;
|
|
399
|
+
/** Accumulated drift measurement. */
|
|
400
|
+
_currentDriftUs = 0;
|
|
401
|
+
/**
|
|
402
|
+
* Video join offset — shifts video render times forward during live join.
|
|
403
|
+
*
|
|
404
|
+
* When joining a live stream, video starts from the GOP boundary
|
|
405
|
+
* (which has an older CaptureTimestamp than the current audio).
|
|
406
|
+
* This offset shifts video render times forward so the first frames
|
|
407
|
+
* render in the future instead of the past, preventing startup stutter.
|
|
408
|
+
*
|
|
409
|
+
* Snaps to zero when the video timeline catches up to the audio live edge.
|
|
410
|
+
* Audio render times are unaffected.
|
|
411
|
+
*/
|
|
412
|
+
_videoJoinOffsetUs = 0;
|
|
413
|
+
// ── Catch-up state ─────────────────────────────────────────
|
|
414
|
+
wallClock;
|
|
415
|
+
targetLatencyMs;
|
|
416
|
+
maxCatchUpRate;
|
|
417
|
+
catchUpThresholdMs;
|
|
418
|
+
catchUpRecoveryMs;
|
|
419
|
+
_catchUpActive = false;
|
|
420
|
+
_currentRate = 1;
|
|
421
|
+
_lastLatencyUs = 0;
|
|
422
|
+
constructor(config) {
|
|
423
|
+
this._driftThresholdUs = config.driftThresholdUs;
|
|
424
|
+
this.dropThresholdUs = config.dropThresholdUs ?? DEFAULT_DROP_THRESHOLD_US;
|
|
425
|
+
this.clock = config.clock;
|
|
426
|
+
this.wallClock = config.wallClock ?? DEFAULT_WALL_CLOCK;
|
|
427
|
+
this.targetLatencyMs = config.targetLatencyMs;
|
|
428
|
+
this.maxCatchUpRate = config.maxCatchUpRate ?? 1;
|
|
429
|
+
this.catchUpThresholdMs = config.catchUpThresholdMs ?? 500;
|
|
430
|
+
this.catchUpRecoveryMs = config.catchUpRecoveryMs ?? 50;
|
|
431
|
+
}
|
|
432
|
+
/** Whether a sync reference has been established. */
|
|
433
|
+
get hasReference() {
|
|
434
|
+
return this.localBaselineUs !== void 0;
|
|
435
|
+
}
|
|
436
|
+
/** Current drift magnitude in microseconds. */
|
|
437
|
+
get currentDriftUs() {
|
|
438
|
+
return this._currentDriftUs;
|
|
439
|
+
}
|
|
440
|
+
/** Current drift threshold in microseconds. Mutable for adaptive tolerance. */
|
|
441
|
+
get driftThresholdUs() {
|
|
442
|
+
return this._driftThresholdUs;
|
|
443
|
+
}
|
|
444
|
+
set driftThresholdUs(value) {
|
|
445
|
+
this._driftThresholdUs = value;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Whether PRESENTATION SCHEDULE drift exceeds the threshold.
|
|
449
|
+
*
|
|
450
|
+
* Deliberately has NO live-join suppression. The exact queued schedule
|
|
451
|
+
* already contains the join offset, so missing that schedule during a join
|
|
452
|
+
* is a real miss. (The legacy PTS-era metric needed the suppression because
|
|
453
|
+
* the offset itself showed up as drift; see {@link needsResync}.)
|
|
454
|
+
*/
|
|
455
|
+
get presentationDriftExceeded() {
|
|
456
|
+
return Math.abs(this._currentDriftUs) >= this._driftThresholdUs;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* @deprecated Threshold check for the legacy {@link reportActualRenderTime}
|
|
460
|
+
* metric, retaining its live-join suppression. Use
|
|
461
|
+
* {@link presentationDriftExceeded} with schedule-based measurement.
|
|
462
|
+
*/
|
|
463
|
+
get needsResync() {
|
|
464
|
+
if (this._videoJoinOffsetUs > 0) return false;
|
|
465
|
+
return Math.abs(this._currentDriftUs) >= this._driftThresholdUs;
|
|
466
|
+
}
|
|
467
|
+
/** Whether catch-up is currently active. */
|
|
468
|
+
get catchUpActive() {
|
|
469
|
+
return this._catchUpActive;
|
|
470
|
+
}
|
|
471
|
+
/** Current playback rate (1.0 when catch-up inactive). */
|
|
472
|
+
get currentRate() {
|
|
473
|
+
return this._currentRate;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Last measured latency in microseconds.
|
|
477
|
+
* 0 before first measurement.
|
|
478
|
+
* @see draft-ietf-moq-loc-01 §2.3.1.1
|
|
479
|
+
*/
|
|
480
|
+
get latencyUs() {
|
|
481
|
+
return this._lastLatencyUs;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Establish sync reference from an audio sample.
|
|
485
|
+
* Maps captureTimestamp to the current local clock time.
|
|
486
|
+
* Resets drift measurement.
|
|
487
|
+
*
|
|
488
|
+
* @param captureTimestampUs CaptureTimestamp in microseconds
|
|
489
|
+
*/
|
|
490
|
+
setAudioReference(captureTimestampUs) {
|
|
491
|
+
this.localBaselineUs = this.clock.now();
|
|
492
|
+
this.captureBaselineUs = captureTimestampUs;
|
|
493
|
+
this._currentDriftUs = 0;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Establish sync reference from a video frame (video-only mode).
|
|
497
|
+
* No-op if a reference already exists — audio has priority.
|
|
498
|
+
*
|
|
499
|
+
* @param captureTimestampUs CaptureTimestamp of the first video frame
|
|
500
|
+
*/
|
|
501
|
+
setVideoReference(captureTimestampUs) {
|
|
502
|
+
if (this.hasReference) return;
|
|
503
|
+
this.localBaselineUs = this.clock.now();
|
|
504
|
+
this.captureBaselineUs = captureTimestampUs;
|
|
505
|
+
this._currentDriftUs = 0;
|
|
506
|
+
}
|
|
507
|
+
/** Current video join offset in microseconds. 0 = no offset active. */
|
|
508
|
+
get videoJoinOffsetUs() {
|
|
509
|
+
return this._videoJoinOffsetUs;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Apply video join re-anchor for live stream join.
|
|
513
|
+
*
|
|
514
|
+
* Called when the first video keyframe is decoded after a live join.
|
|
515
|
+
* Computes an offset that shifts video render times forward so the
|
|
516
|
+
* first frame renders at `now + cushion` instead of in the past.
|
|
517
|
+
*
|
|
518
|
+
* The offset is a constant added to all video render times. It preserves
|
|
519
|
+
* inter-frame timing (33ms spacing) while shifting the whole timeline.
|
|
520
|
+
* Snaps to zero when video catches up to the live edge.
|
|
521
|
+
*
|
|
522
|
+
* @param firstKeyframeCaptureUs CaptureTimestamp of the first keyframe
|
|
523
|
+
* @param cushionUs Extra future cushion (default: 16ms ≈ 1 rAF tick)
|
|
524
|
+
*/
|
|
525
|
+
onVideoJoin(firstKeyframeCaptureUs, cushionUs = 16e3) {
|
|
526
|
+
if (this.localBaselineUs === void 0 || this.captureBaselineUs === void 0) return;
|
|
527
|
+
const naiveRenderTimeUs = this.localBaselineUs + Number(firstKeyframeCaptureUs - this.captureBaselineUs);
|
|
528
|
+
const nowUs = this.clock.now();
|
|
529
|
+
const gapUs = nowUs + cushionUs - naiveRenderTimeUs;
|
|
530
|
+
if (gapUs > 0) {
|
|
531
|
+
this._videoJoinOffsetUs = gapUs;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Compute render timing for a video frame.
|
|
536
|
+
*
|
|
537
|
+
* @param captureTimestampUs CaptureTimestamp in microseconds
|
|
538
|
+
*/
|
|
539
|
+
computeVideoRenderTime(captureTimestampUs) {
|
|
540
|
+
const timing = this.computeRenderTime(captureTimestampUs);
|
|
541
|
+
if (timing === null) return null;
|
|
542
|
+
if (this._videoJoinOffsetUs > 0) {
|
|
543
|
+
const adjustedRenderTimeUs = timing.renderTimeUs + this._videoJoinOffsetUs;
|
|
544
|
+
const nowUs = this.clock.now();
|
|
545
|
+
if (timing.renderTimeUs > nowUs) {
|
|
546
|
+
this._videoJoinOffsetUs = 0;
|
|
547
|
+
return timing;
|
|
548
|
+
}
|
|
549
|
+
const offsetUs = adjustedRenderTimeUs - nowUs;
|
|
550
|
+
return {
|
|
551
|
+
renderTimeUs: adjustedRenderTimeUs,
|
|
552
|
+
offsetUs,
|
|
553
|
+
shouldDrop: offsetUs < -this.dropThresholdUs
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
return timing;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Compute render timing for an audio sample.
|
|
560
|
+
*
|
|
561
|
+
* @param captureTimestampUs CaptureTimestamp in microseconds
|
|
562
|
+
*/
|
|
563
|
+
computeAudioRenderTime(captureTimestampUs) {
|
|
564
|
+
return this.computeRenderTime(captureTimestampUs);
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Record PRESENTATION SCHEDULE drift: did the renderer present the frame
|
|
568
|
+
* when it was told to?
|
|
569
|
+
*
|
|
570
|
+
* `scheduledUs` must be the EXACT timestamp stored with the rendered frame.
|
|
571
|
+
* It already accounts for the playout cushion actually applied to that
|
|
572
|
+
* frame, the live-join offset, decode-output recomputation and late-frame
|
|
573
|
+
* handling — none of which may be recomputed or subtracted later, because an
|
|
574
|
+
* adaptive cushion can move between enqueue and presentation.
|
|
575
|
+
*
|
|
576
|
+
* The schedule is REQUIRED here. Optionality lives at
|
|
577
|
+
* `FrameRenderedFeedback`, where the pipeline suppresses the whole
|
|
578
|
+
* measurement/event transaction cleanly: missing evidence is not drift.
|
|
579
|
+
*
|
|
580
|
+
* Actual audio/video skew is measured separately as `sync_skew`.
|
|
581
|
+
*/
|
|
582
|
+
reportPresentationTiming(scheduledUs, actualUs) {
|
|
583
|
+
this._currentDriftUs = actualUs - scheduledUs;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* @deprecated Compares against the UNCUSHIONED PTS-anchored time, so a
|
|
587
|
+
* stream presenting exactly on its intended schedule reports the whole
|
|
588
|
+
* playout cushion as drift. Retained as a compatibility path only; use
|
|
589
|
+
* {@link reportPresentationTiming}.
|
|
590
|
+
*/
|
|
591
|
+
reportActualRenderTime(captureTimestampUs, actualLocalUs) {
|
|
592
|
+
if (this.localBaselineUs === void 0 || this.captureBaselineUs === void 0) return;
|
|
593
|
+
const expectedRenderUs = this.localBaselineUs + Number(captureTimestampUs - this.captureBaselineUs);
|
|
594
|
+
this._currentDriftUs = actualLocalUs - expectedRenderUs;
|
|
595
|
+
}
|
|
596
|
+
// ─── Live Catch-Up ──────────────────────────────────────────────
|
|
597
|
+
/**
|
|
598
|
+
* Measure end-to-end latency from a wall-clock timestamp.
|
|
599
|
+
*
|
|
600
|
+
* Compares the publisher's wall-clock timestamp to the subscriber's
|
|
601
|
+
* wall clock, both in Unix epoch microseconds. A media-time timestamp
|
|
602
|
+
* (LOC-04 with Timescale) has no wall-clock meaning, so latency is
|
|
603
|
+
* unmeasurable and this returns null.
|
|
604
|
+
*
|
|
605
|
+
* @param captureTimestampUs Timestamp in microseconds
|
|
606
|
+
* @param isWallClock False when the timestamp is media time. Undefined means wall clock.
|
|
607
|
+
* @returns Latency in microseconds, or null if unmeasurable
|
|
608
|
+
* @see draft-ietf-moq-loc-01 §2.3.1.1
|
|
609
|
+
* @see draft-ietf-moq-loc-04 §2.3.1.2
|
|
610
|
+
*/
|
|
611
|
+
measureLatency(captureTimestampUs, isWallClock) {
|
|
612
|
+
if (captureTimestampUs === 0n || isWallClock === false) return null;
|
|
613
|
+
return this.wallClock.now() - Number(captureTimestampUs);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Evaluate catch-up state based on current latency.
|
|
617
|
+
*
|
|
618
|
+
* Hysteresis: activates when latency > target + threshold,
|
|
619
|
+
* deactivates when latency <= target + recovery.
|
|
620
|
+
*
|
|
621
|
+
* Rate is proportional to overshoot, quantized to 0.05x increments.
|
|
622
|
+
*
|
|
623
|
+
* @param captureTimestampUs CaptureTimestamp in microseconds (Unix epoch)
|
|
624
|
+
* @returns CatchUpState if evaluated, null if catch-up is disabled or no timestamp
|
|
625
|
+
* @see draft-ietf-moq-msf-00 §5.1.16 (targetLatency)
|
|
626
|
+
*/
|
|
627
|
+
evaluateCatchUp(captureTimestampUs, isWallClock) {
|
|
628
|
+
const latencyUs = this.measureLatency(captureTimestampUs, isWallClock);
|
|
629
|
+
if (latencyUs === null) return null;
|
|
630
|
+
this._lastLatencyUs = latencyUs;
|
|
631
|
+
if (this.targetLatencyMs === void 0 || this.maxCatchUpRate <= 1) return null;
|
|
632
|
+
const latencyMs = latencyUs / 1e3;
|
|
633
|
+
const targetMs = this.targetLatencyMs;
|
|
634
|
+
if (!this._catchUpActive && latencyMs > targetMs + this.catchUpThresholdMs) {
|
|
635
|
+
this._catchUpActive = true;
|
|
636
|
+
} else if (this._catchUpActive && latencyMs <= targetMs + this.catchUpRecoveryMs) {
|
|
637
|
+
this._catchUpActive = false;
|
|
638
|
+
this._currentRate = 1;
|
|
639
|
+
return { active: false, currentRate: 1, latencyMs, targetMs };
|
|
640
|
+
}
|
|
641
|
+
if (this._catchUpActive) {
|
|
642
|
+
const overshoot = latencyMs - targetMs;
|
|
643
|
+
const ratio = Math.min(Math.max(overshoot / this.catchUpThresholdMs, 0), 1);
|
|
644
|
+
const rawRate = 1 + (this.maxCatchUpRate - 1) * ratio;
|
|
645
|
+
const quantized = Math.round(rawRate * 20) / 20;
|
|
646
|
+
this._currentRate = Math.min(Math.max(quantized, 1), this.maxCatchUpRate);
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
active: this._catchUpActive,
|
|
650
|
+
currentRate: this._currentRate,
|
|
651
|
+
latencyMs,
|
|
652
|
+
targetMs
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Reset the sync reference (e.g., after a skip-forward).
|
|
657
|
+
*/
|
|
658
|
+
reset() {
|
|
659
|
+
this.localBaselineUs = void 0;
|
|
660
|
+
this.captureBaselineUs = void 0;
|
|
661
|
+
this._currentDriftUs = 0;
|
|
662
|
+
this._videoJoinOffsetUs = 0;
|
|
663
|
+
this._catchUpActive = false;
|
|
664
|
+
this._currentRate = 1;
|
|
665
|
+
}
|
|
666
|
+
// ─── Internal ───────────────────────────────────────────────────
|
|
667
|
+
computeRenderTime(captureTimestampUs) {
|
|
668
|
+
if (this.localBaselineUs === void 0 || this.captureBaselineUs === void 0) {
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
const now = this.clock.now();
|
|
672
|
+
const renderTimeUs = this.localBaselineUs + Number(captureTimestampUs - this.captureBaselineUs);
|
|
673
|
+
const offsetUs = renderTimeUs - now;
|
|
674
|
+
const shouldDrop = offsetUs < -this.dropThresholdUs;
|
|
675
|
+
return { renderTimeUs, offsetUs, shouldDrop };
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// src/recovery.ts
|
|
680
|
+
var DefaultRecoveryController = class {
|
|
681
|
+
gapEscalationThreshold;
|
|
682
|
+
maxDecodeErrors;
|
|
683
|
+
windowedThreshold;
|
|
684
|
+
windowedWindowUs;
|
|
685
|
+
stallThresholdMs;
|
|
686
|
+
clock;
|
|
687
|
+
/** Consecutive gap events without a successful decode. */
|
|
688
|
+
consecutiveGaps = 0;
|
|
689
|
+
/**
|
|
690
|
+
* True after reduce_quality fires — gates further escalation until
|
|
691
|
+
* notifySuccess() confirms the quality reduction helped.
|
|
692
|
+
*/
|
|
693
|
+
qualityReductionPending = false;
|
|
694
|
+
/** Number of decode errors seen (for escalation). */
|
|
695
|
+
decodeErrorCount = 0;
|
|
696
|
+
/** Timestamps (µs) of recent gap events for windowed escalation. */
|
|
697
|
+
gapTimestamps = [];
|
|
698
|
+
constructor(config) {
|
|
699
|
+
this.gapEscalationThreshold = config?.gapEscalationThreshold ?? 3;
|
|
700
|
+
this.maxDecodeErrors = config?.maxDecodeErrors ?? 2;
|
|
701
|
+
this.windowedThreshold = config?.windowedEscalationThreshold ?? 5;
|
|
702
|
+
this.windowedWindowUs = (config?.windowedEscalationWindowMs ?? 3e4) * 1e3;
|
|
703
|
+
this.stallThresholdMs = config?.stallThresholdMs ?? 300;
|
|
704
|
+
this.clock = null;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Attach a clock source for windowed escalation. Without a clock
|
|
708
|
+
* the windowed path is silently disabled (consecutive-only policy).
|
|
709
|
+
* Kept as a setter rather than a constructor param so existing
|
|
710
|
+
* callers that don't need windowed escalation don't break.
|
|
711
|
+
*/
|
|
712
|
+
setClock(clock) {
|
|
713
|
+
this.clock = clock;
|
|
714
|
+
}
|
|
715
|
+
evaluate(trigger) {
|
|
716
|
+
switch (trigger.type) {
|
|
717
|
+
case "gap":
|
|
718
|
+
return this.handleGap();
|
|
719
|
+
case "stall":
|
|
720
|
+
return this.handleStall(trigger.durationMs);
|
|
721
|
+
case "buffer_overflow":
|
|
722
|
+
return this.escalateOrSkip("jitter buffer overflow");
|
|
723
|
+
case "too_far_behind":
|
|
724
|
+
return this.escalateOrSkip("server reported TOO_FAR_BEHIND");
|
|
725
|
+
case "delivery_timeout":
|
|
726
|
+
return { type: "resubscribe" };
|
|
727
|
+
case "decode_error":
|
|
728
|
+
return this.handleDecodeError(trigger.message);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Signal that a frame was successfully decoded/rendered.
|
|
733
|
+
*
|
|
734
|
+
* Resets the consecutive gap counter and re-arms quality escalation.
|
|
735
|
+
* This closes the feedback loop: gaps → reduce_quality → better frames
|
|
736
|
+
* arrive → notifySuccess → ready for next escalation if needed.
|
|
737
|
+
*/
|
|
738
|
+
notifySuccess() {
|
|
739
|
+
this.consecutiveGaps = 0;
|
|
740
|
+
this.qualityReductionPending = false;
|
|
741
|
+
}
|
|
742
|
+
/** Reset all state — e.g., after pause→resume. */
|
|
743
|
+
reset() {
|
|
744
|
+
this.consecutiveGaps = 0;
|
|
745
|
+
this.qualityReductionPending = false;
|
|
746
|
+
this.decodeErrorCount = 0;
|
|
747
|
+
this.gapTimestamps.length = 0;
|
|
748
|
+
}
|
|
749
|
+
// ─── Internal ───────────────────────────────────────────────────
|
|
750
|
+
/**
|
|
751
|
+
* Request quality reduction, gated by the pending flag.
|
|
752
|
+
* If a reduce_quality is already pending (waiting for notifySuccess),
|
|
753
|
+
* return skip_forward instead to prevent spam.
|
|
754
|
+
*/
|
|
755
|
+
escalateOrSkip(reason) {
|
|
756
|
+
if (this.qualityReductionPending) {
|
|
757
|
+
return { type: "skip_forward" };
|
|
758
|
+
}
|
|
759
|
+
this.qualityReductionPending = true;
|
|
760
|
+
return { type: "reduce_quality", reason };
|
|
761
|
+
}
|
|
762
|
+
handleGap() {
|
|
763
|
+
this.consecutiveGaps++;
|
|
764
|
+
if (this.consecutiveGaps >= this.gapEscalationThreshold) {
|
|
765
|
+
this.consecutiveGaps = 0;
|
|
766
|
+
this.gapTimestamps.length = 0;
|
|
767
|
+
return this.escalateOrSkip("repeated gaps detected");
|
|
768
|
+
}
|
|
769
|
+
if (this.clock) {
|
|
770
|
+
const now = this.clock.now();
|
|
771
|
+
this.gapTimestamps.push(now);
|
|
772
|
+
const cutoff = now - this.windowedWindowUs;
|
|
773
|
+
while (this.gapTimestamps.length > 0 && this.gapTimestamps[0] < cutoff) {
|
|
774
|
+
this.gapTimestamps.shift();
|
|
775
|
+
}
|
|
776
|
+
if (this.gapTimestamps.length >= this.windowedThreshold) {
|
|
777
|
+
this.gapTimestamps.length = 0;
|
|
778
|
+
this.consecutiveGaps = 0;
|
|
779
|
+
return this.escalateOrSkip("repeated stalls within window");
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return { type: "skip_forward" };
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Relay signals overload via PUBLISH_DONE/TOO_FAR_BEHIND (§9.13);
|
|
786
|
+
* network bottlenecks produce no server signal, so the player must
|
|
787
|
+
* self-detect via stall rate.
|
|
788
|
+
*/
|
|
789
|
+
handleStall(durationMs) {
|
|
790
|
+
if (durationMs < this.stallThresholdMs) {
|
|
791
|
+
return { type: "skip_forward" };
|
|
792
|
+
}
|
|
793
|
+
if (this.clock) {
|
|
794
|
+
const now = this.clock.now();
|
|
795
|
+
this.gapTimestamps.push(now);
|
|
796
|
+
const cutoff = now - this.windowedWindowUs;
|
|
797
|
+
while (this.gapTimestamps.length > 0 && this.gapTimestamps[0] < cutoff) {
|
|
798
|
+
this.gapTimestamps.shift();
|
|
799
|
+
}
|
|
800
|
+
if (this.gapTimestamps.length >= this.windowedThreshold) {
|
|
801
|
+
this.gapTimestamps.length = 0;
|
|
802
|
+
return this.escalateOrSkip("sustained stalls within window");
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return { type: "skip_forward" };
|
|
806
|
+
}
|
|
807
|
+
handleDecodeError(message) {
|
|
808
|
+
this.decodeErrorCount++;
|
|
809
|
+
if (this.decodeErrorCount >= this.maxDecodeErrors) {
|
|
810
|
+
return { type: "terminate", reason: `repeated decode error: ${message}` };
|
|
811
|
+
}
|
|
812
|
+
return { type: "resubscribe" };
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
// src/adaptive-tolerance.ts
|
|
817
|
+
var RecoveryPhase = {
|
|
818
|
+
/** Baseline — no active jitter issues. */
|
|
819
|
+
Normal: "normal",
|
|
820
|
+
/** Gap detected, observing whether transient or persistent. */
|
|
821
|
+
Monitoring: "monitoring",
|
|
822
|
+
/** Persistent gap — waiting with extended timeout before skipping. */
|
|
823
|
+
ExtendedWait: "extended_wait"
|
|
824
|
+
};
|
|
825
|
+
var DEFAULT_TOLERANCE_CONFIG = {
|
|
826
|
+
emaAlpha: 0.125,
|
|
827
|
+
// 1/8 — RFC 6298 gain
|
|
828
|
+
varianceAlpha: 0.25,
|
|
829
|
+
// 1/4 — RFC 6298 gain
|
|
830
|
+
slidingWindowSize: 128,
|
|
831
|
+
// ~4s at 30fps
|
|
832
|
+
jitterMultiplier: 4,
|
|
833
|
+
// μ + 4σ ≈ 99.99% of Gaussian
|
|
834
|
+
minGapTimeoutMs: 50,
|
|
835
|
+
// floor: ~1.5 video frames
|
|
836
|
+
maxGapTimeoutMs: 2e3,
|
|
837
|
+
// ceiling
|
|
838
|
+
minDriftThresholdMs: 80,
|
|
839
|
+
driftThresholdMultiplier: 3,
|
|
840
|
+
stallIncrementMs: 33,
|
|
841
|
+
// 1 video frame interval
|
|
842
|
+
maxStallIncrements: 3,
|
|
843
|
+
// max 3 × 33ms = ~100ms added
|
|
844
|
+
monitorWindowMs: 500,
|
|
845
|
+
// observe before escalating
|
|
846
|
+
monitorCleanFrames: 60,
|
|
847
|
+
// 1s at 60fps tick rate
|
|
848
|
+
monitorCleanDurationMs: 1e3,
|
|
849
|
+
maxExtendedWaitMs: 2e3,
|
|
850
|
+
skipFrequencyThreshold: 3,
|
|
851
|
+
// 3 skips in window → quality reduce
|
|
852
|
+
skipFrequencyWindowMs: 3e4,
|
|
853
|
+
decayMinCleanFrames: 120,
|
|
854
|
+
// 2s at 60fps
|
|
855
|
+
decayMinCleanDurationMs: 2e3,
|
|
856
|
+
decayFactor: 0.9
|
|
857
|
+
// 10% reduction per decay step
|
|
858
|
+
};
|
|
859
|
+
var TICK_NO_SKIP = { shouldSkip: false };
|
|
860
|
+
var TICK_SKIP = { shouldSkip: true };
|
|
861
|
+
var SlidingMax = class {
|
|
862
|
+
buf;
|
|
863
|
+
deq;
|
|
864
|
+
capacity;
|
|
865
|
+
writePos = 0;
|
|
866
|
+
count = 0;
|
|
867
|
+
dHead = 0;
|
|
868
|
+
dTail = 0;
|
|
869
|
+
constructor(capacity) {
|
|
870
|
+
this.capacity = capacity;
|
|
871
|
+
this.buf = new Float64Array(capacity);
|
|
872
|
+
this.deq = new Uint32Array(capacity);
|
|
873
|
+
}
|
|
874
|
+
push(value) {
|
|
875
|
+
const cap = this.capacity;
|
|
876
|
+
const wIdx = this.writePos % cap;
|
|
877
|
+
if (this.count === cap && this.dHead !== this.dTail && this.deq[this.dHead % cap] === wIdx) {
|
|
878
|
+
this.dHead++;
|
|
879
|
+
}
|
|
880
|
+
while (this.dHead !== this.dTail && this.buf[this.deq[(this.dTail - 1) % cap] % cap] <= value) {
|
|
881
|
+
this.dTail--;
|
|
882
|
+
}
|
|
883
|
+
this.deq[this.dTail % cap] = wIdx;
|
|
884
|
+
this.dTail++;
|
|
885
|
+
this.buf[wIdx] = value;
|
|
886
|
+
this.writePos++;
|
|
887
|
+
if (this.count < cap) this.count++;
|
|
888
|
+
}
|
|
889
|
+
get max() {
|
|
890
|
+
if (this.dHead === this.dTail) return 0;
|
|
891
|
+
return this.buf[this.deq[this.dHead % this.capacity] % this.capacity];
|
|
892
|
+
}
|
|
893
|
+
reset() {
|
|
894
|
+
this.writePos = 0;
|
|
895
|
+
this.count = 0;
|
|
896
|
+
this.dHead = 0;
|
|
897
|
+
this.dTail = 0;
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
var AdaptiveToleranceController = class {
|
|
901
|
+
cfg;
|
|
902
|
+
// ── Jitter estimation ──
|
|
903
|
+
_jitterEma = 0;
|
|
904
|
+
_jitterVar = 0;
|
|
905
|
+
_lastTransitMs = 0;
|
|
906
|
+
_hasLastTransit = false;
|
|
907
|
+
_slidingMax;
|
|
908
|
+
// ── Phase state ──
|
|
909
|
+
_phase = RecoveryPhase.Normal;
|
|
910
|
+
_phaseEnteredMs = 0;
|
|
911
|
+
_monitorGapCount = 0;
|
|
912
|
+
_stallCount = 0;
|
|
913
|
+
_stallEscalationMs = 0;
|
|
914
|
+
// ── Skip frequency tracking ──
|
|
915
|
+
_skipTimestamps = [];
|
|
916
|
+
// ── Clean streak tracking (for monitoring exit + decay) ──
|
|
917
|
+
_cleanFrameCount = 0;
|
|
918
|
+
_cleanStreakStartMs = 0;
|
|
919
|
+
// ── Quality reduction ──
|
|
920
|
+
_shouldReduceQuality = false;
|
|
921
|
+
// ── Protocol signals ──
|
|
922
|
+
_deliveryTimeoutMs = 0;
|
|
923
|
+
// ── Capture timestamp jitter (for drift threshold) ──
|
|
924
|
+
_lastCaptureUs = 0;
|
|
925
|
+
_hasCaptureRef = false;
|
|
926
|
+
_captureJitterEma = 0;
|
|
927
|
+
constructor(config) {
|
|
928
|
+
this.cfg = config;
|
|
929
|
+
this._slidingMax = new SlidingMax(config.slidingWindowSize);
|
|
930
|
+
}
|
|
931
|
+
// ─── Read-only state ──────────────────────────────────────────
|
|
932
|
+
get phase() {
|
|
933
|
+
return this._phase;
|
|
934
|
+
}
|
|
935
|
+
get jitterEmaMs() {
|
|
936
|
+
return this._jitterEma;
|
|
937
|
+
}
|
|
938
|
+
get jitterVarMs() {
|
|
939
|
+
return this._jitterVar;
|
|
940
|
+
}
|
|
941
|
+
get slidingMaxMs() {
|
|
942
|
+
return this._slidingMax.max;
|
|
943
|
+
}
|
|
944
|
+
get stallCount() {
|
|
945
|
+
return this._stallCount;
|
|
946
|
+
}
|
|
947
|
+
get shouldReduceQuality() {
|
|
948
|
+
return this._shouldReduceQuality;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Auto-calibrated gap timeout in milliseconds.
|
|
952
|
+
*
|
|
953
|
+
* baseline = max(minGap, EMA + K × variance, slidingMax)
|
|
954
|
+
* effective = min(baseline + stallEscalation, maxGap, deliveryTimeout)
|
|
955
|
+
*/
|
|
956
|
+
get effectiveGapTimeoutMs() {
|
|
957
|
+
const adaptive = Math.max(
|
|
958
|
+
this.cfg.minGapTimeoutMs,
|
|
959
|
+
this._jitterEma + this.cfg.jitterMultiplier * this._jitterVar,
|
|
960
|
+
this._slidingMax.max
|
|
961
|
+
);
|
|
962
|
+
const withStall = adaptive + this._stallEscalationMs;
|
|
963
|
+
let capped = Math.min(withStall, this.cfg.maxGapTimeoutMs);
|
|
964
|
+
if (this._deliveryTimeoutMs > 0) {
|
|
965
|
+
capped = Math.min(capped, this._deliveryTimeoutMs);
|
|
966
|
+
}
|
|
967
|
+
return capped;
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Auto-calibrated drift threshold in milliseconds.
|
|
971
|
+
*
|
|
972
|
+
* Based on observed CaptureTimestamp interarrival jitter.
|
|
973
|
+
*/
|
|
974
|
+
get effectiveDriftThresholdMs() {
|
|
975
|
+
if (!this._hasCaptureRef || this._captureJitterEma === 0) {
|
|
976
|
+
return this.cfg.minDriftThresholdMs;
|
|
977
|
+
}
|
|
978
|
+
return Math.max(
|
|
979
|
+
this.cfg.minDriftThresholdMs,
|
|
980
|
+
this._captureJitterEma * this.cfg.driftThresholdMultiplier
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
// ─── Events IN ────────────────────────────────────────────────
|
|
984
|
+
/**
|
|
985
|
+
* Notify that a frame arrived.
|
|
986
|
+
*
|
|
987
|
+
* @param nowMs Current time in milliseconds
|
|
988
|
+
* @param captureUs CaptureTimestamp in microseconds
|
|
989
|
+
* @param arrivalMs Arrival time in milliseconds
|
|
990
|
+
*/
|
|
991
|
+
onFrameArrived(nowMs, captureUs, arrivalMs) {
|
|
992
|
+
const transitMs = arrivalMs - captureUs * 1e-3;
|
|
993
|
+
if (this._hasLastTransit) {
|
|
994
|
+
const d = transitMs - this._lastTransitMs;
|
|
995
|
+
const absD = d < 0 ? -d : d;
|
|
996
|
+
const emaErr = absD - this._jitterEma;
|
|
997
|
+
this._jitterEma += this.cfg.emaAlpha * emaErr;
|
|
998
|
+
this._jitterVar += this.cfg.varianceAlpha * (absD - this._jitterVar);
|
|
999
|
+
this._slidingMax.push(absD);
|
|
1000
|
+
}
|
|
1001
|
+
this._lastTransitMs = transitMs;
|
|
1002
|
+
this._hasLastTransit = true;
|
|
1003
|
+
if (this._hasCaptureRef) {
|
|
1004
|
+
const captureIntervalUs = captureUs - this._lastCaptureUs;
|
|
1005
|
+
const captureJitterMs = Math.abs(captureIntervalUs) * 1e-3;
|
|
1006
|
+
if (this._captureJitterEma === 0) {
|
|
1007
|
+
this._captureJitterEma = captureJitterMs;
|
|
1008
|
+
} else {
|
|
1009
|
+
this._captureJitterEma += this.cfg.emaAlpha * (captureJitterMs - this._captureJitterEma);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
this._lastCaptureUs = captureUs;
|
|
1013
|
+
this._hasCaptureRef = true;
|
|
1014
|
+
this._cleanFrameCount++;
|
|
1015
|
+
if (this._cleanFrameCount === 1) {
|
|
1016
|
+
this._cleanStreakStartMs = nowMs;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Notify that a gap was detected.
|
|
1021
|
+
*
|
|
1022
|
+
* @param nowMs Current time in milliseconds
|
|
1023
|
+
* @param groupId The group ID where the gap was detected
|
|
1024
|
+
*/
|
|
1025
|
+
onGapDetected(nowMs, _groupId) {
|
|
1026
|
+
this._cleanFrameCount = 0;
|
|
1027
|
+
switch (this._phase) {
|
|
1028
|
+
case RecoveryPhase.Normal:
|
|
1029
|
+
this._phase = RecoveryPhase.Monitoring;
|
|
1030
|
+
this._phaseEnteredMs = nowMs;
|
|
1031
|
+
this._monitorGapCount = 1;
|
|
1032
|
+
break;
|
|
1033
|
+
case RecoveryPhase.Monitoring:
|
|
1034
|
+
this._monitorGapCount++;
|
|
1035
|
+
if (this._monitorGapCount >= 2 && nowMs - this._phaseEnteredMs <= this.cfg.monitorWindowMs) {
|
|
1036
|
+
this._phase = RecoveryPhase.ExtendedWait;
|
|
1037
|
+
this._phaseEnteredMs = nowMs;
|
|
1038
|
+
}
|
|
1039
|
+
break;
|
|
1040
|
+
case RecoveryPhase.ExtendedWait:
|
|
1041
|
+
break;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Periodic tick — evaluate phase transitions and decay.
|
|
1046
|
+
*
|
|
1047
|
+
* @param nowMs Current time in milliseconds
|
|
1048
|
+
* @returns TickResult indicating whether caller should skip forward
|
|
1049
|
+
*/
|
|
1050
|
+
tick(nowMs) {
|
|
1051
|
+
switch (this._phase) {
|
|
1052
|
+
case RecoveryPhase.Monitoring: {
|
|
1053
|
+
if (nowMs - this._phaseEnteredMs > this.cfg.monitorWindowMs) {
|
|
1054
|
+
if (this._cleanFrameCount >= this.cfg.monitorCleanFrames && nowMs - this._cleanStreakStartMs >= this.cfg.monitorCleanDurationMs) {
|
|
1055
|
+
this._phase = RecoveryPhase.Normal;
|
|
1056
|
+
return TICK_NO_SKIP;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
break;
|
|
1060
|
+
}
|
|
1061
|
+
case RecoveryPhase.ExtendedWait: {
|
|
1062
|
+
if (nowMs - this._phaseEnteredMs >= this.cfg.maxExtendedWaitMs) {
|
|
1063
|
+
this._stallCount++;
|
|
1064
|
+
this._stallEscalationMs = Math.min(
|
|
1065
|
+
this._stallCount * this.cfg.stallIncrementMs,
|
|
1066
|
+
this.cfg.maxStallIncrements * this.cfg.stallIncrementMs
|
|
1067
|
+
);
|
|
1068
|
+
this._skipTimestamps.push(nowMs);
|
|
1069
|
+
this._pruneSkipTimestamps(nowMs);
|
|
1070
|
+
if (this._skipTimestamps.length >= this.cfg.skipFrequencyThreshold) {
|
|
1071
|
+
this._shouldReduceQuality = true;
|
|
1072
|
+
}
|
|
1073
|
+
this._phase = RecoveryPhase.Normal;
|
|
1074
|
+
this._cleanFrameCount = 0;
|
|
1075
|
+
return TICK_SKIP;
|
|
1076
|
+
}
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
case RecoveryPhase.Normal: {
|
|
1080
|
+
if (this._stallEscalationMs > 0 && this._cleanFrameCount >= this.cfg.decayMinCleanFrames && this._cleanStreakStartMs > 0 && nowMs - this._cleanStreakStartMs >= this.cfg.decayMinCleanDurationMs) {
|
|
1081
|
+
this._stallEscalationMs *= this.cfg.decayFactor;
|
|
1082
|
+
if (this._stallEscalationMs < 1) {
|
|
1083
|
+
this._stallEscalationMs = 0;
|
|
1084
|
+
this._stallCount = 0;
|
|
1085
|
+
}
|
|
1086
|
+
this._cleanFrameCount = 0;
|
|
1087
|
+
this._cleanStreakStartMs = nowMs;
|
|
1088
|
+
}
|
|
1089
|
+
if (this._shouldReduceQuality) {
|
|
1090
|
+
this._pruneSkipTimestamps(nowMs);
|
|
1091
|
+
if (this._skipTimestamps.length < this.cfg.skipFrequencyThreshold) {
|
|
1092
|
+
this._shouldReduceQuality = false;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
break;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return TICK_NO_SKIP;
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Set DELIVERY_TIMEOUT from protocol negotiation.
|
|
1102
|
+
* Caps the effective gap timeout.
|
|
1103
|
+
*
|
|
1104
|
+
* @see draft-ietf-moq-transport-16 §9.2.2.2
|
|
1105
|
+
*/
|
|
1106
|
+
setDeliveryTimeout(timeoutMs) {
|
|
1107
|
+
this._deliveryTimeoutMs = timeoutMs;
|
|
1108
|
+
}
|
|
1109
|
+
/** Reset all state (seek, pause→resume). */
|
|
1110
|
+
reset() {
|
|
1111
|
+
this._jitterEma = 0;
|
|
1112
|
+
this._jitterVar = 0;
|
|
1113
|
+
this._lastTransitMs = 0;
|
|
1114
|
+
this._hasLastTransit = false;
|
|
1115
|
+
this._slidingMax.reset();
|
|
1116
|
+
this._phase = RecoveryPhase.Normal;
|
|
1117
|
+
this._phaseEnteredMs = 0;
|
|
1118
|
+
this._monitorGapCount = 0;
|
|
1119
|
+
this._stallCount = 0;
|
|
1120
|
+
this._stallEscalationMs = 0;
|
|
1121
|
+
this._skipTimestamps = [];
|
|
1122
|
+
this._cleanFrameCount = 0;
|
|
1123
|
+
this._cleanStreakStartMs = 0;
|
|
1124
|
+
this._shouldReduceQuality = false;
|
|
1125
|
+
this._deliveryTimeoutMs = 0;
|
|
1126
|
+
this._lastCaptureUs = 0;
|
|
1127
|
+
this._hasCaptureRef = false;
|
|
1128
|
+
this._captureJitterEma = 0;
|
|
1129
|
+
}
|
|
1130
|
+
// ─── Internal ─────────────────────────────────────────────────
|
|
1131
|
+
_pruneSkipTimestamps(nowMs) {
|
|
1132
|
+
const windowStart = nowMs - this.cfg.skipFrequencyWindowMs;
|
|
1133
|
+
this._skipTimestamps = this._skipTimestamps.filter((t) => t >= windowStart);
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
// src/render-scheduler.ts
|
|
1138
|
+
var RenderScheduler = class {
|
|
1139
|
+
clock;
|
|
1140
|
+
cushionUs;
|
|
1141
|
+
/** Wall-clock anchor: local time when the first frame was scheduled. */
|
|
1142
|
+
baseTimeUs = null;
|
|
1143
|
+
/** PTS anchor: captureTimestamp of the first frame. */
|
|
1144
|
+
basePtsUs = null;
|
|
1145
|
+
/** Previous frame's arrival time — for burst-to-live detection. */
|
|
1146
|
+
lastArrivalUs = 0;
|
|
1147
|
+
constructor(clock, options) {
|
|
1148
|
+
this.clock = clock;
|
|
1149
|
+
this.cushionUs = options?.cushionUs ?? 16e3;
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Compute the wall-clock render time for a decoded frame.
|
|
1153
|
+
*
|
|
1154
|
+
* @param captureTimestampUs CaptureTimestamp from LOC header (microseconds)
|
|
1155
|
+
* @returns Wall-clock time in microseconds when the frame should be painted
|
|
1156
|
+
*/
|
|
1157
|
+
schedule(captureTimestampUs) {
|
|
1158
|
+
const nowUs = this.clock.now();
|
|
1159
|
+
const ptsUs = Number(captureTimestampUs);
|
|
1160
|
+
if (this.baseTimeUs === null || this.basePtsUs === null) {
|
|
1161
|
+
this.baseTimeUs = nowUs + this.cushionUs;
|
|
1162
|
+
this.basePtsUs = ptsUs;
|
|
1163
|
+
this.lastArrivalUs = nowUs;
|
|
1164
|
+
return this.baseTimeUs;
|
|
1165
|
+
}
|
|
1166
|
+
const ptsDelta = ptsUs - this.basePtsUs;
|
|
1167
|
+
let renderTimeUs = this.baseTimeUs + ptsDelta;
|
|
1168
|
+
const arrivalDelta = nowUs - this.lastArrivalUs;
|
|
1169
|
+
if (renderTimeUs < nowUs && arrivalDelta > 2e4) {
|
|
1170
|
+
this.baseTimeUs = nowUs + this.cushionUs;
|
|
1171
|
+
this.basePtsUs = ptsUs;
|
|
1172
|
+
renderTimeUs = this.baseTimeUs;
|
|
1173
|
+
}
|
|
1174
|
+
this.lastArrivalUs = nowUs;
|
|
1175
|
+
return renderTimeUs;
|
|
1176
|
+
}
|
|
1177
|
+
/** Reset anchor (seek, pause→resume, gap skip). */
|
|
1178
|
+
reset() {
|
|
1179
|
+
this.baseTimeUs = null;
|
|
1180
|
+
this.basePtsUs = null;
|
|
1181
|
+
this.lastArrivalUs = 0;
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
|
|
1185
|
+
// src/startup-buffer.ts
|
|
1186
|
+
function classifyNetwork(handshakeRttMs) {
|
|
1187
|
+
if (handshakeRttMs < 1) {
|
|
1188
|
+
return { minFrames: 2, maxWaitMs: 80 };
|
|
1189
|
+
} else if (handshakeRttMs < 5) {
|
|
1190
|
+
return { minFrames: 3, maxWaitMs: 120 };
|
|
1191
|
+
} else if (handshakeRttMs < 50) {
|
|
1192
|
+
return { minFrames: 8, maxWaitMs: 300 };
|
|
1193
|
+
} else {
|
|
1194
|
+
return { minFrames: 12, maxWaitMs: 500 };
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
var StartupBuffer = class {
|
|
1198
|
+
minFrames;
|
|
1199
|
+
maxWaitMs;
|
|
1200
|
+
_frameCount = 0;
|
|
1201
|
+
_firstFrameMs = 0;
|
|
1202
|
+
_lastFrameMs = 0;
|
|
1203
|
+
_draining = false;
|
|
1204
|
+
constructor(params) {
|
|
1205
|
+
this.minFrames = params.minFrames;
|
|
1206
|
+
this.maxWaitMs = params.maxWaitMs;
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Record a frame arrival. Call from pushObject().
|
|
1210
|
+
* @param nowMs Current time in milliseconds
|
|
1211
|
+
*/
|
|
1212
|
+
recordFrame(nowMs) {
|
|
1213
|
+
this._frameCount++;
|
|
1214
|
+
if (this._frameCount === 1) {
|
|
1215
|
+
this._firstFrameMs = nowMs;
|
|
1216
|
+
}
|
|
1217
|
+
this._lastFrameMs = nowMs;
|
|
1218
|
+
if (!this._draining) {
|
|
1219
|
+
const elapsed = this._lastFrameMs - this._firstFrameMs;
|
|
1220
|
+
if (this._frameCount >= this.minFrames || elapsed >= this.maxWaitMs) {
|
|
1221
|
+
this._draining = true;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
/** Whether the pipeline should start draining (buffer phase complete). */
|
|
1226
|
+
get shouldDrain() {
|
|
1227
|
+
return this._draining;
|
|
1228
|
+
}
|
|
1229
|
+
/** Number of frames recorded. */
|
|
1230
|
+
get frameCount() {
|
|
1231
|
+
return this._frameCount;
|
|
1232
|
+
}
|
|
1233
|
+
/** Time elapsed since first frame in milliseconds. */
|
|
1234
|
+
get elapsedMs() {
|
|
1235
|
+
if (this._frameCount === 0) return 0;
|
|
1236
|
+
return this._lastFrameMs - this._firstFrameMs;
|
|
1237
|
+
}
|
|
1238
|
+
/** Accelerated EMA alpha for warmup (4x standard 1/16). */
|
|
1239
|
+
get warmupEmaAlpha() {
|
|
1240
|
+
return 0.25;
|
|
1241
|
+
}
|
|
1242
|
+
/** Reset to buffering state (e.g., after seek or pause→resume). */
|
|
1243
|
+
reset() {
|
|
1244
|
+
this._frameCount = 0;
|
|
1245
|
+
this._firstFrameMs = 0;
|
|
1246
|
+
this._lastFrameMs = 0;
|
|
1247
|
+
this._draining = false;
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
// src/keyframe-validator.ts
|
|
1252
|
+
function isKeyframePayload(codec, payload) {
|
|
1253
|
+
const codecBase = codec.split(".")[0].toLowerCase();
|
|
1254
|
+
switch (codecBase) {
|
|
1255
|
+
case "avc1":
|
|
1256
|
+
case "avc3":
|
|
1257
|
+
return isH264Keyframe(payload);
|
|
1258
|
+
case "hvc1":
|
|
1259
|
+
case "hev1":
|
|
1260
|
+
return isH265Keyframe(payload);
|
|
1261
|
+
case "av01":
|
|
1262
|
+
return isAV1Keyframe(payload);
|
|
1263
|
+
default:
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
var MAX_NAL_UNITS_PER_AU = 256;
|
|
1268
|
+
function isH264Keyframe(payload) {
|
|
1269
|
+
if (payload.length < 5) return null;
|
|
1270
|
+
let pos = 0;
|
|
1271
|
+
let iterations = 0;
|
|
1272
|
+
while (pos + 4 < payload.length) {
|
|
1273
|
+
if (++iterations > MAX_NAL_UNITS_PER_AU) return null;
|
|
1274
|
+
const nalLen = payload[pos] << 24 | payload[pos + 1] << 16 | payload[pos + 2] << 8 | payload[pos + 3];
|
|
1275
|
+
pos += 4;
|
|
1276
|
+
if (nalLen <= 0 || pos >= payload.length) break;
|
|
1277
|
+
const nalType = payload[pos] & 31;
|
|
1278
|
+
if (nalType === 5) return true;
|
|
1279
|
+
if (nalType === 7) return true;
|
|
1280
|
+
if (nalType === 6 || nalType === 8 || nalType === 9) {
|
|
1281
|
+
pos += nalLen;
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
return false;
|
|
1285
|
+
}
|
|
1286
|
+
return null;
|
|
1287
|
+
}
|
|
1288
|
+
function isH265Keyframe(payload) {
|
|
1289
|
+
if (payload.length < 6) return null;
|
|
1290
|
+
let pos = 0;
|
|
1291
|
+
let iterations = 0;
|
|
1292
|
+
while (pos + 4 < payload.length) {
|
|
1293
|
+
if (++iterations > MAX_NAL_UNITS_PER_AU) return null;
|
|
1294
|
+
const nalLen = payload[pos] << 24 | payload[pos + 1] << 16 | payload[pos + 2] << 8 | payload[pos + 3];
|
|
1295
|
+
pos += 4;
|
|
1296
|
+
if (nalLen <= 0 || pos + 1 >= payload.length) break;
|
|
1297
|
+
const nalType = payload[pos] >> 1 & 63;
|
|
1298
|
+
if (nalType === 19 || nalType === 20) return true;
|
|
1299
|
+
if (nalType === 21) return true;
|
|
1300
|
+
if (nalType === 32 || nalType === 33 || nalType === 34) {
|
|
1301
|
+
pos += nalLen;
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
if (nalType === 35 || nalType === 39 || nalType === 40) {
|
|
1305
|
+
pos += nalLen;
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
return false;
|
|
1309
|
+
}
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
function isAV1Keyframe(payload) {
|
|
1313
|
+
if (payload.length < 2) return null;
|
|
1314
|
+
const obuType = payload[0] >> 3 & 15;
|
|
1315
|
+
if (obuType === 1) return true;
|
|
1316
|
+
if (obuType === 2 && payload.length >= 3) {
|
|
1317
|
+
const nextObuType = payload[2] >> 3 & 15;
|
|
1318
|
+
if (nextObuType === 1) return true;
|
|
1319
|
+
}
|
|
1320
|
+
if (obuType === 3 || obuType === 6) return false;
|
|
1321
|
+
return null;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// ../transport/dist/primitives/varint.js
|
|
1325
|
+
var MAX_VARINT = 4611686018427387903n;
|
|
1326
|
+
function varint(n) {
|
|
1327
|
+
if (typeof n === "number") {
|
|
1328
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
1329
|
+
throw new RangeError(`Varint value must be a non-negative integer, got ${n}`);
|
|
1330
|
+
}
|
|
1331
|
+
if (n > Number.MAX_SAFE_INTEGER) {
|
|
1332
|
+
throw new RangeError(`Varint number value ${n} exceeds MAX_SAFE_INTEGER; use bigint for values > 2^53-1`);
|
|
1333
|
+
}
|
|
1334
|
+
return BigInt(n);
|
|
1335
|
+
}
|
|
1336
|
+
if (n < 0n || n > MAX_VARINT) {
|
|
1337
|
+
throw new RangeError(`Varint value must be 0 \u2264 n \u2264 ${MAX_VARINT}, got ${n}`);
|
|
1338
|
+
}
|
|
1339
|
+
return n;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// ../transport/dist/control/codes-18.js
|
|
1343
|
+
var SetupOption18 = {
|
|
1344
|
+
PATH: 1,
|
|
1345
|
+
AUTHORIZATION_TOKEN: 3,
|
|
1346
|
+
MAX_AUTH_TOKEN_CACHE_SIZE: 4,
|
|
1347
|
+
AUTHORITY: 5,
|
|
1348
|
+
MOQT_IMPLEMENTATION: 7
|
|
1349
|
+
};
|
|
1350
|
+
var SetupOption22 = {
|
|
1351
|
+
...SetupOption18,
|
|
1352
|
+
MAX_FILTER_RANGES: 6,
|
|
1353
|
+
MAX_REQUEST_UPDATES: 8
|
|
1354
|
+
};
|
|
1355
|
+
var MessageParam18 = {
|
|
1356
|
+
OBJECT_DELIVERY_TIMEOUT: { type: 2, kind: "varint" },
|
|
1357
|
+
AUTHORIZATION_TOKEN: { type: 3, kind: "bytes" },
|
|
1358
|
+
RENDEZVOUS_TIMEOUT: { type: 4, kind: "varint" },
|
|
1359
|
+
SUBGROUP_DELIVERY_TIMEOUT: { type: 6, kind: "varint" },
|
|
1360
|
+
EXPIRES: { type: 8, kind: "varint" },
|
|
1361
|
+
LARGEST_OBJECT: { type: 9, kind: "location" },
|
|
1362
|
+
FILL_TIMEOUT: { type: 10, kind: "varint" },
|
|
1363
|
+
FORWARD: { type: 16, kind: "uint8" },
|
|
1364
|
+
SUBSCRIBER_PRIORITY: { type: 32, kind: "uint8" },
|
|
1365
|
+
SUBSCRIPTION_FILTER: { type: 33, kind: "bytes" },
|
|
1366
|
+
GROUP_ORDER: { type: 34, kind: "uint8" },
|
|
1367
|
+
NEW_GROUP_REQUEST: { type: 50, kind: "varint" },
|
|
1368
|
+
TRACK_NAMESPACE_PREFIX: { type: 52, kind: "namespace" }
|
|
1369
|
+
};
|
|
1370
|
+
var MessageParam22 = {
|
|
1371
|
+
OBJECT_DELIVERY_TIMEOUT: { type: 2, kind: "varint" },
|
|
1372
|
+
AUTHORIZATION_TOKEN: { type: 3, kind: "bytes" },
|
|
1373
|
+
RENDEZVOUS_TIMEOUT: { type: 4, kind: "varint" },
|
|
1374
|
+
SUBGROUP_DELIVERY_TIMEOUT: { type: 6, kind: "varint" },
|
|
1375
|
+
EXPIRES: { type: 8, kind: "varint" },
|
|
1376
|
+
LARGEST_OBJECT: { type: 9, kind: "location" },
|
|
1377
|
+
FILL_TIMEOUT: { type: 10, kind: "varint" },
|
|
1378
|
+
FORWARD: { type: 16, kind: "uint8" },
|
|
1379
|
+
SUBSCRIBER_PRIORITY: { type: 32, kind: "uint8" },
|
|
1380
|
+
LOCATION_FILTER: { type: 33, kind: "locationFilter" },
|
|
1381
|
+
GROUP_ORDER: { type: 34, kind: "uint8" },
|
|
1382
|
+
FILL_PARAMETERS: { type: 35, kind: "bytes" },
|
|
1383
|
+
SUBGROUP_FILTER: { type: 37, kind: "bytes" },
|
|
1384
|
+
OBJECTID_FILTER: { type: 38, kind: "bytes" },
|
|
1385
|
+
PRIORITY_FILTER: { type: 39, kind: "bytes" },
|
|
1386
|
+
OBJECT_PROPERTY_FILTER: { type: 40, kind: "bytes" },
|
|
1387
|
+
TRACK_PROPERTY_FILTER: { type: 41, kind: "bytes" },
|
|
1388
|
+
NEW_GROUP_REQUEST: { type: 50, kind: "varint" },
|
|
1389
|
+
TRACK_NAMESPACE_PREFIX: { type: 52, kind: "namespace" },
|
|
1390
|
+
INCLUDE_PROPERTIES: { type: 53, kind: "uint8" }
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1393
|
+
// ../transport/dist/errors.js
|
|
1394
|
+
var SessionError = {
|
|
1395
|
+
NO_ERROR: varint(0),
|
|
1396
|
+
INTERNAL_ERROR: varint(1),
|
|
1397
|
+
UNAUTHORIZED: varint(2),
|
|
1398
|
+
PROTOCOL_VIOLATION: varint(3),
|
|
1399
|
+
INVALID_REQUEST_ID: varint(4),
|
|
1400
|
+
DUPLICATE_TRACK_ALIAS: varint(5),
|
|
1401
|
+
KEY_VALUE_FORMATTING_ERROR: varint(6),
|
|
1402
|
+
TOO_MANY_REQUESTS: varint(7),
|
|
1403
|
+
INVALID_PATH: varint(8),
|
|
1404
|
+
MALFORMED_PATH: varint(9),
|
|
1405
|
+
GOAWAY_TIMEOUT: varint(16),
|
|
1406
|
+
CONTROL_MESSAGE_TIMEOUT: varint(17),
|
|
1407
|
+
DATA_STREAM_TIMEOUT: varint(18),
|
|
1408
|
+
AUTH_TOKEN_CACHE_OVERFLOW: varint(19),
|
|
1409
|
+
DUPLICATE_AUTH_TOKEN_ALIAS: varint(20),
|
|
1410
|
+
VERSION_NEGOTIATION_FAILED: varint(21),
|
|
1411
|
+
MALFORMED_AUTH_TOKEN: varint(22),
|
|
1412
|
+
UNKNOWN_AUTH_TOKEN_ALIAS: varint(23),
|
|
1413
|
+
EXPIRED_AUTH_TOKEN: varint(24),
|
|
1414
|
+
INVALID_AUTHORITY: varint(25),
|
|
1415
|
+
MALFORMED_AUTHORITY: varint(26),
|
|
1416
|
+
/** draft-22: more than MAX_REQUEST_UPDATES updates outstanding on one request. */
|
|
1417
|
+
TOO_MANY_REQUEST_UPDATES: varint(27)
|
|
1418
|
+
};
|
|
1419
|
+
var RequestError = {
|
|
1420
|
+
INTERNAL_ERROR: varint(0),
|
|
1421
|
+
UNAUTHORIZED: varint(1),
|
|
1422
|
+
TIMEOUT: varint(2),
|
|
1423
|
+
NOT_SUPPORTED: varint(3),
|
|
1424
|
+
MALFORMED_AUTH_TOKEN: varint(4),
|
|
1425
|
+
EXPIRED_AUTH_TOKEN: varint(5),
|
|
1426
|
+
DOES_NOT_EXIST: varint(16),
|
|
1427
|
+
INVALID_RANGE: varint(17),
|
|
1428
|
+
MALFORMED_TRACK: varint(18),
|
|
1429
|
+
DUPLICATE_SUBSCRIPTION: varint(25),
|
|
1430
|
+
UNINTERESTED: varint(32),
|
|
1431
|
+
PREFIX_OVERLAP: varint(48),
|
|
1432
|
+
NAMESPACE_TOO_LARGE: varint(49),
|
|
1433
|
+
INVALID_JOINING_REQUEST_ID: varint(50),
|
|
1434
|
+
/** draft-18 §2.5.1: a Mandatory Track Property the endpoint does not understand. */
|
|
1435
|
+
UNSUPPORTED_EXTENSION: varint(51),
|
|
1436
|
+
/** draft-18 §10.6.2: the request is redirected; the body carries a Redirect. */
|
|
1437
|
+
REDIRECT: varint(52)
|
|
1438
|
+
};
|
|
1439
|
+
var PublishDoneCode = {
|
|
1440
|
+
INTERNAL_ERROR: varint(0),
|
|
1441
|
+
UNAUTHORIZED: varint(1),
|
|
1442
|
+
TRACK_ENDED: varint(2),
|
|
1443
|
+
SUBSCRIPTION_ENDED: varint(3),
|
|
1444
|
+
GOING_AWAY: varint(4),
|
|
1445
|
+
EXPIRED: varint(5),
|
|
1446
|
+
TOO_FAR_BEHIND: varint(6),
|
|
1447
|
+
UPDATE_FAILED: varint(8),
|
|
1448
|
+
MALFORMED_TRACK: varint(18)
|
|
1449
|
+
};
|
|
1450
|
+
var DataStreamError = {
|
|
1451
|
+
INTERNAL_ERROR: varint(0),
|
|
1452
|
+
CANCELLED: varint(1),
|
|
1453
|
+
DELIVERY_TIMEOUT: varint(2),
|
|
1454
|
+
SESSION_CLOSED: varint(3),
|
|
1455
|
+
UNKNOWN_OBJECT_STATUS: varint(4),
|
|
1456
|
+
MALFORMED_TRACK: varint(18)
|
|
1457
|
+
};
|
|
1458
|
+
var RequestError18 = {
|
|
1459
|
+
INTERNAL_ERROR: varint(0),
|
|
1460
|
+
UNAUTHORIZED: varint(1),
|
|
1461
|
+
TIMEOUT: varint(2),
|
|
1462
|
+
NOT_SUPPORTED: varint(3),
|
|
1463
|
+
MALFORMED_AUTH_TOKEN: varint(4),
|
|
1464
|
+
EXPIRED_AUTH_TOKEN: varint(5),
|
|
1465
|
+
GOING_AWAY: varint(6),
|
|
1466
|
+
EXCESSIVE_LOAD: varint(9),
|
|
1467
|
+
DOES_NOT_EXIST: varint(16),
|
|
1468
|
+
INVALID_RANGE: varint(17),
|
|
1469
|
+
MALFORMED_TRACK: varint(18),
|
|
1470
|
+
DUPLICATE_SUBSCRIPTION: varint(25),
|
|
1471
|
+
UNINTERESTED: varint(32),
|
|
1472
|
+
PREFIX_OVERLAP: varint(48),
|
|
1473
|
+
NAMESPACE_TOO_LARGE: varint(49),
|
|
1474
|
+
INVALID_JOINING_REQUEST_ID: varint(50),
|
|
1475
|
+
UNSUPPORTED_EXTENSION: varint(51),
|
|
1476
|
+
REDIRECT: varint(52)
|
|
1477
|
+
};
|
|
1478
|
+
var RequestError22 = {
|
|
1479
|
+
INTERNAL_ERROR: varint(0),
|
|
1480
|
+
UNAUTHORIZED: varint(1),
|
|
1481
|
+
TIMEOUT: varint(2),
|
|
1482
|
+
NOT_SUPPORTED: varint(3),
|
|
1483
|
+
MALFORMED_AUTH_TOKEN: varint(4),
|
|
1484
|
+
EXPIRED_AUTH_TOKEN: varint(5),
|
|
1485
|
+
GOING_AWAY: varint(6),
|
|
1486
|
+
EXCESSIVE_LOAD: varint(9),
|
|
1487
|
+
DOES_NOT_EXIST: varint(16),
|
|
1488
|
+
INVALID_RANGE: varint(17),
|
|
1489
|
+
MALFORMED_TRACK: varint(18),
|
|
1490
|
+
UNINTERESTED: varint(32),
|
|
1491
|
+
PREFIX_OVERLAP: varint(48),
|
|
1492
|
+
NAMESPACE_TOO_LARGE: varint(49),
|
|
1493
|
+
UNSUPPORTED_EXTENSION: varint(51),
|
|
1494
|
+
REDIRECT: varint(52),
|
|
1495
|
+
CONFLICTING_FILTERS: varint(53),
|
|
1496
|
+
INVALID_FILTER: varint(54)
|
|
1497
|
+
};
|
|
1498
|
+
var PublishDoneCode18 = {
|
|
1499
|
+
INTERNAL_ERROR: varint(0),
|
|
1500
|
+
UNAUTHORIZED: varint(1),
|
|
1501
|
+
TRACK_ENDED: varint(2),
|
|
1502
|
+
SUBSCRIPTION_ENDED: varint(3),
|
|
1503
|
+
GOING_AWAY: varint(4),
|
|
1504
|
+
TOO_FAR_BEHIND: varint(5),
|
|
1505
|
+
EXPIRED: varint(6),
|
|
1506
|
+
UPDATE_FAILED: varint(8),
|
|
1507
|
+
EXCESSIVE_LOAD: varint(9),
|
|
1508
|
+
MALFORMED_TRACK: varint(18)
|
|
1509
|
+
};
|
|
1510
|
+
var StreamResetCode18 = {
|
|
1511
|
+
INTERNAL_ERROR: varint(0),
|
|
1512
|
+
CANCELLED: varint(1),
|
|
1513
|
+
DELIVERY_TIMEOUT: varint(2),
|
|
1514
|
+
SESSION_CLOSED: varint(3),
|
|
1515
|
+
GOING_AWAY: varint(4),
|
|
1516
|
+
TOO_FAR_BEHIND: varint(5),
|
|
1517
|
+
UNKNOWN_OBJECT_STATUS: varint(6),
|
|
1518
|
+
EXPIRED_AUTH_TOKEN: varint(7),
|
|
1519
|
+
EXCESSIVE_LOAD: varint(9),
|
|
1520
|
+
MALFORMED_TRACK: varint(18)
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
// ../transport/dist/primitives/bytes.js
|
|
1524
|
+
var SESSION_NAMESPACE_FIELD = new Uint8Array([46, 115, 101, 115, 115, 105, 111, 110]);
|
|
1525
|
+
|
|
1526
|
+
// ../transport/dist/control/message-params-18.js
|
|
1527
|
+
var DEFAULT_MESSAGE_PARAM_REGISTRY = new Map(Object.values(MessageParam18).map((d) => [BigInt(d.type), d.kind]));
|
|
1528
|
+
var DRAFT22_MESSAGE_PARAM_REGISTRY = new Map(Object.values(MessageParam22).map((d) => [BigInt(d.type), d.kind]));
|
|
1529
|
+
|
|
1530
|
+
// ../transport/dist/primitives/reason.js
|
|
1531
|
+
var encoder = new TextEncoder();
|
|
1532
|
+
var decoder = new TextDecoder();
|
|
1533
|
+
|
|
1534
|
+
// ../transport/dist/control/codes.js
|
|
1535
|
+
var MessageType = {
|
|
1536
|
+
REQUEST_UPDATE: varint(2),
|
|
1537
|
+
SUBSCRIBE: varint(3),
|
|
1538
|
+
SUBSCRIBE_OK: varint(4),
|
|
1539
|
+
REQUEST_ERROR: varint(5),
|
|
1540
|
+
PUBLISH_NAMESPACE: varint(6),
|
|
1541
|
+
REQUEST_OK: varint(7),
|
|
1542
|
+
NAMESPACE: varint(8),
|
|
1543
|
+
PUBLISH_NAMESPACE_DONE: varint(9),
|
|
1544
|
+
UNSUBSCRIBE: varint(10),
|
|
1545
|
+
PUBLISH_DONE: varint(11),
|
|
1546
|
+
PUBLISH_NAMESPACE_CANCEL: varint(12),
|
|
1547
|
+
TRACK_STATUS: varint(13),
|
|
1548
|
+
NAMESPACE_DONE: varint(14),
|
|
1549
|
+
GOAWAY: varint(16),
|
|
1550
|
+
SUBSCRIBE_NAMESPACE: varint(17),
|
|
1551
|
+
MAX_REQUEST_ID: varint(21),
|
|
1552
|
+
FETCH: varint(22),
|
|
1553
|
+
FETCH_CANCEL: varint(23),
|
|
1554
|
+
FETCH_OK: varint(24),
|
|
1555
|
+
REQUESTS_BLOCKED: varint(26),
|
|
1556
|
+
PUBLISH: varint(29),
|
|
1557
|
+
PUBLISH_OK: varint(30),
|
|
1558
|
+
CLIENT_SETUP: varint(32),
|
|
1559
|
+
SERVER_SETUP: varint(33)
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1562
|
+
// ../transport/dist/control/parameters.js
|
|
1563
|
+
var SetupParam = {
|
|
1564
|
+
/** @see §9.3.1.2 */
|
|
1565
|
+
PATH: varint(1),
|
|
1566
|
+
/** @see §9.3.1.3 */
|
|
1567
|
+
MAX_REQUEST_ID: varint(2),
|
|
1568
|
+
/** @see §9.3.1.5 */
|
|
1569
|
+
AUTHORIZATION_TOKEN: varint(3),
|
|
1570
|
+
/** @see §9.3.1.4 */
|
|
1571
|
+
MAX_AUTH_TOKEN_CACHE_SIZE: varint(4),
|
|
1572
|
+
/** @see §9.3.1.1 */
|
|
1573
|
+
AUTHORITY: varint(5),
|
|
1574
|
+
/** @see §9.3.1.6 */
|
|
1575
|
+
MOQT_IMPLEMENTATION: varint(7)
|
|
1576
|
+
};
|
|
1577
|
+
var MessageParam = {
|
|
1578
|
+
/** @see §9.2.2.2 */
|
|
1579
|
+
DELIVERY_TIMEOUT: varint(2),
|
|
1580
|
+
/**
|
|
1581
|
+
* draft-18 §10.2.4: OBJECT_DELIVERY_TIMEOUT — same wire type (0x02) as the
|
|
1582
|
+
* draft-14/16 DELIVERY_TIMEOUT; named alias for the draft-18 scope tables.
|
|
1583
|
+
*/
|
|
1584
|
+
OBJECT_DELIVERY_TIMEOUT: varint(2),
|
|
1585
|
+
/** @see §9.2.2.1 */
|
|
1586
|
+
AUTHORIZATION_TOKEN: varint(3),
|
|
1587
|
+
/** draft-18 §10.2.6: RENDEZVOUS_TIMEOUT — SUBSCRIBE only. */
|
|
1588
|
+
RENDEZVOUS_TIMEOUT: varint(4),
|
|
1589
|
+
/** draft-18 §10.2.3: SUBGROUP_DELIVERY_TIMEOUT — PUBLISH_OK / SUBSCRIBE / REQUEST_UPDATE. */
|
|
1590
|
+
SUBGROUP_DELIVERY_TIMEOUT: varint(6),
|
|
1591
|
+
/** @see §9.2.2.6 */
|
|
1592
|
+
EXPIRES: varint(8),
|
|
1593
|
+
/** @see §9.2.2.7 */
|
|
1594
|
+
LARGEST_OBJECT: varint(9),
|
|
1595
|
+
/** draft-18 §10.2.5: FILL_TIMEOUT — FETCH only. */
|
|
1596
|
+
FILL_TIMEOUT: varint(10),
|
|
1597
|
+
/** @see §9.2.2.8 */
|
|
1598
|
+
FORWARD: varint(16),
|
|
1599
|
+
/** @see §9.2.2.3 */
|
|
1600
|
+
SUBSCRIBER_PRIORITY: varint(32),
|
|
1601
|
+
/** @see §9.2.2.5 */
|
|
1602
|
+
SUBSCRIPTION_FILTER: varint(33),
|
|
1603
|
+
/** @see §9.2.2.4 */
|
|
1604
|
+
GROUP_ORDER: varint(34),
|
|
1605
|
+
/** @see §9.2.2.9 */
|
|
1606
|
+
NEW_GROUP_REQUEST: varint(50),
|
|
1607
|
+
/**
|
|
1608
|
+
* draft-18 §10.2.14: Track Namespace Prefix. Carried in REQUEST_UPDATE to
|
|
1609
|
+
* change the prefix of a SUBSCRIBE_NAMESPACE / SUBSCRIBE_TRACKS. Its value is a
|
|
1610
|
+
* Track Namespace tuple (semantic `Uint8Array[]`), not a KVP scalar — the
|
|
1611
|
+
* draft-14/16 codecs reject it at the KVP boundary.
|
|
1612
|
+
*/
|
|
1613
|
+
TRACK_NAMESPACE_PREFIX: varint(52)
|
|
1614
|
+
};
|
|
1615
|
+
|
|
1616
|
+
// ../transport/dist/control/encoder.js
|
|
1617
|
+
var UNIQUE_MESSAGE_PARAMS = /* @__PURE__ */ new Set([
|
|
1618
|
+
MessageParam.DELIVERY_TIMEOUT,
|
|
1619
|
+
MessageParam.EXPIRES,
|
|
1620
|
+
MessageParam.LARGEST_OBJECT,
|
|
1621
|
+
MessageParam.FORWARD,
|
|
1622
|
+
MessageParam.SUBSCRIBER_PRIORITY,
|
|
1623
|
+
MessageParam.SUBSCRIPTION_FILTER,
|
|
1624
|
+
MessageParam.GROUP_ORDER,
|
|
1625
|
+
MessageParam.NEW_GROUP_REQUEST
|
|
1626
|
+
]);
|
|
1627
|
+
var MESSAGE_PARAM_NAMES = /* @__PURE__ */ new Map([
|
|
1628
|
+
[MessageParam.DELIVERY_TIMEOUT, "DELIVERY_TIMEOUT"],
|
|
1629
|
+
[MessageParam.EXPIRES, "EXPIRES"],
|
|
1630
|
+
[MessageParam.LARGEST_OBJECT, "LARGEST_OBJECT"],
|
|
1631
|
+
[MessageParam.FORWARD, "FORWARD"],
|
|
1632
|
+
[MessageParam.SUBSCRIBER_PRIORITY, "SUBSCRIBER_PRIORITY"],
|
|
1633
|
+
[MessageParam.SUBSCRIPTION_FILTER, "SUBSCRIPTION_FILTER"],
|
|
1634
|
+
[MessageParam.GROUP_ORDER, "GROUP_ORDER"],
|
|
1635
|
+
[MessageParam.NEW_GROUP_REQUEST, "NEW_GROUP_REQUEST"]
|
|
1636
|
+
]);
|
|
1637
|
+
|
|
1638
|
+
// ../transport/dist/control/decoder.js
|
|
1639
|
+
var codeToType = /* @__PURE__ */ new Map();
|
|
1640
|
+
for (const [name, code] of Object.entries(MessageType)) {
|
|
1641
|
+
codeToType.set(code, name);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// ../transport/dist/control/draft14-codec.js
|
|
1645
|
+
var D14 = {
|
|
1646
|
+
// Outbound (subscriber sends)
|
|
1647
|
+
CLIENT_SETUP: 32,
|
|
1648
|
+
SUBSCRIBE: 3,
|
|
1649
|
+
SUBSCRIBE_UPDATE: 2,
|
|
1650
|
+
SUBSCRIBE_NAMESPACE: 17,
|
|
1651
|
+
UNSUBSCRIBE_NAMESPACE: 20,
|
|
1652
|
+
UNSUBSCRIBE: 10,
|
|
1653
|
+
FETCH: 22,
|
|
1654
|
+
FETCH_CANCEL: 23,
|
|
1655
|
+
TRACK_STATUS: 13,
|
|
1656
|
+
PUBLISH_NAMESPACE_OK: 7,
|
|
1657
|
+
PUBLISH_NAMESPACE_ERROR: 8,
|
|
1658
|
+
// Inbound (subscriber receives)
|
|
1659
|
+
SERVER_SETUP: 33,
|
|
1660
|
+
SUBSCRIBE_OK: 4,
|
|
1661
|
+
SUBSCRIBE_ERROR: 5,
|
|
1662
|
+
FETCH_OK: 24,
|
|
1663
|
+
FETCH_ERROR: 25,
|
|
1664
|
+
TRACK_STATUS_OK: 14,
|
|
1665
|
+
TRACK_STATUS_ERROR: 15,
|
|
1666
|
+
SUBSCRIBE_NAMESPACE_OK: 18,
|
|
1667
|
+
SUBSCRIBE_NAMESPACE_ERROR: 19,
|
|
1668
|
+
PUBLISH_NAMESPACE: 6,
|
|
1669
|
+
PUBLISH_NAMESPACE_DONE: 9,
|
|
1670
|
+
PUBLISH_NAMESPACE_CANCEL: 12,
|
|
1671
|
+
PUBLISH_DONE: 11,
|
|
1672
|
+
GOAWAY: 16,
|
|
1673
|
+
MAX_REQUEST_ID: 21,
|
|
1674
|
+
REQUESTS_BLOCKED: 26,
|
|
1675
|
+
// Varint-length messages
|
|
1676
|
+
PUBLISH: 29,
|
|
1677
|
+
PUBLISH_OK: 30,
|
|
1678
|
+
PUBLISH_ERROR: 31
|
|
1679
|
+
};
|
|
1680
|
+
var VARINT_LENGTH_TYPES = /* @__PURE__ */ new Set([D14.PUBLISH, D14.PUBLISH_OK, D14.PUBLISH_ERROR]);
|
|
1681
|
+
|
|
1682
|
+
// ../transport/dist/control/draft18-codec.js
|
|
1683
|
+
var TEXT_ENCODER = new TextEncoder();
|
|
1684
|
+
var TEXT_DECODER = new TextDecoder();
|
|
1685
|
+
var SETUP_SINGLETONS = /* @__PURE__ */ new Set([
|
|
1686
|
+
BigInt(SetupOption18.PATH),
|
|
1687
|
+
BigInt(SetupOption18.AUTHORITY),
|
|
1688
|
+
BigInt(SetupOption18.MAX_AUTH_TOKEN_CACHE_SIZE),
|
|
1689
|
+
BigInt(SetupOption18.MOQT_IMPLEMENTATION)
|
|
1690
|
+
]);
|
|
1691
|
+
|
|
1692
|
+
// ../transport/dist/data/codes.js
|
|
1693
|
+
var ObjectStatus = {
|
|
1694
|
+
/** Normal object (implicit for non-zero length payloads). */
|
|
1695
|
+
NORMAL: varint(0),
|
|
1696
|
+
/**
|
|
1697
|
+
* Object does not exist at any publisher. Valid in draft-14.
|
|
1698
|
+
* @see draft-ietf-moq-transport-14 §10.2.1.1
|
|
1699
|
+
*/
|
|
1700
|
+
OBJECT_DOES_NOT_EXIST: varint(1),
|
|
1701
|
+
/** No objects with same Group ID and Object ID >= this exist. */
|
|
1702
|
+
END_OF_GROUP: varint(3),
|
|
1703
|
+
/** No objects at location >= this exist. */
|
|
1704
|
+
END_OF_TRACK: varint(4)
|
|
1705
|
+
};
|
|
1706
|
+
|
|
1707
|
+
// ../transport/dist/data/decoder.js
|
|
1708
|
+
var VALID_STATUS_CODES_V16 = /* @__PURE__ */ new Set([
|
|
1709
|
+
ObjectStatus.NORMAL,
|
|
1710
|
+
ObjectStatus.END_OF_GROUP,
|
|
1711
|
+
ObjectStatus.END_OF_TRACK
|
|
1712
|
+
]);
|
|
1713
|
+
var VALID_STATUS_CODES_V14 = /* @__PURE__ */ new Set([
|
|
1714
|
+
ObjectStatus.NORMAL,
|
|
1715
|
+
ObjectStatus.OBJECT_DOES_NOT_EXIST,
|
|
1716
|
+
ObjectStatus.END_OF_GROUP,
|
|
1717
|
+
ObjectStatus.END_OF_TRACK
|
|
1718
|
+
]);
|
|
1719
|
+
|
|
1720
|
+
// ../transport/dist/qlog/trace.js
|
|
1721
|
+
var MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
|
|
1722
|
+
|
|
1723
|
+
// ../transport/dist/control/auth-token.js
|
|
1724
|
+
var AliasType = {
|
|
1725
|
+
/** Retire a previously registered alias. Has Alias, no Type/Value. */
|
|
1726
|
+
DELETE: varint(0),
|
|
1727
|
+
/** Associate an alias with a Token Type + Value. Has Alias, Type, Value. */
|
|
1728
|
+
REGISTER: varint(1),
|
|
1729
|
+
/** Reference a previously registered token by alias. Has Alias, no Type/Value. */
|
|
1730
|
+
USE_ALIAS: varint(2),
|
|
1731
|
+
/** Provide Token Type + Value directly (no caching). No Alias, has Type/Value. */
|
|
1732
|
+
USE_VALUE: varint(3)
|
|
1733
|
+
};
|
|
1734
|
+
|
|
1735
|
+
// ../transport/dist/session/setup.js
|
|
1736
|
+
var KNOWN_SINGLETON_SETUP_OPTIONS_18 = /* @__PURE__ */ new Set([
|
|
1737
|
+
SetupOption18.PATH,
|
|
1738
|
+
// 0x01
|
|
1739
|
+
SetupOption18.MAX_AUTH_TOKEN_CACHE_SIZE,
|
|
1740
|
+
// 0x04
|
|
1741
|
+
SetupOption18.AUTHORITY,
|
|
1742
|
+
// 0x05
|
|
1743
|
+
SetupOption18.MOQT_IMPLEMENTATION
|
|
1744
|
+
// 0x07
|
|
1745
|
+
]);
|
|
1746
|
+
|
|
1747
|
+
// ../transport/dist/session/session.js
|
|
1748
|
+
var KNOWN_MESSAGE_PARAMS = /* @__PURE__ */ new Set([
|
|
1749
|
+
MessageParam.DELIVERY_TIMEOUT,
|
|
1750
|
+
MessageParam.AUTHORIZATION_TOKEN,
|
|
1751
|
+
MessageParam.EXPIRES,
|
|
1752
|
+
MessageParam.LARGEST_OBJECT,
|
|
1753
|
+
MessageParam.FORWARD,
|
|
1754
|
+
MessageParam.SUBSCRIBER_PRIORITY,
|
|
1755
|
+
MessageParam.SUBSCRIPTION_FILTER,
|
|
1756
|
+
MessageParam.GROUP_ORDER,
|
|
1757
|
+
MessageParam.NEW_GROUP_REQUEST,
|
|
1758
|
+
MessageParam.TRACK_NAMESPACE_PREFIX
|
|
1759
|
+
]);
|
|
1760
|
+
var VALID_PARAMS_FOR_MESSAGE_TYPE = /* @__PURE__ */ new Map([
|
|
1761
|
+
// §9.2.2.2: DELIVERY_TIMEOUT MAY appear in PUBLISH_OK, SUBSCRIBE, REQUEST_UPDATE
|
|
1762
|
+
[MessageParam.DELIVERY_TIMEOUT, /* @__PURE__ */ new Set(["PUBLISH_OK", "SUBSCRIBE", "REQUEST_UPDATE"])],
|
|
1763
|
+
// §9.2.2.1: AUTHORIZATION_TOKEN MAY appear in PUBLISH, SUBSCRIBE, REQUEST_UPDATE,
|
|
1764
|
+
// SUBSCRIBE_NAMESPACE, PUBLISH_NAMESPACE, TRACK_STATUS, FETCH
|
|
1765
|
+
[MessageParam.AUTHORIZATION_TOKEN, /* @__PURE__ */ new Set([
|
|
1766
|
+
"PUBLISH",
|
|
1767
|
+
"SUBSCRIBE",
|
|
1768
|
+
"REQUEST_UPDATE",
|
|
1769
|
+
"SUBSCRIBE_NAMESPACE",
|
|
1770
|
+
"PUBLISH_NAMESPACE",
|
|
1771
|
+
"TRACK_STATUS",
|
|
1772
|
+
"FETCH"
|
|
1773
|
+
])],
|
|
1774
|
+
// §9.2.2.6: EXPIRES MAY appear in SUBSCRIBE_OK, PUBLISH, PUBLISH_OK
|
|
1775
|
+
[MessageParam.EXPIRES, /* @__PURE__ */ new Set(["SUBSCRIBE_OK", "PUBLISH", "PUBLISH_OK"])],
|
|
1776
|
+
// §9.2.2.7: LARGEST_OBJECT MAY appear in SUBSCRIBE_OK, PUBLISH, REQUEST_OK
|
|
1777
|
+
[MessageParam.LARGEST_OBJECT, /* @__PURE__ */ new Set(["SUBSCRIBE_OK", "PUBLISH", "REQUEST_OK"])],
|
|
1778
|
+
// §9.2.2.8: FORWARD MAY appear in SUBSCRIBE, REQUEST_UPDATE, PUBLISH, PUBLISH_OK, SUBSCRIBE_NAMESPACE
|
|
1779
|
+
[MessageParam.FORWARD, /* @__PURE__ */ new Set(["SUBSCRIBE", "REQUEST_UPDATE", "PUBLISH", "PUBLISH_OK", "SUBSCRIBE_NAMESPACE"])],
|
|
1780
|
+
// §9.2.2.3: SUBSCRIBER_PRIORITY MAY appear in SUBSCRIBE, FETCH, REQUEST_UPDATE, PUBLISH_OK
|
|
1781
|
+
[MessageParam.SUBSCRIBER_PRIORITY, /* @__PURE__ */ new Set(["SUBSCRIBE", "FETCH", "REQUEST_UPDATE", "PUBLISH_OK"])],
|
|
1782
|
+
// §9.2.2.5: SUBSCRIPTION_FILTER MAY appear in SUBSCRIBE, PUBLISH_OK, REQUEST_UPDATE
|
|
1783
|
+
[MessageParam.SUBSCRIPTION_FILTER, /* @__PURE__ */ new Set(["SUBSCRIBE", "PUBLISH_OK", "REQUEST_UPDATE"])],
|
|
1784
|
+
// §9.2.2.4: GROUP_ORDER MAY appear in SUBSCRIBE, PUBLISH_OK, FETCH
|
|
1785
|
+
// Draft-14 also carries it inline on PUBLISH; handle that as a versioned exception.
|
|
1786
|
+
[MessageParam.GROUP_ORDER, /* @__PURE__ */ new Set(["SUBSCRIBE", "PUBLISH_OK", "FETCH"])],
|
|
1787
|
+
// §9.2.2.9: NEW_GROUP_REQUEST MAY appear in PUBLISH_OK, SUBSCRIBE, REQUEST_UPDATE
|
|
1788
|
+
[MessageParam.NEW_GROUP_REQUEST, /* @__PURE__ */ new Set(["PUBLISH_OK", "SUBSCRIBE", "REQUEST_UPDATE"])],
|
|
1789
|
+
// draft-18 §10.2.14: TRACK_NAMESPACE_PREFIX MAY appear in REQUEST_UPDATE (to
|
|
1790
|
+
// change the prefix of a SUBSCRIBE_NAMESPACE / SUBSCRIBE_TRACKS).
|
|
1791
|
+
[MessageParam.TRACK_NAMESPACE_PREFIX, /* @__PURE__ */ new Set(["REQUEST_UPDATE"])]
|
|
1792
|
+
]);
|
|
1793
|
+
var KNOWN_MESSAGE_PARAMS_18 = /* @__PURE__ */ new Set([
|
|
1794
|
+
MessageParam.OBJECT_DELIVERY_TIMEOUT,
|
|
1795
|
+
// 0x02
|
|
1796
|
+
MessageParam.AUTHORIZATION_TOKEN,
|
|
1797
|
+
// 0x03
|
|
1798
|
+
MessageParam.RENDEZVOUS_TIMEOUT,
|
|
1799
|
+
// 0x04
|
|
1800
|
+
MessageParam.SUBGROUP_DELIVERY_TIMEOUT,
|
|
1801
|
+
// 0x06
|
|
1802
|
+
MessageParam.EXPIRES,
|
|
1803
|
+
// 0x08
|
|
1804
|
+
MessageParam.LARGEST_OBJECT,
|
|
1805
|
+
// 0x09
|
|
1806
|
+
MessageParam.FILL_TIMEOUT,
|
|
1807
|
+
// 0x0a
|
|
1808
|
+
MessageParam.FORWARD,
|
|
1809
|
+
// 0x10
|
|
1810
|
+
MessageParam.SUBSCRIBER_PRIORITY,
|
|
1811
|
+
// 0x20
|
|
1812
|
+
MessageParam.SUBSCRIPTION_FILTER,
|
|
1813
|
+
// 0x21
|
|
1814
|
+
MessageParam.GROUP_ORDER,
|
|
1815
|
+
// 0x22
|
|
1816
|
+
MessageParam.NEW_GROUP_REQUEST,
|
|
1817
|
+
// 0x32
|
|
1818
|
+
MessageParam.TRACK_NAMESPACE_PREFIX
|
|
1819
|
+
]);
|
|
1820
|
+
var VALID_PARAMS_FOR_MESSAGE_TYPE_18 = /* @__PURE__ */ new Map([
|
|
1821
|
+
// §10.2.4
|
|
1822
|
+
[MessageParam.OBJECT_DELIVERY_TIMEOUT, /* @__PURE__ */ new Set(["PUBLISH_OK", "SUBSCRIBE", "REQUEST_UPDATE"])],
|
|
1823
|
+
// §10.2.2
|
|
1824
|
+
[MessageParam.AUTHORIZATION_TOKEN, /* @__PURE__ */ new Set([
|
|
1825
|
+
"PUBLISH",
|
|
1826
|
+
"SUBSCRIBE",
|
|
1827
|
+
"REQUEST_UPDATE",
|
|
1828
|
+
"SUBSCRIBE_NAMESPACE",
|
|
1829
|
+
"SUBSCRIBE_TRACKS",
|
|
1830
|
+
"PUBLISH_NAMESPACE",
|
|
1831
|
+
"TRACK_STATUS",
|
|
1832
|
+
"FETCH"
|
|
1833
|
+
])],
|
|
1834
|
+
// §10.2.6
|
|
1835
|
+
[MessageParam.RENDEZVOUS_TIMEOUT, /* @__PURE__ */ new Set(["SUBSCRIBE"])],
|
|
1836
|
+
// §10.2.3
|
|
1837
|
+
[MessageParam.SUBGROUP_DELIVERY_TIMEOUT, /* @__PURE__ */ new Set(["PUBLISH_OK", "SUBSCRIBE", "REQUEST_UPDATE"])],
|
|
1838
|
+
// §10.2.10
|
|
1839
|
+
[MessageParam.EXPIRES, /* @__PURE__ */ new Set(["SUBSCRIBE_OK", "PUBLISH", "PUBLISH_OK", "REQUEST_UPDATE_OK"])],
|
|
1840
|
+
// §10.2.11
|
|
1841
|
+
[MessageParam.LARGEST_OBJECT, /* @__PURE__ */ new Set(["SUBSCRIBE_OK", "PUBLISH", "REQUEST_UPDATE_OK", "TRACK_STATUS_OK"])],
|
|
1842
|
+
// §10.2.5
|
|
1843
|
+
[MessageParam.FILL_TIMEOUT, /* @__PURE__ */ new Set(["FETCH"])],
|
|
1844
|
+
// §10.2.12
|
|
1845
|
+
[MessageParam.FORWARD, /* @__PURE__ */ new Set(["SUBSCRIBE", "REQUEST_UPDATE", "PUBLISH", "PUBLISH_OK", "SUBSCRIBE_TRACKS"])],
|
|
1846
|
+
// §10.2.7
|
|
1847
|
+
[MessageParam.SUBSCRIBER_PRIORITY, /* @__PURE__ */ new Set(["SUBSCRIBE", "FETCH", "REQUEST_UPDATE", "PUBLISH_OK"])],
|
|
1848
|
+
// §10.2.9
|
|
1849
|
+
[MessageParam.SUBSCRIPTION_FILTER, /* @__PURE__ */ new Set(["SUBSCRIBE", "PUBLISH_OK", "REQUEST_UPDATE"])],
|
|
1850
|
+
// §10.2.8
|
|
1851
|
+
[MessageParam.GROUP_ORDER, /* @__PURE__ */ new Set(["SUBSCRIBE", "PUBLISH_OK", "FETCH"])],
|
|
1852
|
+
// §10.2.13
|
|
1853
|
+
[MessageParam.NEW_GROUP_REQUEST, /* @__PURE__ */ new Set(["PUBLISH_OK", "SUBSCRIBE", "REQUEST_UPDATE"])],
|
|
1854
|
+
// §10.2.14 — generic REQUEST_UPDATE scope; the namespace/tracks-target restriction
|
|
1855
|
+
// is enforced contextually in handleIncomingRequestUpdate.
|
|
1856
|
+
[MessageParam.TRACK_NAMESPACE_PREFIX, /* @__PURE__ */ new Set(["REQUEST_UPDATE"])]
|
|
1857
|
+
]);
|
|
1858
|
+
var FILL_PARAMETERS = 0x23n;
|
|
1859
|
+
var RANGE_FILTER_PARAMS_22 = /* @__PURE__ */ new Set([0x25n, 0x26n, 0x27n, 0x28n, 0x29n]);
|
|
1860
|
+
var KNOWN_MESSAGE_PARAMS_22 = /* @__PURE__ */ new Set([
|
|
1861
|
+
...KNOWN_MESSAGE_PARAMS_18,
|
|
1862
|
+
FILL_PARAMETERS,
|
|
1863
|
+
...RANGE_FILTER_PARAMS_22,
|
|
1864
|
+
0x35n
|
|
1865
|
+
// INCLUDE_PROPERTIES
|
|
1866
|
+
]);
|
|
1867
|
+
var VALID_PARAMS_FOR_MESSAGE_TYPE_22 = /* @__PURE__ */ new Map([
|
|
1868
|
+
[MessageParam.OBJECT_DELIVERY_TIMEOUT, /* @__PURE__ */ new Set(["SUBSCRIBE", "PUBLISH", "REQUEST_UPDATE", "SUBSCRIBE_TRACKS"])],
|
|
1869
|
+
[MessageParam.AUTHORIZATION_TOKEN, /* @__PURE__ */ new Set([
|
|
1870
|
+
"PUBLISH",
|
|
1871
|
+
"SUBSCRIBE",
|
|
1872
|
+
"REQUEST_UPDATE",
|
|
1873
|
+
"SUBSCRIBE_NAMESPACE",
|
|
1874
|
+
"SUBSCRIBE_TRACKS",
|
|
1875
|
+
"PUBLISH_NAMESPACE",
|
|
1876
|
+
"TRACK_STATUS",
|
|
1877
|
+
"FETCH"
|
|
1878
|
+
])],
|
|
1879
|
+
[MessageParam.RENDEZVOUS_TIMEOUT, /* @__PURE__ */ new Set(["SUBSCRIBE", "SUBSCRIBE_TRACKS"])],
|
|
1880
|
+
[MessageParam.SUBGROUP_DELIVERY_TIMEOUT, /* @__PURE__ */ new Set(["SUBSCRIBE", "PUBLISH", "REQUEST_UPDATE", "SUBSCRIBE_TRACKS"])],
|
|
1881
|
+
[MessageParam.EXPIRES, /* @__PURE__ */ new Set([
|
|
1882
|
+
"SUBSCRIBE_OK",
|
|
1883
|
+
"PUBLISH",
|
|
1884
|
+
"PUBLISH_OK",
|
|
1885
|
+
"REQUEST_UPDATE_OK",
|
|
1886
|
+
"SUBSCRIBE_NAMESPACE_OK",
|
|
1887
|
+
"SUBSCRIBE_TRACKS_OK",
|
|
1888
|
+
"PUBLISH_NAMESPACE_OK"
|
|
1889
|
+
])],
|
|
1890
|
+
[MessageParam.LARGEST_OBJECT, /* @__PURE__ */ new Set([
|
|
1891
|
+
"SUBSCRIBE_OK",
|
|
1892
|
+
"PUBLISH",
|
|
1893
|
+
"REQUEST_UPDATE_OK",
|
|
1894
|
+
"TRACK_STATUS_OK",
|
|
1895
|
+
"PUBLISH_STATE_NOTIFY"
|
|
1896
|
+
])],
|
|
1897
|
+
[MessageParam.FILL_TIMEOUT, /* @__PURE__ */ new Set(["FETCH"])],
|
|
1898
|
+
[MessageParam.FORWARD, /* @__PURE__ */ new Set(["SUBSCRIBE", "REQUEST_UPDATE", "PUBLISH", "SUBSCRIBE_TRACKS", "PUBLISH_STATE_NOTIFY"])],
|
|
1899
|
+
[MessageParam.SUBSCRIBER_PRIORITY, /* @__PURE__ */ new Set(["SUBSCRIBE", "FETCH", "REQUEST_UPDATE", "PUBLISH", "SUBSCRIBE_TRACKS"])],
|
|
1900
|
+
[MessageParam.SUBSCRIPTION_FILTER, /* @__PURE__ */ new Set([
|
|
1901
|
+
"SUBSCRIBE",
|
|
1902
|
+
"REQUEST_UPDATE",
|
|
1903
|
+
"PUBLISH",
|
|
1904
|
+
"FETCH",
|
|
1905
|
+
"SUBSCRIBE_TRACKS",
|
|
1906
|
+
"PUBLISH_STATE_NOTIFY"
|
|
1907
|
+
])],
|
|
1908
|
+
[MessageParam.GROUP_ORDER, /* @__PURE__ */ new Set(["SUBSCRIBE", "FETCH", "PUBLISH", "SUBSCRIBE_TRACKS"])],
|
|
1909
|
+
[FILL_PARAMETERS, /* @__PURE__ */ new Set(["SUBSCRIBE", "REQUEST_UPDATE", "SUBSCRIBE_TRACKS"])],
|
|
1910
|
+
[0x25n, /* @__PURE__ */ new Set(["SUBSCRIBE", "FETCH", "REQUEST_UPDATE", "SUBSCRIBE_TRACKS"])],
|
|
1911
|
+
[0x26n, /* @__PURE__ */ new Set(["SUBSCRIBE", "FETCH", "REQUEST_UPDATE", "SUBSCRIBE_TRACKS"])],
|
|
1912
|
+
[0x27n, /* @__PURE__ */ new Set(["SUBSCRIBE", "FETCH", "REQUEST_UPDATE", "SUBSCRIBE_TRACKS"])],
|
|
1913
|
+
[0x28n, /* @__PURE__ */ new Set(["SUBSCRIBE", "FETCH", "REQUEST_UPDATE", "SUBSCRIBE_TRACKS"])],
|
|
1914
|
+
[0x29n, /* @__PURE__ */ new Set(["REQUEST_UPDATE", "SUBSCRIBE_TRACKS"])],
|
|
1915
|
+
[MessageParam.NEW_GROUP_REQUEST, /* @__PURE__ */ new Set(["SUBSCRIBE", "REQUEST_UPDATE", "SUBSCRIBE_TRACKS"])],
|
|
1916
|
+
[MessageParam.TRACK_NAMESPACE_PREFIX, /* @__PURE__ */ new Set(["REQUEST_UPDATE"])],
|
|
1917
|
+
[0x35n, /* @__PURE__ */ new Set(["SUBSCRIBE", "FETCH", "TRACK_STATUS", "SUBSCRIBE_TRACKS"])]
|
|
1918
|
+
]);
|
|
1919
|
+
|
|
1920
|
+
// ../loc/dist/types.js
|
|
1921
|
+
var Loc01PropertyId = {
|
|
1922
|
+
/** Wall-clock capture time in microseconds. @see loc-01 §2.3.1.1 */
|
|
1923
|
+
CAPTURE_TIMESTAMP: 2,
|
|
1924
|
+
/** RFC 9626 video frame marking as an integer. @see loc-01 §2.3.2.2 */
|
|
1925
|
+
VIDEO_FRAME_MARKING: 4,
|
|
1926
|
+
/** RFC 6464 audio level + voice activity. @see loc-01 §2.3.3.1 */
|
|
1927
|
+
AUDIO_LEVEL: 6,
|
|
1928
|
+
/** Video codec configuration extradata. @see loc-01 §2.3.2.1 */
|
|
1929
|
+
VIDEO_CONFIG: 13
|
|
1930
|
+
};
|
|
1931
|
+
var Loc04PropertyId = {
|
|
1932
|
+
/** Timestamp units per second. Absent means microseconds. @see loc-04 §2.3.1.2 */
|
|
1933
|
+
TIMESCALE: 8,
|
|
1934
|
+
/** RFC 9626 video frame marking as 1 to 4 bytes. @see loc-04 §2.3.2.2 */
|
|
1935
|
+
VIDEO_FRAME_MARKING: 9,
|
|
1936
|
+
/** RFC 6464 audio level + voice activity. @see loc-04 §2.3.3.2 */
|
|
1937
|
+
AUDIO_LEVEL: 12,
|
|
1938
|
+
/** Video codec configuration extradata. @see loc-04 §2.3.2.1 */
|
|
1939
|
+
VIDEO_CONFIG: 13,
|
|
1940
|
+
/** Audio codec configuration. @see loc-04 §2.3.3.1 */
|
|
1941
|
+
AUDIO_CONFIG: 15,
|
|
1942
|
+
/** Frame timestamp in Timescale units. @see loc-04 §2.3.1.1 */
|
|
1943
|
+
TIMESTAMP: 16
|
|
1944
|
+
};
|
|
1945
|
+
|
|
1946
|
+
// ../loc/dist/property-map.js
|
|
1947
|
+
var ID01 = {
|
|
1948
|
+
captureTimestamp: BigInt(Loc01PropertyId.CAPTURE_TIMESTAMP),
|
|
1949
|
+
videoFrameMarking: BigInt(Loc01PropertyId.VIDEO_FRAME_MARKING),
|
|
1950
|
+
audioLevel: BigInt(Loc01PropertyId.AUDIO_LEVEL),
|
|
1951
|
+
videoConfig: BigInt(Loc01PropertyId.VIDEO_CONFIG)
|
|
1952
|
+
};
|
|
1953
|
+
var ID04 = {
|
|
1954
|
+
timescale: BigInt(Loc04PropertyId.TIMESCALE),
|
|
1955
|
+
videoFrameMarking: BigInt(Loc04PropertyId.VIDEO_FRAME_MARKING),
|
|
1956
|
+
audioLevel: BigInt(Loc04PropertyId.AUDIO_LEVEL),
|
|
1957
|
+
videoConfig: BigInt(Loc04PropertyId.VIDEO_CONFIG),
|
|
1958
|
+
audioConfig: BigInt(Loc04PropertyId.AUDIO_CONFIG),
|
|
1959
|
+
timestamp: BigInt(Loc04PropertyId.TIMESTAMP)
|
|
1960
|
+
};
|
|
1961
|
+
|
|
1962
|
+
// ../loc/dist/headers.js
|
|
1963
|
+
function sourceTimestamp(headers) {
|
|
1964
|
+
const micros = headers.captureTimestamp;
|
|
1965
|
+
if (micros === void 0)
|
|
1966
|
+
return void 0;
|
|
1967
|
+
const scale = headers.timescale;
|
|
1968
|
+
if (scale !== void 0 && (scale <= 0n || headers.timestampIsWallClock === true))
|
|
1969
|
+
return void 0;
|
|
1970
|
+
if (headers.timestamp !== void 0) {
|
|
1971
|
+
if (scale === void 0 && headers.timestampIsWallClock === false)
|
|
1972
|
+
return void 0;
|
|
1973
|
+
const ticksPerSecond = scale ?? 1000000n;
|
|
1974
|
+
const scaled = headers.timestamp * 1000000n;
|
|
1975
|
+
const truncated = scaled / ticksPerSecond;
|
|
1976
|
+
const rounded = (scaled + (scaled >= 0n ? ticksPerSecond / 2n : -(ticksPerSecond / 2n))) / ticksPerSecond;
|
|
1977
|
+
if (micros !== truncated && micros !== rounded)
|
|
1978
|
+
return void 0;
|
|
1979
|
+
return Object.freeze({ ticks: headers.timestamp, ticksPerSecond, domain: scale === void 0 ? "unix" : "media" });
|
|
1980
|
+
}
|
|
1981
|
+
return Object.freeze({
|
|
1982
|
+
ticks: micros,
|
|
1983
|
+
ticksPerSecond: 1000000n,
|
|
1984
|
+
domain: headers.timestampIsWallClock === true ? "unix" : headers.timestampIsWallClock === false || scale !== void 0 ? "media" : "unknown"
|
|
1985
|
+
});
|
|
1986
|
+
}
|
|
1987
|
+
function toVideoChunkInit(payload, headers) {
|
|
1988
|
+
const isKey = headers.videoFrameMarking?.independent === true;
|
|
1989
|
+
const timestamp = headers.captureTimestamp !== void 0 ? Number(headers.captureTimestamp) : 0;
|
|
1990
|
+
const source = sourceTimestamp(headers);
|
|
1991
|
+
return {
|
|
1992
|
+
type: isKey ? "key" : "delta",
|
|
1993
|
+
timestamp,
|
|
1994
|
+
...source !== void 0 ? { sourceTimestamp: source } : {},
|
|
1995
|
+
...headers.duration !== void 0 ? { duration: headers.duration } : {},
|
|
1996
|
+
data: payload
|
|
1997
|
+
};
|
|
1998
|
+
}
|
|
1999
|
+
function toAudioChunkInit(payload, headers) {
|
|
2000
|
+
const timestamp = headers.captureTimestamp !== void 0 ? Number(headers.captureTimestamp) : 0;
|
|
2001
|
+
const source = sourceTimestamp(headers);
|
|
2002
|
+
return {
|
|
2003
|
+
type: "key",
|
|
2004
|
+
timestamp,
|
|
2005
|
+
...source !== void 0 ? { sourceTimestamp: source } : {},
|
|
2006
|
+
...headers.duration !== void 0 ? { duration: headers.duration } : {},
|
|
2007
|
+
data: payload
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// src/pipeline.ts
|
|
2012
|
+
var PlaybackPipeline = class {
|
|
2013
|
+
mediaType;
|
|
2014
|
+
clock;
|
|
2015
|
+
sync;
|
|
2016
|
+
onCommand;
|
|
2017
|
+
onEvent;
|
|
2018
|
+
recovery;
|
|
2019
|
+
buffer;
|
|
2020
|
+
gapDetector;
|
|
2021
|
+
decoderState;
|
|
2022
|
+
/** Headers stored alongside objects, keyed by "groupId:objectId". */
|
|
2023
|
+
headerMap = /* @__PURE__ */ new Map();
|
|
2024
|
+
/** Group reference counts in the jitter buffer. */
|
|
2025
|
+
bufferedGroupCounts = /* @__PURE__ */ new Map();
|
|
2026
|
+
/** Next expected objectId within the current group for contiguity check. */
|
|
2027
|
+
nextExpectedObjectId = 0n;
|
|
2028
|
+
/** Highest group from which at least one object was consumed. -1n = nothing consumed yet. */
|
|
2029
|
+
lastConsumedGroupId = -1n;
|
|
2030
|
+
/** Groups for which END_OF_GROUP has been observed. */
|
|
2031
|
+
endedGroups = /* @__PURE__ */ new Set();
|
|
2032
|
+
/** Timestamp when we first started waiting for active group completion. */
|
|
2033
|
+
activeGroupWaitStartUs = null;
|
|
2034
|
+
/**
|
|
2035
|
+
* Minimum group ID accepted by pushObject().
|
|
2036
|
+
* Set after SKIP_FORWARD to reject stale objects from pre-skip groups
|
|
2037
|
+
* that arrive late due to async QUIC stream delivery.
|
|
2038
|
+
*/
|
|
2039
|
+
minAcceptGroupId = -1n;
|
|
2040
|
+
/** Last group for which keyframe_waiting was emitted (dedup). */
|
|
2041
|
+
lastKeyframeWaitingGroupId = -1n;
|
|
2042
|
+
/**
|
|
2043
|
+
* When true, the first tick syncs to whatever group is available
|
|
2044
|
+
* without gap detection — avoids cascading recovery on initial
|
|
2045
|
+
* live join or after pause→resume on a live stream.
|
|
2046
|
+
*
|
|
2047
|
+
* Starts `true` so the first arriving group is accepted as the
|
|
2048
|
+
* starting point (joining mid-broadcast is not a gap).
|
|
2049
|
+
*/
|
|
2050
|
+
resetPending = true;
|
|
2051
|
+
/** True once track_ended has been emitted — prevents re-emission on every tick. */
|
|
2052
|
+
_trackEnded = false;
|
|
2053
|
+
/** Last emitted playback rate — dedup to avoid spamming commands. */
|
|
2054
|
+
_lastEmittedRate = 1;
|
|
2055
|
+
/**
|
|
2056
|
+
* Throttle flag — set by decoder feedback when queue depth is high.
|
|
2057
|
+
* While true, tick() evaluates gaps but does NOT drain the buffer.
|
|
2058
|
+
* Objects still enter the jitter buffer (needed for gap detection).
|
|
2059
|
+
*/
|
|
2060
|
+
_throttled = false;
|
|
2061
|
+
/** Adaptive jitter tolerance controller (null if disabled). */
|
|
2062
|
+
adaptiveTolerance;
|
|
2063
|
+
/** Max objects to release per tick (video only). 0 = unlimited. */
|
|
2064
|
+
maxReleasePerTick;
|
|
2065
|
+
/** Max groups before shedding old ones. 0 = unlimited. */
|
|
2066
|
+
maxBacklogGroups;
|
|
2067
|
+
/** Video codec string from catalog (e.g., 'avc1.42c01f'). Used for keyframe validation. */
|
|
2068
|
+
_videoCodec;
|
|
2069
|
+
_videoOnly;
|
|
2070
|
+
constructor(opts) {
|
|
2071
|
+
this.mediaType = opts.mediaType;
|
|
2072
|
+
this.clock = opts.clock;
|
|
2073
|
+
this.sync = opts.sync;
|
|
2074
|
+
this.onCommand = opts.onCommand;
|
|
2075
|
+
this.onEvent = opts.onEvent;
|
|
2076
|
+
this.recovery = opts.recovery;
|
|
2077
|
+
this._videoOnly = opts.videoOnly ?? false;
|
|
2078
|
+
const liveVideo = opts.mediaType === "video" && (opts.isLive ?? true);
|
|
2079
|
+
this.maxReleasePerTick = liveVideo ? opts.config.maxReleasePerTick ?? 5 : 0;
|
|
2080
|
+
this.maxBacklogGroups = liveVideo ? opts.config.maxBacklogGroups ?? 3 : 0;
|
|
2081
|
+
this.buffer = new JitterBuffer(opts.config.maxBufferDepth);
|
|
2082
|
+
this.gapDetector = new GapDetector({
|
|
2083
|
+
gapTimeoutUs: opts.config.gapTimeoutUs,
|
|
2084
|
+
clock: opts.clock
|
|
2085
|
+
});
|
|
2086
|
+
this.decoderState = new DecoderStateMachine(opts.mediaType);
|
|
2087
|
+
this.adaptiveTolerance = opts.config.adaptiveTolerance ? new AdaptiveToleranceController(DEFAULT_TOLERANCE_CONFIG) : null;
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* Set the video codec string for keyframe payload validation.
|
|
2091
|
+
*
|
|
2092
|
+
* @param codec Codec string from catalog (e.g., 'avc1.42c01f', 'hvc1', 'av01.0.08M.10')
|
|
2093
|
+
* @see draft-ietf-moq-loc-01 §4.2 (decode order)
|
|
2094
|
+
*/
|
|
2095
|
+
setCodec(codec) {
|
|
2096
|
+
this._videoCodec = codec;
|
|
2097
|
+
}
|
|
2098
|
+
/**
|
|
2099
|
+
* Current effective gap timeout in microseconds.
|
|
2100
|
+
* When adaptive tolerance is enabled, this is auto-calibrated from jitter.
|
|
2101
|
+
* Otherwise, returns the fixed configured value.
|
|
2102
|
+
*/
|
|
2103
|
+
get effectiveGapTimeoutUs() {
|
|
2104
|
+
if (this.adaptiveTolerance) {
|
|
2105
|
+
return this.adaptiveTolerance.effectiveGapTimeoutMs * 1e3;
|
|
2106
|
+
}
|
|
2107
|
+
return this.gapDetector.gapTimeoutUs;
|
|
2108
|
+
}
|
|
2109
|
+
/** Last consumed group ID — for track switch subscription filters. */
|
|
2110
|
+
get currentGroupId() {
|
|
2111
|
+
return this.lastConsumedGroupId;
|
|
2112
|
+
}
|
|
2113
|
+
/** Number of distinct groups currently in the jitter buffer. */
|
|
2114
|
+
get bufferedGroupCount() {
|
|
2115
|
+
return this.bufferedGroupCounts.size;
|
|
2116
|
+
}
|
|
2117
|
+
/** Whether draining is throttled due to decoder backpressure. */
|
|
2118
|
+
get throttled() {
|
|
2119
|
+
return this._throttled;
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* Handle feedback from browser adapters (decoder queue, errors, drift).
|
|
2123
|
+
*
|
|
2124
|
+
* @see draft-ietf-moq-loc-01 §2.3.1.1 (drift detection)
|
|
2125
|
+
*/
|
|
2126
|
+
handleFeedback(fb) {
|
|
2127
|
+
switch (fb.type) {
|
|
2128
|
+
case "queue_pressure":
|
|
2129
|
+
this._throttled = fb.depth >= fb.maxRecommended;
|
|
2130
|
+
break;
|
|
2131
|
+
case "decode_error":
|
|
2132
|
+
this._throttled = false;
|
|
2133
|
+
{
|
|
2134
|
+
const decision = this.decoderState.notifyGap();
|
|
2135
|
+
this.emitFsmDecision(decision);
|
|
2136
|
+
}
|
|
2137
|
+
if (this.recovery) {
|
|
2138
|
+
const action = this.recovery.evaluate({ type: "decode_error", message: fb.message });
|
|
2139
|
+
this.onEvent({ type: "recovery", action });
|
|
2140
|
+
}
|
|
2141
|
+
break;
|
|
2142
|
+
case "frame_rendered":
|
|
2143
|
+
if (fb.scheduledRenderUs !== void 0) {
|
|
2144
|
+
this.sync.reportPresentationTiming(fb.scheduledRenderUs, fb.actualRenderUs);
|
|
2145
|
+
const drift = this.sync.currentDriftUs;
|
|
2146
|
+
if (this.sync.presentationDriftExceeded) {
|
|
2147
|
+
this.onEvent({ type: "sync_drift", driftUs: drift });
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
break;
|
|
2151
|
+
case "flush_complete":
|
|
2152
|
+
break;
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
/**
|
|
2156
|
+
* Provide codec configuration (from catalog or initial headers).
|
|
2157
|
+
*
|
|
2158
|
+
* This is also triggered automatically when a LOC object carries the
|
|
2159
|
+
* matching config property: Video Config for video, Audio Config for
|
|
2160
|
+
* audio.
|
|
2161
|
+
*
|
|
2162
|
+
* @param config Codec-specific configuration bytes
|
|
2163
|
+
*/
|
|
2164
|
+
configure(config) {
|
|
2165
|
+
const decision = this.decoderState.configure(config);
|
|
2166
|
+
if (decision.action === "configure") {
|
|
2167
|
+
this.onCommand({
|
|
2168
|
+
type: "configure",
|
|
2169
|
+
mediaType: this.mediaType,
|
|
2170
|
+
config
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
/**
|
|
2175
|
+
* Push an object into the pipeline.
|
|
2176
|
+
*
|
|
2177
|
+
* Data objects are buffered. Gap objects notify the gap detector.
|
|
2178
|
+
*
|
|
2179
|
+
* @param obj The MoqtObject (data or gap)
|
|
2180
|
+
* @param headers Parsed LocHeaders (only meaningful for data objects)
|
|
2181
|
+
*/
|
|
2182
|
+
pushObject(obj, headers) {
|
|
2183
|
+
if (obj.kind === "gap") {
|
|
2184
|
+
if (obj.status === ObjectStatus.END_OF_GROUP) {
|
|
2185
|
+
this.gapDetector.observeEndOfGroup(obj.groupId);
|
|
2186
|
+
this.endedGroups.add(obj.groupId);
|
|
2187
|
+
} else if (obj.status === ObjectStatus.END_OF_TRACK) {
|
|
2188
|
+
this.gapDetector.observeEndOfTrack();
|
|
2189
|
+
}
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
if (obj.groupId < this.minAcceptGroupId) return;
|
|
2193
|
+
if (this.lastConsumedGroupId >= 0n && obj.groupId < this.lastConsumedGroupId) return;
|
|
2194
|
+
if (obj.groupId === this.lastConsumedGroupId && obj.objectId < this.nextExpectedObjectId) return;
|
|
2195
|
+
const key = `${obj.groupId}:${obj.objectId}`;
|
|
2196
|
+
if (this.headerMap.has(key)) return;
|
|
2197
|
+
if (this.buffer.insert(obj)) {
|
|
2198
|
+
this.headerMap.set(key, headers ?? {});
|
|
2199
|
+
const count = this.bufferedGroupCounts.get(obj.groupId) ?? 0;
|
|
2200
|
+
this.bufferedGroupCounts.set(obj.groupId, count + 1);
|
|
2201
|
+
} else if (obj.publisherPriority !== void 0) {
|
|
2202
|
+
if (!this.tryEvictAndInsert(obj, headers) && this.recovery) {
|
|
2203
|
+
this.onEvent({
|
|
2204
|
+
type: "recovery",
|
|
2205
|
+
action: this.recovery.evaluate({ type: "buffer_overflow" })
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
} else if (this.recovery) {
|
|
2209
|
+
this.onEvent({
|
|
2210
|
+
type: "recovery",
|
|
2211
|
+
action: this.recovery.evaluate({ type: "buffer_overflow" })
|
|
2212
|
+
});
|
|
2213
|
+
}
|
|
2214
|
+
if (this.adaptiveTolerance && headers?.captureTimestamp !== void 0) {
|
|
2215
|
+
const nowMs = this.clock.now() / 1e3;
|
|
2216
|
+
const captureUs = Number(headers.captureTimestamp);
|
|
2217
|
+
this.adaptiveTolerance.onFrameArrived(nowMs, captureUs, nowMs);
|
|
2218
|
+
}
|
|
2219
|
+
this.gapDetector.observeGroup(obj.groupId);
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Reset pipeline state for pause→resume or seek.
|
|
2223
|
+
*
|
|
2224
|
+
* Clears the jitter buffer, gap detector, and decoder FSM so the first
|
|
2225
|
+
* arriving group after resume is accepted cleanly without cascading
|
|
2226
|
+
* gap-recovery escalation from the stale pre-pause position.
|
|
2227
|
+
*
|
|
2228
|
+
* Emits a decoder 'reset' command so the WebCodecs decoder clears its
|
|
2229
|
+
* queue and waits for the next keyframe (video) or resumes immediately
|
|
2230
|
+
* (audio — Opus/AAC-LC frames are independently decodable).
|
|
2231
|
+
*
|
|
2232
|
+
* @param targetGroupId When seeking, set this to the target group ID.
|
|
2233
|
+
* Objects from groups before this ID are rejected — prevents stale
|
|
2234
|
+
* in-flight objects from corrupting the post-seek decoder state.
|
|
2235
|
+
* @see draft-ietf-moq-transport-16 §9.11.1 — "it might still receive
|
|
2236
|
+
* Objects outside the new range if the publisher sent them before the
|
|
2237
|
+
* update was processed."
|
|
2238
|
+
*/
|
|
2239
|
+
/**
|
|
2240
|
+
* Reset for a track switch — clears jitter buffer (old-track objects
|
|
2241
|
+
* use the same groupIds as new-track per altGroup alignment), resets
|
|
2242
|
+
* gap detector and group tracking. Does NOT reset decoder state or
|
|
2243
|
+
* emit flush/reset commands — already-decoded frames in the renderer
|
|
2244
|
+
* queue play out naturally for seamless visual transition.
|
|
2245
|
+
*
|
|
2246
|
+
* @param firstGroupId The first group ID from the new track — sets
|
|
2247
|
+
* lastConsumedGroupId to firstGroupId-1 so the gap detector doesn't
|
|
2248
|
+
* see a gap and trigger skip_forward (which would reset sync).
|
|
2249
|
+
*/
|
|
2250
|
+
resetForTrackSwitch(firstGroupId) {
|
|
2251
|
+
this.buffer.clear();
|
|
2252
|
+
this.headerMap.clear();
|
|
2253
|
+
this.bufferedGroupCounts.clear();
|
|
2254
|
+
this.lastConsumedGroupId = firstGroupId - 1n;
|
|
2255
|
+
this.endedGroups.add(this.lastConsumedGroupId);
|
|
2256
|
+
this.nextExpectedObjectId = 0n;
|
|
2257
|
+
this.minAcceptGroupId = firstGroupId;
|
|
2258
|
+
this.lastKeyframeWaitingGroupId = -1n;
|
|
2259
|
+
this._throttled = false;
|
|
2260
|
+
this.activeGroupWaitStartUs = null;
|
|
2261
|
+
this.gapDetector.reset();
|
|
2262
|
+
const decision = this.decoderState.notifyGap();
|
|
2263
|
+
this.emitFsmDecision(decision);
|
|
2264
|
+
}
|
|
2265
|
+
reset(targetGroupId) {
|
|
2266
|
+
this.buffer.clear();
|
|
2267
|
+
this.headerMap.clear();
|
|
2268
|
+
this.bufferedGroupCounts.clear();
|
|
2269
|
+
this.lastConsumedGroupId = -1n;
|
|
2270
|
+
this.nextExpectedObjectId = 0n;
|
|
2271
|
+
this.minAcceptGroupId = targetGroupId ?? -1n;
|
|
2272
|
+
this.lastKeyframeWaitingGroupId = -1n;
|
|
2273
|
+
this.resetPending = true;
|
|
2274
|
+
this._throttled = false;
|
|
2275
|
+
this._trackEnded = false;
|
|
2276
|
+
this.endedGroups.clear();
|
|
2277
|
+
this.activeGroupWaitStartUs = null;
|
|
2278
|
+
this.gapDetector.reset();
|
|
2279
|
+
this.adaptiveTolerance?.reset();
|
|
2280
|
+
const decision = this.decoderState.notifyGap();
|
|
2281
|
+
this.emitFsmDecision(decision);
|
|
2282
|
+
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Process buffered objects: evaluate gaps, feed decoder FSM, emit commands.
|
|
2285
|
+
*
|
|
2286
|
+
* Call this on a regular interval (e.g., requestAnimationFrame or timer).
|
|
2287
|
+
*/
|
|
2288
|
+
tick() {
|
|
2289
|
+
if (this.adaptiveTolerance) {
|
|
2290
|
+
const nowMs = this.clock.now() / 1e3;
|
|
2291
|
+
this.adaptiveTolerance.tick(nowMs);
|
|
2292
|
+
this.gapDetector.gapTimeoutUs = this.adaptiveTolerance.effectiveGapTimeoutMs * 1e3;
|
|
2293
|
+
this.sync.driftThresholdUs = this.adaptiveTolerance.effectiveDriftThresholdMs * 1e3;
|
|
2294
|
+
}
|
|
2295
|
+
if (this.resetPending && this.buffer.size > 0) {
|
|
2296
|
+
const top = this.buffer.peek();
|
|
2297
|
+
if (top.objectId !== 0n) {
|
|
2298
|
+
this.buffer.extract();
|
|
2299
|
+
this.decrementGroupCount(top.groupId);
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
this.lastConsumedGroupId = top.groupId - 1n;
|
|
2303
|
+
this.endedGroups.add(this.lastConsumedGroupId);
|
|
2304
|
+
this.resetPending = false;
|
|
2305
|
+
}
|
|
2306
|
+
const availableGroupIds = [...this.bufferedGroupCounts.keys()];
|
|
2307
|
+
const gapDecision = this.gapDetector.evaluate(this.lastConsumedGroupId, availableGroupIds);
|
|
2308
|
+
if (gapDecision.action === GapAction.TRACK_ENDED) {
|
|
2309
|
+
if (!this._trackEnded) {
|
|
2310
|
+
this._trackEnded = true;
|
|
2311
|
+
const decision = this.decoderState.notifyTrackEnded();
|
|
2312
|
+
this.emitFsmDecision(decision);
|
|
2313
|
+
this.onEvent({ type: "track_ended" });
|
|
2314
|
+
}
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
if (gapDecision.action === GapAction.SKIP_FORWARD && gapDecision.targetGroupId !== void 0) {
|
|
2318
|
+
const target = gapDecision.targetGroupId;
|
|
2319
|
+
if (this.mediaType === "video" && this.lastConsumedGroupId >= 0n && !this.endedGroups.has(this.lastConsumedGroupId)) {
|
|
2320
|
+
this.onEvent({
|
|
2321
|
+
type: "partial_group_abandoned",
|
|
2322
|
+
fromGroupId: this.lastConsumedGroupId,
|
|
2323
|
+
toGroupId: target,
|
|
2324
|
+
reason: "gap timeout"
|
|
2325
|
+
});
|
|
2326
|
+
}
|
|
2327
|
+
for (const gid of this.endedGroups) {
|
|
2328
|
+
if (gid < target) this.endedGroups.delete(gid);
|
|
2329
|
+
}
|
|
2330
|
+
this.onEvent({
|
|
2331
|
+
type: "skip_forward",
|
|
2332
|
+
fromGroupId: this.lastConsumedGroupId,
|
|
2333
|
+
toGroupId: target
|
|
2334
|
+
});
|
|
2335
|
+
if (this.recovery) {
|
|
2336
|
+
const action = this.recovery.evaluate({ type: "gap", groupId: target });
|
|
2337
|
+
this.onEvent({ type: "recovery", action });
|
|
2338
|
+
}
|
|
2339
|
+
this.minAcceptGroupId = target;
|
|
2340
|
+
this.discardBefore(target);
|
|
2341
|
+
const decision = this.decoderState.notifyGap();
|
|
2342
|
+
this.emitFsmDecision(decision);
|
|
2343
|
+
this.lastConsumedGroupId = target - 1n;
|
|
2344
|
+
this.endedGroups.add(this.lastConsumedGroupId);
|
|
2345
|
+
this.nextExpectedObjectId = 0n;
|
|
2346
|
+
this.activeGroupWaitStartUs = null;
|
|
2347
|
+
}
|
|
2348
|
+
if (this.maxBacklogGroups > 0 && this.bufferedGroupCounts.size > this.maxBacklogGroups) {
|
|
2349
|
+
this.shedBacklog();
|
|
2350
|
+
}
|
|
2351
|
+
if (this._throttled) return;
|
|
2352
|
+
if (this.mediaType === "video" && this.activeGroupWaitStartUs !== null) {
|
|
2353
|
+
const elapsed = this.clock.now() - this.activeGroupWaitStartUs;
|
|
2354
|
+
if (elapsed > this.gapDetector.gapTimeoutUs) {
|
|
2355
|
+
const nextGroupId = this.lastConsumedGroupId + 1n;
|
|
2356
|
+
this.onEvent({
|
|
2357
|
+
type: "partial_group_abandoned",
|
|
2358
|
+
fromGroupId: this.lastConsumedGroupId,
|
|
2359
|
+
toGroupId: nextGroupId,
|
|
2360
|
+
reason: "intra-group timeout"
|
|
2361
|
+
});
|
|
2362
|
+
this.endedGroups.add(this.lastConsumedGroupId);
|
|
2363
|
+
const decision = this.decoderState.notifyGap();
|
|
2364
|
+
this.emitFsmDecision(decision);
|
|
2365
|
+
this.activeGroupWaitStartUs = null;
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
let released = 0;
|
|
2369
|
+
const budget = this.maxReleasePerTick;
|
|
2370
|
+
while (this.buffer.size > 0) {
|
|
2371
|
+
if (budget > 0 && released >= budget) break;
|
|
2372
|
+
if (this._throttled) break;
|
|
2373
|
+
const top = this.buffer.peek();
|
|
2374
|
+
if (!top) break;
|
|
2375
|
+
const nextExpectedGroup = this.lastConsumedGroupId + 1n;
|
|
2376
|
+
if (top.groupId > nextExpectedGroup) {
|
|
2377
|
+
break;
|
|
2378
|
+
}
|
|
2379
|
+
if (top.groupId > this.lastConsumedGroupId && this.lastConsumedGroupId >= 0n) {
|
|
2380
|
+
if (this.mediaType === "video" && !this.endedGroups.has(this.lastConsumedGroupId)) {
|
|
2381
|
+
if (this.activeGroupWaitStartUs === null) {
|
|
2382
|
+
this.activeGroupWaitStartUs = this.clock.now();
|
|
2383
|
+
}
|
|
2384
|
+
break;
|
|
2385
|
+
}
|
|
2386
|
+
this.activeGroupWaitStartUs = null;
|
|
2387
|
+
this.endedGroups.delete(this.lastConsumedGroupId);
|
|
2388
|
+
}
|
|
2389
|
+
if (top.groupId > this.lastConsumedGroupId) {
|
|
2390
|
+
this.nextExpectedObjectId = 0n;
|
|
2391
|
+
}
|
|
2392
|
+
if (this.mediaType === "video" && top.objectId > this.nextExpectedObjectId) {
|
|
2393
|
+
const skipGroupId = top.groupId;
|
|
2394
|
+
while (this.buffer.size > 0) {
|
|
2395
|
+
const peek = this.buffer.peek();
|
|
2396
|
+
if (!peek || peek.groupId !== skipGroupId) break;
|
|
2397
|
+
this.buffer.extract();
|
|
2398
|
+
this.decrementGroupCount(skipGroupId);
|
|
2399
|
+
}
|
|
2400
|
+
this.lastConsumedGroupId = skipGroupId;
|
|
2401
|
+
this.nextExpectedObjectId = 0n;
|
|
2402
|
+
const decision = this.decoderState.notifyGap();
|
|
2403
|
+
this.emitFsmDecision(decision);
|
|
2404
|
+
break;
|
|
2405
|
+
}
|
|
2406
|
+
const obj = this.buffer.extract();
|
|
2407
|
+
this.decrementGroupCount(obj.groupId);
|
|
2408
|
+
if (obj.kind === "data") {
|
|
2409
|
+
this.processDataObject(obj);
|
|
2410
|
+
}
|
|
2411
|
+
this.nextExpectedObjectId = obj.objectId + 1n;
|
|
2412
|
+
released++;
|
|
2413
|
+
if (obj.groupId > this.lastConsumedGroupId) {
|
|
2414
|
+
this.lastConsumedGroupId = obj.groupId;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
// ─── Internal ───────────────────────────────────────────────────
|
|
2419
|
+
/**
|
|
2420
|
+
* Drop oldest whole groups when backlog exceeds maxBacklogGroups.
|
|
2421
|
+
* Keeps the newest maxBacklogGroups groups, discards the rest.
|
|
2422
|
+
* After shedding, notifies decoder that a gap occurred (needs keyframe).
|
|
2423
|
+
*/
|
|
2424
|
+
shedBacklog() {
|
|
2425
|
+
const groupIds = [...this.bufferedGroupCounts.keys()].sort(
|
|
2426
|
+
(a, b) => a < b ? -1 : a > b ? 1 : 0
|
|
2427
|
+
);
|
|
2428
|
+
const excess = groupIds.length - this.maxBacklogGroups;
|
|
2429
|
+
if (excess <= 0) return;
|
|
2430
|
+
const groupsToShed = groupIds.slice(0, excess);
|
|
2431
|
+
const cutoff = groupsToShed[groupsToShed.length - 1] + 1n;
|
|
2432
|
+
this.buffer.discardBefore(cutoff);
|
|
2433
|
+
for (const gid of groupsToShed) {
|
|
2434
|
+
this.bufferedGroupCounts.delete(gid);
|
|
2435
|
+
}
|
|
2436
|
+
for (const key of this.headerMap.keys()) {
|
|
2437
|
+
const groupId = BigInt(key.split(":")[0]);
|
|
2438
|
+
if (groupId < cutoff) this.headerMap.delete(key);
|
|
2439
|
+
}
|
|
2440
|
+
const lastShed = groupsToShed[groupsToShed.length - 1];
|
|
2441
|
+
if (lastShed >= this.lastConsumedGroupId) {
|
|
2442
|
+
this.lastConsumedGroupId = lastShed;
|
|
2443
|
+
}
|
|
2444
|
+
this.minAcceptGroupId = lastShed + 1n;
|
|
2445
|
+
const decision = this.decoderState.notifyGap();
|
|
2446
|
+
this.emitFsmDecision(decision);
|
|
2447
|
+
this.onEvent({
|
|
2448
|
+
type: "backlog_shed",
|
|
2449
|
+
droppedGroups: excess,
|
|
2450
|
+
remainingGroups: this.bufferedGroupCounts.size,
|
|
2451
|
+
reason: `backlog exceeded ${this.maxBacklogGroups} groups`
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
/**
|
|
2455
|
+
* Whether late encoded VIDEO may be discarded before it reaches the decoder.
|
|
2456
|
+
*
|
|
2457
|
+
* Only explicitly discardable, non-independent frames may be. Everything
|
|
2458
|
+
* else is NOT PROVEN SAFE TO DISCARD and is therefore preserved
|
|
2459
|
+
* conservatively: RFC 9626 §3.1 defines D=1 as the sender asserting the
|
|
2460
|
+
* frame can be removed while the stream stays decodable, so D=0 does not
|
|
2461
|
+
* prove the frame is a reference — it means the receiver has no assurance
|
|
2462
|
+
* that discarding it is safe. Absent marking is treated the same way, and
|
|
2463
|
+
* an independent frame is preserved even if it also claims to be
|
|
2464
|
+
* discardable.
|
|
2465
|
+
*
|
|
2466
|
+
* Lateness of decoded OUTPUT remains the output stage's decision.
|
|
2467
|
+
*
|
|
2468
|
+
* @see draft-ietf-moq-loc-01 §2.3.2.2 (Video Frame Marking)
|
|
2469
|
+
*/
|
|
2470
|
+
isPreDecodeDroppable(marking) {
|
|
2471
|
+
if (marking === void 0) return false;
|
|
2472
|
+
if (marking.independent) return false;
|
|
2473
|
+
return marking.discardable === true;
|
|
2474
|
+
}
|
|
2475
|
+
/**
|
|
2476
|
+
* Attempt to evict a lower-importance object to make room.
|
|
2477
|
+
* @returns true if the incoming object was inserted.
|
|
2478
|
+
* @see draft-ietf-moq-transport-16 §7 (Priority-based dropping)
|
|
2479
|
+
*/
|
|
2480
|
+
tryEvictAndInsert(obj, headers) {
|
|
2481
|
+
const evicted = this.buffer.evictLowestImportance();
|
|
2482
|
+
if (!evicted) return false;
|
|
2483
|
+
if (evicted.publisherPriority > obj.publisherPriority) {
|
|
2484
|
+
this.decrementGroupCount(evicted.groupId);
|
|
2485
|
+
const evictedKey = `${evicted.groupId}:${evicted.objectId}`;
|
|
2486
|
+
this.headerMap.delete(evictedKey);
|
|
2487
|
+
this.buffer.insert(obj);
|
|
2488
|
+
const key = `${obj.groupId}:${obj.objectId}`;
|
|
2489
|
+
this.headerMap.set(key, headers ?? {});
|
|
2490
|
+
const count = this.bufferedGroupCounts.get(obj.groupId) ?? 0;
|
|
2491
|
+
this.bufferedGroupCounts.set(obj.groupId, count + 1);
|
|
2492
|
+
return true;
|
|
2493
|
+
} else {
|
|
2494
|
+
this.buffer.insert(evicted);
|
|
2495
|
+
return false;
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
processDataObject(obj) {
|
|
2499
|
+
const key = `${obj.groupId}:${obj.objectId}`;
|
|
2500
|
+
const headers = this.headerMap.get(key) ?? {};
|
|
2501
|
+
this.headerMap.delete(key);
|
|
2502
|
+
const config = this.mediaType === "video" ? headers.videoConfig : headers.audioConfig;
|
|
2503
|
+
if (config) {
|
|
2504
|
+
const configDecision = this.decoderState.configure(config);
|
|
2505
|
+
if (configDecision.action === "configure") {
|
|
2506
|
+
this.onCommand({
|
|
2507
|
+
type: "configure",
|
|
2508
|
+
mediaType: this.mediaType,
|
|
2509
|
+
config
|
|
2510
|
+
});
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
if (headers.captureTimestamp !== void 0 && !this.sync.hasReference) {
|
|
2514
|
+
if (this.mediaType === "audio") {
|
|
2515
|
+
this.sync.setAudioReference(headers.captureTimestamp);
|
|
2516
|
+
} else if (this._videoOnly) {
|
|
2517
|
+
this.sync.setVideoReference(headers.captureTimestamp);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
const videoMarking = this.mediaType === "video" ? headers.videoFrameMarking ?? (obj.objectId === 0n ? {
|
|
2521
|
+
independent: true,
|
|
2522
|
+
startOfFrame: true,
|
|
2523
|
+
endOfFrame: true,
|
|
2524
|
+
discardable: false,
|
|
2525
|
+
baseLayerSync: false,
|
|
2526
|
+
temporalId: 0
|
|
2527
|
+
} : void 0) : void 0;
|
|
2528
|
+
let renderTimeUs;
|
|
2529
|
+
if (headers.captureTimestamp !== void 0) {
|
|
2530
|
+
const timing = this.mediaType === "video" ? this.sync.computeVideoRenderTime(headers.captureTimestamp) : this.sync.computeAudioRenderTime(headers.captureTimestamp);
|
|
2531
|
+
if (timing === null) {
|
|
2532
|
+
renderTimeUs = 0;
|
|
2533
|
+
} else if (timing.shouldDrop) {
|
|
2534
|
+
if (timing.offsetUs < -5e6) {
|
|
2535
|
+
renderTimeUs = this.clock.now();
|
|
2536
|
+
} else if (this.mediaType === "video" && !this.isPreDecodeDroppable(videoMarking)) {
|
|
2537
|
+
renderTimeUs = this.clock.now();
|
|
2538
|
+
} else {
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
} else if (timing.offsetUs > 5e6) {
|
|
2542
|
+
renderTimeUs = this.clock.now();
|
|
2543
|
+
} else {
|
|
2544
|
+
renderTimeUs = timing.renderTimeUs;
|
|
2545
|
+
}
|
|
2546
|
+
} else {
|
|
2547
|
+
renderTimeUs = this.clock.now();
|
|
2548
|
+
}
|
|
2549
|
+
if (headers.captureTimestamp !== void 0) {
|
|
2550
|
+
const catchUp = this.sync.evaluateCatchUp(headers.captureTimestamp, headers.timestampIsWallClock);
|
|
2551
|
+
if (catchUp !== null && catchUp.currentRate !== this._lastEmittedRate) {
|
|
2552
|
+
this._lastEmittedRate = catchUp.currentRate;
|
|
2553
|
+
this.onEvent({ type: "catch_up_changed", state: catchUp });
|
|
2554
|
+
this.onCommand({ type: "set_playback_rate", rate: catchUp.currentRate });
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
if (this.mediaType === "video") {
|
|
2558
|
+
const marking = videoMarking;
|
|
2559
|
+
if (marking?.independent && this._videoCodec && obj.payload.length > 5) {
|
|
2560
|
+
const isKeyframe = isKeyframePayload(this._videoCodec, obj.payload);
|
|
2561
|
+
if (isKeyframe === false) {
|
|
2562
|
+
this.onEvent({
|
|
2563
|
+
type: "keyframe_validation_failed",
|
|
2564
|
+
groupId: obj.groupId,
|
|
2565
|
+
objectId: obj.objectId,
|
|
2566
|
+
codec: this._videoCodec
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
const headersWithMarking = marking !== void 0 ? { ...headers, videoFrameMarking: marking } : headers;
|
|
2571
|
+
const chunk = toVideoChunkInit(obj.payload, headersWithMarking);
|
|
2572
|
+
const prevState = this.decoderState.state;
|
|
2573
|
+
const decision = this.decoderState.processVideoChunk(chunk, marking);
|
|
2574
|
+
if (prevState === "needs_keyframe" && this.decoderState.state === "decoding" && headers.captureTimestamp !== void 0) {
|
|
2575
|
+
this.sync.onVideoJoin(headers.captureTimestamp);
|
|
2576
|
+
const recomputed = this.sync.computeVideoRenderTime(headers.captureTimestamp);
|
|
2577
|
+
if (recomputed) {
|
|
2578
|
+
renderTimeUs = recomputed.renderTimeUs;
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
if (decision.action === "decode") {
|
|
2582
|
+
this.onCommand({
|
|
2583
|
+
type: "decode_video",
|
|
2584
|
+
chunk: decision.chunk,
|
|
2585
|
+
renderTimeUs,
|
|
2586
|
+
...headers.captureTimestamp !== void 0 ? { captureTimestampUs: headers.captureTimestamp } : {}
|
|
2587
|
+
});
|
|
2588
|
+
} else if (decision.action === "skip") {
|
|
2589
|
+
if (decision.reason === "waiting for keyframe" && obj.groupId !== this.lastKeyframeWaitingGroupId) {
|
|
2590
|
+
this.lastKeyframeWaitingGroupId = obj.groupId;
|
|
2591
|
+
this.onEvent({ type: "keyframe_waiting", groupId: obj.groupId });
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
} else {
|
|
2595
|
+
const chunk = toAudioChunkInit(obj.payload, headers);
|
|
2596
|
+
const decision = this.decoderState.processAudioChunk(chunk);
|
|
2597
|
+
if (decision.action === "decode") {
|
|
2598
|
+
this.onCommand({
|
|
2599
|
+
type: "decode_audio",
|
|
2600
|
+
chunk: decision.chunk,
|
|
2601
|
+
renderTimeUs
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
emitFsmDecision(decision) {
|
|
2607
|
+
if (decision.action === "reset") {
|
|
2608
|
+
this.onCommand({
|
|
2609
|
+
type: "reset",
|
|
2610
|
+
mediaType: this.mediaType,
|
|
2611
|
+
reason: decision.reason ?? "gap"
|
|
2612
|
+
});
|
|
2613
|
+
} else if (decision.action === "flush") {
|
|
2614
|
+
this.onCommand({
|
|
2615
|
+
type: "flush",
|
|
2616
|
+
mediaType: this.mediaType
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
discardBefore(groupId) {
|
|
2621
|
+
this.buffer.discardBefore(groupId);
|
|
2622
|
+
for (const [gid] of this.bufferedGroupCounts) {
|
|
2623
|
+
if (gid < groupId) {
|
|
2624
|
+
this.bufferedGroupCounts.delete(gid);
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
for (const [key] of this.headerMap) {
|
|
2628
|
+
const colonIdx = key.indexOf(":");
|
|
2629
|
+
const gid = BigInt(key.slice(0, colonIdx));
|
|
2630
|
+
if (gid < groupId) {
|
|
2631
|
+
this.headerMap.delete(key);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
decrementGroupCount(groupId) {
|
|
2636
|
+
const count = this.bufferedGroupCounts.get(groupId);
|
|
2637
|
+
if (count !== void 0) {
|
|
2638
|
+
if (count <= 1) {
|
|
2639
|
+
this.bufferedGroupCounts.delete(groupId);
|
|
2640
|
+
} else {
|
|
2641
|
+
this.bufferedGroupCounts.set(groupId, count - 1);
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
}
|
|
2645
|
+
};
|
|
2646
|
+
|
|
2647
|
+
// src/bandwidth-estimator.ts
|
|
2648
|
+
var BandwidthEstimator = class {
|
|
2649
|
+
alpha;
|
|
2650
|
+
minSamples;
|
|
2651
|
+
estimateBps = null;
|
|
2652
|
+
lastTimestampUs = null;
|
|
2653
|
+
lastGroupBytes = null;
|
|
2654
|
+
sampleCount = 0;
|
|
2655
|
+
/**
|
|
2656
|
+
* @param alpha EWMA smoothing factor (0-1). Higher = more responsive
|
|
2657
|
+
* to recent samples, lower = smoother. Default 0.3.
|
|
2658
|
+
* @param minSamples Minimum group arrivals before reporting a non-zero
|
|
2659
|
+
* estimate. Early samples include connection latency
|
|
2660
|
+
* and aren't representative. Default 5 (~10s at 2s GOPs).
|
|
2661
|
+
*/
|
|
2662
|
+
constructor(alpha = 0.3, minSamples = 5) {
|
|
2663
|
+
this.alpha = alpha;
|
|
2664
|
+
this.minSamples = minSamples;
|
|
2665
|
+
}
|
|
2666
|
+
/**
|
|
2667
|
+
* Record a group arrival. Call once per complete group (GOP).
|
|
2668
|
+
*
|
|
2669
|
+
* @param bytes Total bytes in the group.
|
|
2670
|
+
* @param timestampUs Arrival timestamp in microseconds.
|
|
2671
|
+
*/
|
|
2672
|
+
recordGroup(bytes, timestampUs) {
|
|
2673
|
+
if (this.lastTimestampUs !== null && this.lastGroupBytes !== null) {
|
|
2674
|
+
const deltaUs = timestampUs - this.lastTimestampUs;
|
|
2675
|
+
if (deltaUs > 0) {
|
|
2676
|
+
this.sampleCount++;
|
|
2677
|
+
const instantBps = bytes * 8 / (deltaUs / 1e6);
|
|
2678
|
+
if (this.estimateBps === null) {
|
|
2679
|
+
this.estimateBps = instantBps;
|
|
2680
|
+
} else {
|
|
2681
|
+
this.estimateBps = this.alpha * instantBps + (1 - this.alpha) * this.estimateBps;
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
this.lastTimestampUs = timestampUs;
|
|
2686
|
+
this.lastGroupBytes = bytes;
|
|
2687
|
+
}
|
|
2688
|
+
/**
|
|
2689
|
+
* Current smoothed bandwidth estimate in kbps.
|
|
2690
|
+
* Returns 0 if fewer than 2 groups have been recorded.
|
|
2691
|
+
*/
|
|
2692
|
+
getEstimateKbps() {
|
|
2693
|
+
if (this.estimateBps === null || this.sampleCount < this.minSamples) return 0;
|
|
2694
|
+
return this.estimateBps / 1e3;
|
|
2695
|
+
}
|
|
2696
|
+
/** Reset all state. */
|
|
2697
|
+
reset() {
|
|
2698
|
+
this.estimateBps = null;
|
|
2699
|
+
this.lastTimestampUs = null;
|
|
2700
|
+
this.lastGroupBytes = null;
|
|
2701
|
+
this.sampleCount = 0;
|
|
2702
|
+
}
|
|
2703
|
+
};
|
|
2704
|
+
|
|
2705
|
+
// src/buffer-based-controller.ts
|
|
2706
|
+
var BufferBasedController = class {
|
|
2707
|
+
ladder;
|
|
2708
|
+
lowThresholdUs;
|
|
2709
|
+
/** Kept for future temporal-buffer mode (playout buffer, not jitter buffer). */
|
|
2710
|
+
highThresholdUs;
|
|
2711
|
+
upshiftMultiple;
|
|
2712
|
+
currentIndex;
|
|
2713
|
+
constructor(config) {
|
|
2714
|
+
this.ladder = config.ladder;
|
|
2715
|
+
this.lowThresholdUs = config.lowThresholdUs;
|
|
2716
|
+
this.highThresholdUs = config.highThresholdUs;
|
|
2717
|
+
this.upshiftMultiple = config.upshiftBandwidthMultiple ?? 2.25;
|
|
2718
|
+
this.currentIndex = config.initialIndex;
|
|
2719
|
+
}
|
|
2720
|
+
/** Current ladder index. */
|
|
2721
|
+
get index() {
|
|
2722
|
+
return this.currentIndex;
|
|
2723
|
+
}
|
|
2724
|
+
/** Current track. */
|
|
2725
|
+
get currentTrack() {
|
|
2726
|
+
return this.ladder[this.currentIndex];
|
|
2727
|
+
}
|
|
2728
|
+
/**
|
|
2729
|
+
* Evaluate buffer depth + bandwidth and return an ABR decision.
|
|
2730
|
+
*
|
|
2731
|
+
* Called periodically (e.g., every tick or every group arrival).
|
|
2732
|
+
*/
|
|
2733
|
+
/**
|
|
2734
|
+
* Evaluate buffer depth + bandwidth and return an ABR recommendation.
|
|
2735
|
+
*
|
|
2736
|
+
* Does NOT mutate internal state — the caller must call
|
|
2737
|
+
* {@link commitDownshift} or {@link commitUpshift} after a
|
|
2738
|
+
* confirmed switch. This prevents index drift when async switches
|
|
2739
|
+
* fail or are aborted.
|
|
2740
|
+
*/
|
|
2741
|
+
evaluate(signals) {
|
|
2742
|
+
if (signals.bufferDepthUs < this.lowThresholdUs) {
|
|
2743
|
+
if (this.currentIndex < this.ladder.length - 1) {
|
|
2744
|
+
return { action: "downshift", targetIndex: this.currentIndex + 1 };
|
|
2745
|
+
}
|
|
2746
|
+
return { action: "hold" };
|
|
2747
|
+
}
|
|
2748
|
+
if (this.currentIndex > 0 && signals.bufferDepthUs >= this.highThresholdUs) {
|
|
2749
|
+
const higherTrack = this.ladder[this.currentIndex - 1];
|
|
2750
|
+
if (signals.bandwidthEstimateKbps > higherTrack.bitrateKbps * this.upshiftMultiple) {
|
|
2751
|
+
return { action: "upshift", targetIndex: this.currentIndex - 1 };
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
return { action: "hold" };
|
|
2755
|
+
}
|
|
2756
|
+
/** Confirm a downshift — advances index to the lower track. */
|
|
2757
|
+
commitDownshift() {
|
|
2758
|
+
if (this.currentIndex < this.ladder.length - 1) this.currentIndex++;
|
|
2759
|
+
}
|
|
2760
|
+
/** Confirm an upshift — moves index to the higher track. */
|
|
2761
|
+
commitUpshift() {
|
|
2762
|
+
if (this.currentIndex > 0) this.currentIndex--;
|
|
2763
|
+
}
|
|
2764
|
+
/** Reset to a specific ladder index (e.g., after manual selection). */
|
|
2765
|
+
setIndex(index) {
|
|
2766
|
+
this.currentIndex = Math.max(0, Math.min(index, this.ladder.length - 1));
|
|
2767
|
+
}
|
|
2768
|
+
};
|
|
2769
|
+
export {
|
|
2770
|
+
AdaptiveToleranceController,
|
|
2771
|
+
BandwidthEstimator,
|
|
2772
|
+
BufferBasedController,
|
|
2773
|
+
DEFAULT_TOLERANCE_CONFIG,
|
|
2774
|
+
DecoderState,
|
|
2775
|
+
DecoderStateMachine,
|
|
2776
|
+
DefaultRecoveryController,
|
|
2777
|
+
GapAction,
|
|
2778
|
+
GapDetector,
|
|
2779
|
+
JitterBuffer,
|
|
2780
|
+
PlaybackPipeline,
|
|
2781
|
+
RecoveryPhase,
|
|
2782
|
+
RenderScheduler,
|
|
2783
|
+
StartupBuffer,
|
|
2784
|
+
SyncController,
|
|
2785
|
+
classifyNetwork,
|
|
2786
|
+
isKeyframePayload
|
|
2787
|
+
};
|
|
2788
|
+
//# sourceMappingURL=index.js.map
|