@openmoq/playback 0.0.0-stage → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playback pipeline orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Wires jitter-buffer + gap-detector + decoder-state + sync together.
|
|
5
|
+
* Accepts MoqtObject + LocHeaders, emits DecoderCommand + PlaybackEvent.
|
|
6
|
+
*
|
|
7
|
+
* Sans-I/O: no WebCodecs, no Canvas, no AudioContext — just pure logic.
|
|
8
|
+
* The browser adapter consumes the emitted DecoderCommands.
|
|
9
|
+
*
|
|
10
|
+
* @see draft-ietf-moq-loc-01 §4.2 (decode order)
|
|
11
|
+
* @see draft-ietf-moq-transport-16 §10.2.1.1 (Object Status)
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import { ObjectStatus } from '@openmoq/transport';
|
|
15
|
+
import { toVideoChunkInit, toAudioChunkInit } from '@openmoq/loc';
|
|
16
|
+
import { isKeyframePayload } from './keyframe-validator.js';
|
|
17
|
+
import { JitterBuffer } from './jitter-buffer.js';
|
|
18
|
+
import { GapDetector, GapAction } from './gap-detector.js';
|
|
19
|
+
import { DecoderStateMachine } from './decoder-state.js';
|
|
20
|
+
import { SyncController } from './sync.js';
|
|
21
|
+
import { AdaptiveToleranceController, DEFAULT_TOLERANCE_CONFIG } from './adaptive-tolerance.js';
|
|
22
|
+
/**
|
|
23
|
+
* Playback pipeline orchestrator.
|
|
24
|
+
*
|
|
25
|
+
* @see draft-ietf-moq-loc-01 §4.2
|
|
26
|
+
*/
|
|
27
|
+
export class PlaybackPipeline {
|
|
28
|
+
mediaType;
|
|
29
|
+
clock;
|
|
30
|
+
sync;
|
|
31
|
+
onCommand;
|
|
32
|
+
onEvent;
|
|
33
|
+
recovery;
|
|
34
|
+
buffer;
|
|
35
|
+
gapDetector;
|
|
36
|
+
decoderState;
|
|
37
|
+
/** Headers stored alongside objects, keyed by "groupId:objectId". */
|
|
38
|
+
headerMap = new Map();
|
|
39
|
+
/** Group reference counts in the jitter buffer. */
|
|
40
|
+
bufferedGroupCounts = new Map();
|
|
41
|
+
/** Next expected objectId within the current group for contiguity check. */
|
|
42
|
+
nextExpectedObjectId = 0n;
|
|
43
|
+
/** Highest group from which at least one object was consumed. -1n = nothing consumed yet. */
|
|
44
|
+
lastConsumedGroupId = -1n;
|
|
45
|
+
/** Groups for which END_OF_GROUP has been observed. */
|
|
46
|
+
endedGroups = new Set();
|
|
47
|
+
/** Timestamp when we first started waiting for active group completion. */
|
|
48
|
+
activeGroupWaitStartUs = null;
|
|
49
|
+
/**
|
|
50
|
+
* Minimum group ID accepted by pushObject().
|
|
51
|
+
* Set after SKIP_FORWARD to reject stale objects from pre-skip groups
|
|
52
|
+
* that arrive late due to async QUIC stream delivery.
|
|
53
|
+
*/
|
|
54
|
+
minAcceptGroupId = -1n;
|
|
55
|
+
/** Last group for which keyframe_waiting was emitted (dedup). */
|
|
56
|
+
lastKeyframeWaitingGroupId = -1n;
|
|
57
|
+
/**
|
|
58
|
+
* When true, the first tick syncs to whatever group is available
|
|
59
|
+
* without gap detection — avoids cascading recovery on initial
|
|
60
|
+
* live join or after pause→resume on a live stream.
|
|
61
|
+
*
|
|
62
|
+
* Starts `true` so the first arriving group is accepted as the
|
|
63
|
+
* starting point (joining mid-broadcast is not a gap).
|
|
64
|
+
*/
|
|
65
|
+
resetPending = true;
|
|
66
|
+
/** True once track_ended has been emitted — prevents re-emission on every tick. */
|
|
67
|
+
_trackEnded = false;
|
|
68
|
+
/** Last emitted playback rate — dedup to avoid spamming commands. */
|
|
69
|
+
_lastEmittedRate = 1.0;
|
|
70
|
+
/**
|
|
71
|
+
* Throttle flag — set by decoder feedback when queue depth is high.
|
|
72
|
+
* While true, tick() evaluates gaps but does NOT drain the buffer.
|
|
73
|
+
* Objects still enter the jitter buffer (needed for gap detection).
|
|
74
|
+
*/
|
|
75
|
+
_throttled = false;
|
|
76
|
+
/** Adaptive jitter tolerance controller (null if disabled). */
|
|
77
|
+
adaptiveTolerance;
|
|
78
|
+
/** Max objects to release per tick (video only). 0 = unlimited. */
|
|
79
|
+
maxReleasePerTick;
|
|
80
|
+
/** Max groups before shedding old ones. 0 = unlimited. */
|
|
81
|
+
maxBacklogGroups;
|
|
82
|
+
/** Video codec string from catalog (e.g., 'avc1.42c01f'). Used for keyframe validation. */
|
|
83
|
+
_videoCodec;
|
|
84
|
+
_videoOnly;
|
|
85
|
+
constructor(opts) {
|
|
86
|
+
this.mediaType = opts.mediaType;
|
|
87
|
+
this.clock = opts.clock;
|
|
88
|
+
this.sync = opts.sync;
|
|
89
|
+
this.onCommand = opts.onCommand;
|
|
90
|
+
this.onEvent = opts.onEvent;
|
|
91
|
+
this.recovery = opts.recovery;
|
|
92
|
+
this._videoOnly = opts.videoOnly ?? false;
|
|
93
|
+
// Bounded release: live video defaults to 5 objects/tick + 3 max
|
|
94
|
+
// backlog groups. VOD/non-live and audio are unlimited — shedding
|
|
95
|
+
// old groups is wrong when you need the full timeline.
|
|
96
|
+
const liveVideo = opts.mediaType === 'video' && (opts.isLive ?? true);
|
|
97
|
+
this.maxReleasePerTick = liveVideo
|
|
98
|
+
? (opts.config.maxReleasePerTick ?? 5)
|
|
99
|
+
: 0;
|
|
100
|
+
this.maxBacklogGroups = liveVideo
|
|
101
|
+
? (opts.config.maxBacklogGroups ?? 3)
|
|
102
|
+
: 0;
|
|
103
|
+
this.buffer = new JitterBuffer(opts.config.maxBufferDepth);
|
|
104
|
+
this.gapDetector = new GapDetector({
|
|
105
|
+
gapTimeoutUs: opts.config.gapTimeoutUs,
|
|
106
|
+
clock: opts.clock,
|
|
107
|
+
});
|
|
108
|
+
this.decoderState = new DecoderStateMachine(opts.mediaType);
|
|
109
|
+
// Adaptive tolerance: auto-calibrate gap timeout and drift threshold
|
|
110
|
+
this.adaptiveTolerance = opts.config.adaptiveTolerance
|
|
111
|
+
? new AdaptiveToleranceController(DEFAULT_TOLERANCE_CONFIG)
|
|
112
|
+
: null;
|
|
113
|
+
// Startup buffer not used — video join re-anchor handles startup timing.
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Set the video codec string for keyframe payload validation.
|
|
117
|
+
*
|
|
118
|
+
* @param codec Codec string from catalog (e.g., 'avc1.42c01f', 'hvc1', 'av01.0.08M.10')
|
|
119
|
+
* @see draft-ietf-moq-loc-01 §4.2 (decode order)
|
|
120
|
+
*/
|
|
121
|
+
setCodec(codec) {
|
|
122
|
+
this._videoCodec = codec;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Current effective gap timeout in microseconds.
|
|
126
|
+
* When adaptive tolerance is enabled, this is auto-calibrated from jitter.
|
|
127
|
+
* Otherwise, returns the fixed configured value.
|
|
128
|
+
*/
|
|
129
|
+
get effectiveGapTimeoutUs() {
|
|
130
|
+
if (this.adaptiveTolerance) {
|
|
131
|
+
return this.adaptiveTolerance.effectiveGapTimeoutMs * 1000;
|
|
132
|
+
}
|
|
133
|
+
return this.gapDetector.gapTimeoutUs;
|
|
134
|
+
}
|
|
135
|
+
/** Last consumed group ID — for track switch subscription filters. */
|
|
136
|
+
get currentGroupId() {
|
|
137
|
+
return this.lastConsumedGroupId;
|
|
138
|
+
}
|
|
139
|
+
/** Number of distinct groups currently in the jitter buffer. */
|
|
140
|
+
get bufferedGroupCount() {
|
|
141
|
+
return this.bufferedGroupCounts.size;
|
|
142
|
+
}
|
|
143
|
+
/** Whether draining is throttled due to decoder backpressure. */
|
|
144
|
+
get throttled() {
|
|
145
|
+
return this._throttled;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Handle feedback from browser adapters (decoder queue, errors, drift).
|
|
149
|
+
*
|
|
150
|
+
* @see draft-ietf-moq-loc-01 §2.3.1.1 (drift detection)
|
|
151
|
+
*/
|
|
152
|
+
handleFeedback(fb) {
|
|
153
|
+
switch (fb.type) {
|
|
154
|
+
case 'queue_pressure':
|
|
155
|
+
this._throttled = fb.depth >= fb.maxRecommended;
|
|
156
|
+
break;
|
|
157
|
+
case 'decode_error':
|
|
158
|
+
// Clear throttle — the decoder has likely been recreated with an
|
|
159
|
+
// empty queue. Without this, the pipeline deadlocks: throttled →
|
|
160
|
+
// no decode commands → no decoder output → no queue_pressure_low
|
|
161
|
+
// feedback → throttled forever.
|
|
162
|
+
this._throttled = false;
|
|
163
|
+
// Reset decoder FSM — the browser adapter has recreated the
|
|
164
|
+
// WebCodecs decoder with an empty queue. Video must wait for
|
|
165
|
+
// the next keyframe; audio resumes immediately (all key).
|
|
166
|
+
// Without this, the FSM stays in DECODING and feeds delta
|
|
167
|
+
// frames to a fresh decoder with no keyframe context → silent
|
|
168
|
+
// failures → permanent stall.
|
|
169
|
+
{
|
|
170
|
+
const decision = this.decoderState.notifyGap();
|
|
171
|
+
this.emitFsmDecision(decision);
|
|
172
|
+
}
|
|
173
|
+
if (this.recovery) {
|
|
174
|
+
const action = this.recovery.evaluate({ type: 'decode_error', message: fb.message });
|
|
175
|
+
this.onEvent({ type: 'recovery', action });
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
case 'frame_rendered':
|
|
179
|
+
// PRESENTATION SCHEDULE drift: actual minus the exact time this
|
|
180
|
+
// frame was queued to present. Both are on the SAME local clock,
|
|
181
|
+
// so the old PTS-era guards no longer apply and are deliberately
|
|
182
|
+
// gone: a capture timestamp is not required, an active video-join
|
|
183
|
+
// offset is already represented in the schedule, and there is no
|
|
184
|
+
// cross-epoch case to hide.
|
|
185
|
+
//
|
|
186
|
+
// Without a schedule the whole measurement/event transaction is
|
|
187
|
+
// suppressed — returning BEFORE reading drift, so a stale value
|
|
188
|
+
// from an earlier frame cannot be re-emitted as a new event.
|
|
189
|
+
if (fb.scheduledRenderUs !== undefined) {
|
|
190
|
+
this.sync.reportPresentationTiming(fb.scheduledRenderUs, fb.actualRenderUs);
|
|
191
|
+
const drift = this.sync.currentDriftUs;
|
|
192
|
+
if (this.sync.presentationDriftExceeded) {
|
|
193
|
+
this.onEvent({ type: 'sync_drift', driftUs: drift });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
case 'flush_complete':
|
|
198
|
+
// No-op — included for forward compatibility (quality switch gating).
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Provide codec configuration (from catalog or initial headers).
|
|
204
|
+
*
|
|
205
|
+
* This is also triggered automatically when a LOC object carries the
|
|
206
|
+
* matching config property: Video Config for video, Audio Config for
|
|
207
|
+
* audio.
|
|
208
|
+
*
|
|
209
|
+
* @param config Codec-specific configuration bytes
|
|
210
|
+
*/
|
|
211
|
+
configure(config) {
|
|
212
|
+
const decision = this.decoderState.configure(config);
|
|
213
|
+
if (decision.action === 'configure') {
|
|
214
|
+
this.onCommand({
|
|
215
|
+
type: 'configure',
|
|
216
|
+
mediaType: this.mediaType,
|
|
217
|
+
config,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Push an object into the pipeline.
|
|
223
|
+
*
|
|
224
|
+
* Data objects are buffered. Gap objects notify the gap detector.
|
|
225
|
+
*
|
|
226
|
+
* @param obj The MoqtObject (data or gap)
|
|
227
|
+
* @param headers Parsed LocHeaders (only meaningful for data objects)
|
|
228
|
+
*/
|
|
229
|
+
pushObject(obj, headers) {
|
|
230
|
+
if (obj.kind === 'gap') {
|
|
231
|
+
// Gap signals — notify gap detector
|
|
232
|
+
if (obj.status === ObjectStatus.END_OF_GROUP) {
|
|
233
|
+
this.gapDetector.observeEndOfGroup(obj.groupId);
|
|
234
|
+
this.endedGroups.add(obj.groupId);
|
|
235
|
+
}
|
|
236
|
+
else if (obj.status === ObjectStatus.END_OF_TRACK) {
|
|
237
|
+
this.gapDetector.observeEndOfTrack();
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// Reject stale objects: both from before skip/reset targets AND
|
|
242
|
+
// from groups already consumed during normal forward progress.
|
|
243
|
+
// Late-arriving old groups from QUIC reordering can poison decode
|
|
244
|
+
// order and cause blockiness.
|
|
245
|
+
if (obj.groupId < this.minAcceptGroupId)
|
|
246
|
+
return;
|
|
247
|
+
if (this.lastConsumedGroupId >= 0n && obj.groupId < this.lastConsumedGroupId)
|
|
248
|
+
return;
|
|
249
|
+
// Reject already-consumed objects from the current group.
|
|
250
|
+
if (obj.groupId === this.lastConsumedGroupId && obj.objectId < this.nextExpectedObjectId)
|
|
251
|
+
return;
|
|
252
|
+
// Dedup: reject if this (groupId, objectId) is already buffered.
|
|
253
|
+
const key = `${obj.groupId}:${obj.objectId}`;
|
|
254
|
+
if (this.headerMap.has(key))
|
|
255
|
+
return;
|
|
256
|
+
// Data object — try to buffer it
|
|
257
|
+
if (this.buffer.insert(obj)) {
|
|
258
|
+
this.headerMap.set(key, headers ?? {});
|
|
259
|
+
const count = this.bufferedGroupCounts.get(obj.groupId) ?? 0;
|
|
260
|
+
this.bufferedGroupCounts.set(obj.groupId, count + 1);
|
|
261
|
+
}
|
|
262
|
+
else if (obj.publisherPriority !== undefined) {
|
|
263
|
+
// Buffer full — try priority-aware eviction (§7)
|
|
264
|
+
if (!this.tryEvictAndInsert(obj, headers) && this.recovery) {
|
|
265
|
+
this.onEvent({
|
|
266
|
+
type: 'recovery',
|
|
267
|
+
action: this.recovery.evaluate({ type: 'buffer_overflow' }),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else if (this.recovery) {
|
|
272
|
+
// No priority info, can't evict — notify recovery
|
|
273
|
+
this.onEvent({
|
|
274
|
+
type: 'recovery',
|
|
275
|
+
action: this.recovery.evaluate({ type: 'buffer_overflow' }),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// Interarrival jitter uses transit differences, so the timestamp epoch
|
|
279
|
+
// cancels out. Media time is suitable here, but not for latency/catch-up.
|
|
280
|
+
if (this.adaptiveTolerance && headers?.captureTimestamp !== undefined) {
|
|
281
|
+
const nowMs = this.clock.now() / 1000; // µs → ms
|
|
282
|
+
const captureUs = Number(headers.captureTimestamp);
|
|
283
|
+
this.adaptiveTolerance.onFrameArrived(nowMs, captureUs, nowMs);
|
|
284
|
+
}
|
|
285
|
+
// Observe group for gap timeout tracking
|
|
286
|
+
this.gapDetector.observeGroup(obj.groupId);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Reset pipeline state for pause→resume or seek.
|
|
290
|
+
*
|
|
291
|
+
* Clears the jitter buffer, gap detector, and decoder FSM so the first
|
|
292
|
+
* arriving group after resume is accepted cleanly without cascading
|
|
293
|
+
* gap-recovery escalation from the stale pre-pause position.
|
|
294
|
+
*
|
|
295
|
+
* Emits a decoder 'reset' command so the WebCodecs decoder clears its
|
|
296
|
+
* queue and waits for the next keyframe (video) or resumes immediately
|
|
297
|
+
* (audio — Opus/AAC-LC frames are independently decodable).
|
|
298
|
+
*
|
|
299
|
+
* @param targetGroupId When seeking, set this to the target group ID.
|
|
300
|
+
* Objects from groups before this ID are rejected — prevents stale
|
|
301
|
+
* in-flight objects from corrupting the post-seek decoder state.
|
|
302
|
+
* @see draft-ietf-moq-transport-16 §9.11.1 — "it might still receive
|
|
303
|
+
* Objects outside the new range if the publisher sent them before the
|
|
304
|
+
* update was processed."
|
|
305
|
+
*/
|
|
306
|
+
/**
|
|
307
|
+
* Reset for a track switch — clears jitter buffer (old-track objects
|
|
308
|
+
* use the same groupIds as new-track per altGroup alignment), resets
|
|
309
|
+
* gap detector and group tracking. Does NOT reset decoder state or
|
|
310
|
+
* emit flush/reset commands — already-decoded frames in the renderer
|
|
311
|
+
* queue play out naturally for seamless visual transition.
|
|
312
|
+
*
|
|
313
|
+
* @param firstGroupId The first group ID from the new track — sets
|
|
314
|
+
* lastConsumedGroupId to firstGroupId-1 so the gap detector doesn't
|
|
315
|
+
* see a gap and trigger skip_forward (which would reset sync).
|
|
316
|
+
*/
|
|
317
|
+
resetForTrackSwitch(firstGroupId) {
|
|
318
|
+
// Clear the jitter buffer — old-track encoded objects must not
|
|
319
|
+
// survive into the new decoder configuration. Without this,
|
|
320
|
+
// stale P-frames from the old track drain under the new codec
|
|
321
|
+
// and produce decode errors.
|
|
322
|
+
this.buffer.clear();
|
|
323
|
+
this.headerMap.clear();
|
|
324
|
+
this.bufferedGroupCounts.clear();
|
|
325
|
+
this.lastConsumedGroupId = firstGroupId - 1n;
|
|
326
|
+
this.endedGroups.add(this.lastConsumedGroupId);
|
|
327
|
+
this.nextExpectedObjectId = 0n;
|
|
328
|
+
this.minAcceptGroupId = firstGroupId;
|
|
329
|
+
this.lastKeyframeWaitingGroupId = -1n;
|
|
330
|
+
this._throttled = false;
|
|
331
|
+
this.activeGroupWaitStartUs = null;
|
|
332
|
+
this.gapDetector.reset();
|
|
333
|
+
// Force decoder back to NEEDS_KEYFRAME so partial-group deltas
|
|
334
|
+
// from a mid-stream subscription are skipped, not decoded.
|
|
335
|
+
const decision = this.decoderState.notifyGap();
|
|
336
|
+
this.emitFsmDecision(decision);
|
|
337
|
+
}
|
|
338
|
+
reset(targetGroupId) {
|
|
339
|
+
this.buffer.clear();
|
|
340
|
+
this.headerMap.clear();
|
|
341
|
+
this.bufferedGroupCounts.clear();
|
|
342
|
+
this.lastConsumedGroupId = -1n;
|
|
343
|
+
this.nextExpectedObjectId = 0n;
|
|
344
|
+
this.minAcceptGroupId = targetGroupId ?? -1n;
|
|
345
|
+
this.lastKeyframeWaitingGroupId = -1n;
|
|
346
|
+
this.resetPending = true;
|
|
347
|
+
this._throttled = false;
|
|
348
|
+
this._trackEnded = false;
|
|
349
|
+
this.endedGroups.clear();
|
|
350
|
+
this.activeGroupWaitStartUs = null;
|
|
351
|
+
this.gapDetector.reset();
|
|
352
|
+
this.adaptiveTolerance?.reset();
|
|
353
|
+
const decision = this.decoderState.notifyGap();
|
|
354
|
+
this.emitFsmDecision(decision);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Process buffered objects: evaluate gaps, feed decoder FSM, emit commands.
|
|
358
|
+
*
|
|
359
|
+
* Call this on a regular interval (e.g., requestAnimationFrame or timer).
|
|
360
|
+
*/
|
|
361
|
+
tick() {
|
|
362
|
+
// Update adaptive tolerance → push auto-calibrated thresholds to gap detector and sync
|
|
363
|
+
if (this.adaptiveTolerance) {
|
|
364
|
+
const nowMs = this.clock.now() / 1000;
|
|
365
|
+
this.adaptiveTolerance.tick(nowMs);
|
|
366
|
+
this.gapDetector.gapTimeoutUs = this.adaptiveTolerance.effectiveGapTimeoutMs * 1000;
|
|
367
|
+
this.sync.driftThresholdUs = this.adaptiveTolerance.effectiveDriftThresholdMs * 1000;
|
|
368
|
+
}
|
|
369
|
+
// After reset (pause→resume, jump-to-live), sync up to the first
|
|
370
|
+
// group that starts with a keyframe (objectId 0). Stale P-frames
|
|
371
|
+
// from the old subscription that arrive after the reset must not
|
|
372
|
+
// become the new starting point — they lack keyframe context and
|
|
373
|
+
// would produce artifacts.
|
|
374
|
+
if (this.resetPending && this.buffer.size > 0) {
|
|
375
|
+
const top = this.buffer.peek();
|
|
376
|
+
// Only accept a group that starts with objectId 0 (keyframe).
|
|
377
|
+
// Stale mid-group objects from the old subscription are
|
|
378
|
+
// discarded by extracting + dropping them.
|
|
379
|
+
if (top.objectId !== 0n) {
|
|
380
|
+
this.buffer.extract();
|
|
381
|
+
this.decrementGroupCount(top.groupId);
|
|
382
|
+
return; // wait for a keyframe-starting group
|
|
383
|
+
}
|
|
384
|
+
this.lastConsumedGroupId = top.groupId - 1n;
|
|
385
|
+
this.endedGroups.add(this.lastConsumedGroupId);
|
|
386
|
+
this.resetPending = false;
|
|
387
|
+
}
|
|
388
|
+
// 1. Evaluate gap state
|
|
389
|
+
const availableGroupIds = [...this.bufferedGroupCounts.keys()];
|
|
390
|
+
const gapDecision = this.gapDetector.evaluate(this.lastConsumedGroupId, availableGroupIds);
|
|
391
|
+
// 2. Handle gap decision
|
|
392
|
+
if (gapDecision.action === GapAction.TRACK_ENDED) {
|
|
393
|
+
if (!this._trackEnded) {
|
|
394
|
+
this._trackEnded = true;
|
|
395
|
+
const decision = this.decoderState.notifyTrackEnded();
|
|
396
|
+
this.emitFsmDecision(decision);
|
|
397
|
+
this.onEvent({ type: 'track_ended' });
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (gapDecision.action === GapAction.SKIP_FORWARD && gapDecision.targetGroupId !== undefined) {
|
|
402
|
+
const target = gapDecision.targetGroupId;
|
|
403
|
+
// Emit partial_group_abandoned if the current video group wasn't
|
|
404
|
+
// ended normally. This makes the transition observable for diagnostics.
|
|
405
|
+
if (this.mediaType === 'video'
|
|
406
|
+
&& this.lastConsumedGroupId >= 0n
|
|
407
|
+
&& !this.endedGroups.has(this.lastConsumedGroupId)) {
|
|
408
|
+
this.onEvent({
|
|
409
|
+
type: 'partial_group_abandoned',
|
|
410
|
+
fromGroupId: this.lastConsumedGroupId,
|
|
411
|
+
toGroupId: target,
|
|
412
|
+
reason: 'gap timeout',
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
// Clean up ended-group tracking for skipped groups
|
|
416
|
+
for (const gid of this.endedGroups) {
|
|
417
|
+
if (gid < target)
|
|
418
|
+
this.endedGroups.delete(gid);
|
|
419
|
+
}
|
|
420
|
+
this.onEvent({
|
|
421
|
+
type: 'skip_forward',
|
|
422
|
+
fromGroupId: this.lastConsumedGroupId,
|
|
423
|
+
toGroupId: target,
|
|
424
|
+
});
|
|
425
|
+
// Notify recovery controller for potential escalation
|
|
426
|
+
if (this.recovery) {
|
|
427
|
+
const action = this.recovery.evaluate({ type: 'gap', groupId: target });
|
|
428
|
+
this.onEvent({ type: 'recovery', action });
|
|
429
|
+
}
|
|
430
|
+
// Reject future pushObject() calls for groups before the target
|
|
431
|
+
this.minAcceptGroupId = target;
|
|
432
|
+
// Discard old groups from buffer
|
|
433
|
+
this.discardBefore(target);
|
|
434
|
+
// NOTE: Sync reset is NOT done here. When two pipelines (audio + video)
|
|
435
|
+
// share a SyncController, both would call reset() during skip_forward,
|
|
436
|
+
// causing a double-reset: audio resets → re-establishes reference →
|
|
437
|
+
// video resets → wipes reference. The caller (MoqtPlayer) coordinates
|
|
438
|
+
// sync reset via handlePipelineEvent, resetting once per tick cycle.
|
|
439
|
+
// Notify decoder of gap
|
|
440
|
+
const decision = this.decoderState.notifyGap();
|
|
441
|
+
this.emitFsmDecision(decision);
|
|
442
|
+
// Advance consumed position so we start from the target group
|
|
443
|
+
this.lastConsumedGroupId = target - 1n;
|
|
444
|
+
this.endedGroups.add(this.lastConsumedGroupId);
|
|
445
|
+
this.nextExpectedObjectId = 0n;
|
|
446
|
+
this.activeGroupWaitStartUs = null;
|
|
447
|
+
}
|
|
448
|
+
// 3. Backlog shedding: runs BEFORE throttle check so backlog is
|
|
449
|
+
// controlled even when decoder pressure is high. Without this,
|
|
450
|
+
// objects keep entering via pushObject() while throttled, and the
|
|
451
|
+
// shedding path never executes — defeating "move the control
|
|
452
|
+
// point earlier."
|
|
453
|
+
if (this.maxBacklogGroups > 0 && this.bufferedGroupCounts.size > this.maxBacklogGroups) {
|
|
454
|
+
this.shedBacklog();
|
|
455
|
+
}
|
|
456
|
+
// 4. Throttle: if decoder queue is deep, skip draining.
|
|
457
|
+
// Objects still enter via pushObject() (needed for gap detection).
|
|
458
|
+
// Un-throttle signals come from CommandDispatcher on frame output —
|
|
459
|
+
// as the decoder drains its queue, checkQueuePressure() fires on each
|
|
460
|
+
// decoded frame, crossing the low threshold to clear the throttle.
|
|
461
|
+
// @see draft-ietf-moq-transport-16 §7
|
|
462
|
+
if (this._throttled)
|
|
463
|
+
return;
|
|
464
|
+
// 4b. Intra-group timeout: abandon a partially-consumed video group
|
|
465
|
+
// when its suffix never arrives. The gap detector handles inter-group
|
|
466
|
+
// gaps (N+2 present, N+1 missing). This handles the case where N+1
|
|
467
|
+
// is present but N is incomplete — the gap detector won't fire because
|
|
468
|
+
// the next group IS available.
|
|
469
|
+
if (this.mediaType === 'video' && this.activeGroupWaitStartUs !== null) {
|
|
470
|
+
const elapsed = this.clock.now() - this.activeGroupWaitStartUs;
|
|
471
|
+
if (elapsed > this.gapDetector.gapTimeoutUs) {
|
|
472
|
+
const nextGroupId = this.lastConsumedGroupId + 1n;
|
|
473
|
+
this.onEvent({
|
|
474
|
+
type: 'partial_group_abandoned',
|
|
475
|
+
fromGroupId: this.lastConsumedGroupId,
|
|
476
|
+
toGroupId: nextGroupId,
|
|
477
|
+
reason: 'intra-group timeout',
|
|
478
|
+
});
|
|
479
|
+
this.endedGroups.add(this.lastConsumedGroupId);
|
|
480
|
+
const decision = this.decoderState.notifyGap();
|
|
481
|
+
this.emitFsmDecision(decision);
|
|
482
|
+
this.activeGroupWaitStartUs = null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
// 5. Release contiguous objects from the buffer (bounded).
|
|
486
|
+
// Within a group, objects MUST be contiguous (0, 1, 2, ...) before
|
|
487
|
+
// decoding. A missing objectId means a frame the decoder needs for
|
|
488
|
+
// reference is absent — decoding later objects produces artifacts.
|
|
489
|
+
// Wait for the missing object (QUIC delivers in-order per stream)
|
|
490
|
+
// or let the gap detector timeout and skip the group.
|
|
491
|
+
let released = 0;
|
|
492
|
+
const budget = this.maxReleasePerTick;
|
|
493
|
+
while (this.buffer.size > 0) {
|
|
494
|
+
// Bounded release: stop after budget objects (0 = unlimited).
|
|
495
|
+
if (budget > 0 && released >= budget)
|
|
496
|
+
break;
|
|
497
|
+
// Mid-tick throttle: decoder feedback during processDataObject
|
|
498
|
+
// may set _throttled via handleFeedback(queue_pressure).
|
|
499
|
+
if (this._throttled)
|
|
500
|
+
break;
|
|
501
|
+
const top = this.buffer.peek();
|
|
502
|
+
if (!top)
|
|
503
|
+
break;
|
|
504
|
+
const nextExpectedGroup = this.lastConsumedGroupId + 1n;
|
|
505
|
+
// Don't drain past the next expected group — there might be a gap
|
|
506
|
+
if (top.groupId > nextExpectedGroup) {
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
// New group boundary: for video, don't silently transition from
|
|
510
|
+
// a partial group. If the current group wasn't ended (END_OF_GROUP),
|
|
511
|
+
// wait for the intra-group timeout to explicitly abandon it.
|
|
512
|
+
// Audio is independently decodable — transitions are always safe.
|
|
513
|
+
if (top.groupId > this.lastConsumedGroupId && this.lastConsumedGroupId >= 0n) {
|
|
514
|
+
if (this.mediaType === 'video' && !this.endedGroups.has(this.lastConsumedGroupId)) {
|
|
515
|
+
if (this.activeGroupWaitStartUs === null) {
|
|
516
|
+
this.activeGroupWaitStartUs = this.clock.now();
|
|
517
|
+
}
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
this.activeGroupWaitStartUs = null;
|
|
521
|
+
this.endedGroups.delete(this.lastConsumedGroupId);
|
|
522
|
+
}
|
|
523
|
+
// New group: reset objectId contiguity tracker
|
|
524
|
+
if (top.groupId > this.lastConsumedGroupId) {
|
|
525
|
+
this.nextExpectedObjectId = 0n;
|
|
526
|
+
}
|
|
527
|
+
// Intra-group contiguity (video only): if object N is missing
|
|
528
|
+
// but N+1 is available, skip the rest of this group — decoding
|
|
529
|
+
// N+1 without N's reference produces artifacts. Audio chunks
|
|
530
|
+
// are independently decodable (LOC §4.1) so gaps are harmless.
|
|
531
|
+
if (this.mediaType === 'video' && top.objectId > this.nextExpectedObjectId) {
|
|
532
|
+
// Discard remaining objects in this group — they can't
|
|
533
|
+
// decode correctly without the missing reference.
|
|
534
|
+
const skipGroupId = top.groupId;
|
|
535
|
+
while (this.buffer.size > 0) {
|
|
536
|
+
const peek = this.buffer.peek();
|
|
537
|
+
if (!peek || peek.groupId !== skipGroupId)
|
|
538
|
+
break;
|
|
539
|
+
this.buffer.extract();
|
|
540
|
+
this.decrementGroupCount(skipGroupId);
|
|
541
|
+
}
|
|
542
|
+
this.lastConsumedGroupId = skipGroupId;
|
|
543
|
+
this.nextExpectedObjectId = 0n;
|
|
544
|
+
// Let decoder know it needs a fresh keyframe
|
|
545
|
+
const decision = this.decoderState.notifyGap();
|
|
546
|
+
this.emitFsmDecision(decision);
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
const obj = this.buffer.extract();
|
|
550
|
+
this.decrementGroupCount(obj.groupId);
|
|
551
|
+
if (obj.kind === 'data') {
|
|
552
|
+
this.processDataObject(obj);
|
|
553
|
+
}
|
|
554
|
+
this.nextExpectedObjectId = obj.objectId + 1n;
|
|
555
|
+
released++;
|
|
556
|
+
// Track last consumed group
|
|
557
|
+
if (obj.groupId > this.lastConsumedGroupId) {
|
|
558
|
+
this.lastConsumedGroupId = obj.groupId;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
// ─── Internal ───────────────────────────────────────────────────
|
|
563
|
+
/**
|
|
564
|
+
* Drop oldest whole groups when backlog exceeds maxBacklogGroups.
|
|
565
|
+
* Keeps the newest maxBacklogGroups groups, discards the rest.
|
|
566
|
+
* After shedding, notifies decoder that a gap occurred (needs keyframe).
|
|
567
|
+
*/
|
|
568
|
+
shedBacklog() {
|
|
569
|
+
const groupIds = [...this.bufferedGroupCounts.keys()].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
|
|
570
|
+
const excess = groupIds.length - this.maxBacklogGroups;
|
|
571
|
+
if (excess <= 0)
|
|
572
|
+
return;
|
|
573
|
+
const groupsToShed = groupIds.slice(0, excess);
|
|
574
|
+
const cutoff = groupsToShed[groupsToShed.length - 1] + 1n;
|
|
575
|
+
this.buffer.discardBefore(cutoff);
|
|
576
|
+
for (const gid of groupsToShed) {
|
|
577
|
+
this.bufferedGroupCounts.delete(gid);
|
|
578
|
+
}
|
|
579
|
+
// Clean up leaked header entries for shed groups
|
|
580
|
+
for (const key of this.headerMap.keys()) {
|
|
581
|
+
const groupId = BigInt(key.split(':')[0]);
|
|
582
|
+
if (groupId < cutoff)
|
|
583
|
+
this.headerMap.delete(key);
|
|
584
|
+
}
|
|
585
|
+
// Advance consumed position past shed groups
|
|
586
|
+
const lastShed = groupsToShed[groupsToShed.length - 1];
|
|
587
|
+
if (lastShed >= this.lastConsumedGroupId) {
|
|
588
|
+
this.lastConsumedGroupId = lastShed;
|
|
589
|
+
}
|
|
590
|
+
this.minAcceptGroupId = lastShed + 1n;
|
|
591
|
+
// Decoder needs a fresh keyframe after shedding
|
|
592
|
+
const decision = this.decoderState.notifyGap();
|
|
593
|
+
this.emitFsmDecision(decision);
|
|
594
|
+
this.onEvent({
|
|
595
|
+
type: 'backlog_shed',
|
|
596
|
+
droppedGroups: excess,
|
|
597
|
+
remainingGroups: this.bufferedGroupCounts.size,
|
|
598
|
+
reason: `backlog exceeded ${this.maxBacklogGroups} groups`,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Whether late encoded VIDEO may be discarded before it reaches the decoder.
|
|
603
|
+
*
|
|
604
|
+
* Only explicitly discardable, non-independent frames may be. Everything
|
|
605
|
+
* else is NOT PROVEN SAFE TO DISCARD and is therefore preserved
|
|
606
|
+
* conservatively: RFC 9626 §3.1 defines D=1 as the sender asserting the
|
|
607
|
+
* frame can be removed while the stream stays decodable, so D=0 does not
|
|
608
|
+
* prove the frame is a reference — it means the receiver has no assurance
|
|
609
|
+
* that discarding it is safe. Absent marking is treated the same way, and
|
|
610
|
+
* an independent frame is preserved even if it also claims to be
|
|
611
|
+
* discardable.
|
|
612
|
+
*
|
|
613
|
+
* Lateness of decoded OUTPUT remains the output stage's decision.
|
|
614
|
+
*
|
|
615
|
+
* @see draft-ietf-moq-loc-01 §2.3.2.2 (Video Frame Marking)
|
|
616
|
+
*/
|
|
617
|
+
isPreDecodeDroppable(marking) {
|
|
618
|
+
if (marking === undefined)
|
|
619
|
+
return false; // unknown dependencies — keep
|
|
620
|
+
if (marking.independent)
|
|
621
|
+
return false; // reference anchor — keep
|
|
622
|
+
return marking.discardable === true; // sender said it is safe
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Attempt to evict a lower-importance object to make room.
|
|
626
|
+
* @returns true if the incoming object was inserted.
|
|
627
|
+
* @see draft-ietf-moq-transport-16 §7 (Priority-based dropping)
|
|
628
|
+
*/
|
|
629
|
+
tryEvictAndInsert(obj, headers) {
|
|
630
|
+
const evicted = this.buffer.evictLowestImportance();
|
|
631
|
+
if (!evicted)
|
|
632
|
+
return false; // No evictable candidate
|
|
633
|
+
if (evicted.publisherPriority > obj.publisherPriority) {
|
|
634
|
+
// Evicted is less important — insert incoming
|
|
635
|
+
this.decrementGroupCount(evicted.groupId);
|
|
636
|
+
const evictedKey = `${evicted.groupId}:${evicted.objectId}`;
|
|
637
|
+
this.headerMap.delete(evictedKey);
|
|
638
|
+
this.buffer.insert(obj);
|
|
639
|
+
const key = `${obj.groupId}:${obj.objectId}`;
|
|
640
|
+
this.headerMap.set(key, headers ?? {});
|
|
641
|
+
const count = this.bufferedGroupCounts.get(obj.groupId) ?? 0;
|
|
642
|
+
this.bufferedGroupCounts.set(obj.groupId, count + 1);
|
|
643
|
+
return true;
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
// Evicted is equally or more important — put it back, drop incoming
|
|
647
|
+
this.buffer.insert(evicted);
|
|
648
|
+
return false;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
processDataObject(obj) {
|
|
652
|
+
const key = `${obj.groupId}:${obj.objectId}`;
|
|
653
|
+
const headers = this.headerMap.get(key) ?? {};
|
|
654
|
+
this.headerMap.delete(key);
|
|
655
|
+
// 1. Configure decoder if the object carries codec config for this
|
|
656
|
+
// media type. DecoderStateMachine.configure dedupes equal bytes.
|
|
657
|
+
// @see draft-ietf-moq-loc-04 §2.3.2.1 (Video Config), §2.3.3.1 (Audio Config)
|
|
658
|
+
const config = this.mediaType === 'video' ? headers.videoConfig : headers.audioConfig;
|
|
659
|
+
if (config) {
|
|
660
|
+
const configDecision = this.decoderState.configure(config);
|
|
661
|
+
if (configDecision.action === 'configure') {
|
|
662
|
+
this.onCommand({
|
|
663
|
+
type: 'configure',
|
|
664
|
+
mediaType: this.mediaType,
|
|
665
|
+
config,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// 2. Establish sync reference.
|
|
670
|
+
// Audio-master: first audio sample sets the reference (ONCE).
|
|
671
|
+
// Video-only: first video CaptureTimestamp sets the reference.
|
|
672
|
+
if (headers.captureTimestamp !== undefined && !this.sync.hasReference) {
|
|
673
|
+
if (this.mediaType === 'audio') {
|
|
674
|
+
this.sync.setAudioReference(headers.captureTimestamp);
|
|
675
|
+
}
|
|
676
|
+
else if (this._videoOnly) {
|
|
677
|
+
this.sync.setVideoReference(headers.captureTimestamp);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
// 3. Compute render time.
|
|
681
|
+
// Returns null when no reference — CommandDispatcher holds the frame.
|
|
682
|
+
// Video dependency marking, resolved BEFORE the lateness decision so the
|
|
683
|
+
// drop policy and DecoderStateMachine see the same marking.
|
|
684
|
+
const videoMarking = this.mediaType === 'video'
|
|
685
|
+
? (headers.videoFrameMarking
|
|
686
|
+
?? (obj.objectId === 0n
|
|
687
|
+
? { independent: true, startOfFrame: true, endOfFrame: true,
|
|
688
|
+
discardable: false, baseLayerSync: false, temporalId: 0 }
|
|
689
|
+
: undefined))
|
|
690
|
+
: undefined;
|
|
691
|
+
let renderTimeUs;
|
|
692
|
+
if (headers.captureTimestamp !== undefined) {
|
|
693
|
+
const timing = this.mediaType === 'video'
|
|
694
|
+
? this.sync.computeVideoRenderTime(headers.captureTimestamp)
|
|
695
|
+
: this.sync.computeAudioRenderTime(headers.captureTimestamp);
|
|
696
|
+
if (timing === null) {
|
|
697
|
+
// No reference yet — use placeholder. CommandDispatcher will
|
|
698
|
+
// hold this frame and recompute when the reference is ready.
|
|
699
|
+
renderTimeUs = 0;
|
|
700
|
+
}
|
|
701
|
+
else if (timing.shouldDrop) {
|
|
702
|
+
if (timing.offsetUs < -5_000_000) {
|
|
703
|
+
renderTimeUs = this.clock.now();
|
|
704
|
+
}
|
|
705
|
+
else if (this.mediaType === 'video' && !this.isPreDecodeDroppable(videoMarking)) {
|
|
706
|
+
// Late, but the sender has not asserted this frame can be
|
|
707
|
+
// removed with the stream still decodable, so discarding it
|
|
708
|
+
// here risks every frame that may depend on it — a far
|
|
709
|
+
// larger cost than one late picture. Decode it and let the
|
|
710
|
+
// OUTPUT stage decide what to present: the dispatcher
|
|
711
|
+
// recomputes timing at decoder output and CanvasRenderer
|
|
712
|
+
// drops sufficiently late frames while keeping the newest.
|
|
713
|
+
renderTimeUs = this.clock.now();
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
return; // Genuinely late and safe to discard — skip
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
else if (timing.offsetUs > 5_000_000) {
|
|
720
|
+
renderTimeUs = this.clock.now();
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
renderTimeUs = timing.renderTimeUs;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
renderTimeUs = this.clock.now();
|
|
728
|
+
}
|
|
729
|
+
// 3b. Evaluate live catch-up from CaptureTimestamp
|
|
730
|
+
// @see draft-ietf-moq-loc-01 §2.3.1.1 (latency measurement)
|
|
731
|
+
// @see draft-ietf-moq-msf-00 §5.1.16 (targetLatency)
|
|
732
|
+
if (headers.captureTimestamp !== undefined) {
|
|
733
|
+
const catchUp = this.sync.evaluateCatchUp(headers.captureTimestamp, headers.timestampIsWallClock);
|
|
734
|
+
if (catchUp !== null && catchUp.currentRate !== this._lastEmittedRate) {
|
|
735
|
+
this._lastEmittedRate = catchUp.currentRate;
|
|
736
|
+
this.onEvent({ type: 'catch_up_changed', state: catchUp });
|
|
737
|
+
this.onCommand({ type: 'set_playback_rate', rate: catchUp.currentRate });
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// 4. Create chunk init and feed through decoder FSM
|
|
741
|
+
if (this.mediaType === 'video') {
|
|
742
|
+
// Resolved above, before the lateness decision, so the drop policy
|
|
743
|
+
// and the decoder FSM cannot disagree about the same object.
|
|
744
|
+
const marking = videoMarking;
|
|
745
|
+
// Keyframe payload validation: verify bitstream actually starts with a keyframe
|
|
746
|
+
// when the LOC header claims independent. Catches broken publishers.
|
|
747
|
+
// Only check payloads > 5 bytes (too short = can't determine).
|
|
748
|
+
// @see draft-ietf-moq-loc-01 §4.2
|
|
749
|
+
if (marking?.independent && this._videoCodec && obj.payload.length > 5) {
|
|
750
|
+
const isKeyframe = isKeyframePayload(this._videoCodec, obj.payload);
|
|
751
|
+
if (isKeyframe === false) {
|
|
752
|
+
this.onEvent({
|
|
753
|
+
type: 'keyframe_validation_failed',
|
|
754
|
+
groupId: obj.groupId,
|
|
755
|
+
objectId: obj.objectId,
|
|
756
|
+
codec: this._videoCodec,
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const headersWithMarking = marking !== undefined
|
|
761
|
+
? { ...headers, videoFrameMarking: marking }
|
|
762
|
+
: headers;
|
|
763
|
+
const chunk = toVideoChunkInit(obj.payload, headersWithMarking);
|
|
764
|
+
const prevState = this.decoderState.state;
|
|
765
|
+
const decision = this.decoderState.processVideoChunk(chunk, marking);
|
|
766
|
+
// Detect live join: NEEDS_KEYFRAME → DECODING (first keyframe decoded)
|
|
767
|
+
// Apply video join re-anchor to prevent startup stutter.
|
|
768
|
+
if (prevState === 'needs_keyframe' && this.decoderState.state === 'decoding'
|
|
769
|
+
&& headers.captureTimestamp !== undefined) {
|
|
770
|
+
this.sync.onVideoJoin(headers.captureTimestamp);
|
|
771
|
+
// Recompute render time with the offset applied
|
|
772
|
+
const recomputed = this.sync.computeVideoRenderTime(headers.captureTimestamp);
|
|
773
|
+
if (recomputed) {
|
|
774
|
+
renderTimeUs = recomputed.renderTimeUs;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (decision.action === 'decode') {
|
|
778
|
+
this.onCommand({
|
|
779
|
+
type: 'decode_video',
|
|
780
|
+
chunk: decision.chunk,
|
|
781
|
+
renderTimeUs,
|
|
782
|
+
...(headers.captureTimestamp !== undefined
|
|
783
|
+
? { captureTimestampUs: headers.captureTimestamp }
|
|
784
|
+
: {}),
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
else if (decision.action === 'skip') {
|
|
788
|
+
// Emit keyframe_waiting once per group (avoid per-object spam)
|
|
789
|
+
if (decision.reason === 'waiting for keyframe' && obj.groupId !== this.lastKeyframeWaitingGroupId) {
|
|
790
|
+
this.lastKeyframeWaitingGroupId = obj.groupId;
|
|
791
|
+
this.onEvent({ type: 'keyframe_waiting', groupId: obj.groupId });
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
const chunk = toAudioChunkInit(obj.payload, headers);
|
|
797
|
+
const decision = this.decoderState.processAudioChunk(chunk);
|
|
798
|
+
if (decision.action === 'decode') {
|
|
799
|
+
this.onCommand({
|
|
800
|
+
type: 'decode_audio',
|
|
801
|
+
chunk: decision.chunk,
|
|
802
|
+
renderTimeUs,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
emitFsmDecision(decision) {
|
|
808
|
+
if (decision.action === 'reset') {
|
|
809
|
+
this.onCommand({
|
|
810
|
+
type: 'reset',
|
|
811
|
+
mediaType: this.mediaType,
|
|
812
|
+
reason: decision.reason ?? 'gap',
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
else if (decision.action === 'flush') {
|
|
816
|
+
this.onCommand({
|
|
817
|
+
type: 'flush',
|
|
818
|
+
mediaType: this.mediaType,
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
discardBefore(groupId) {
|
|
823
|
+
// Remove from buffer
|
|
824
|
+
this.buffer.discardBefore(groupId);
|
|
825
|
+
// Clean up group counts and headers for removed groups
|
|
826
|
+
for (const [gid] of this.bufferedGroupCounts) {
|
|
827
|
+
if (gid < groupId) {
|
|
828
|
+
this.bufferedGroupCounts.delete(gid);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// Clean up headers for discarded objects
|
|
832
|
+
for (const [key] of this.headerMap) {
|
|
833
|
+
const colonIdx = key.indexOf(':');
|
|
834
|
+
const gid = BigInt(key.slice(0, colonIdx));
|
|
835
|
+
if (gid < groupId) {
|
|
836
|
+
this.headerMap.delete(key);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
decrementGroupCount(groupId) {
|
|
841
|
+
const count = this.bufferedGroupCounts.get(groupId);
|
|
842
|
+
if (count !== undefined) {
|
|
843
|
+
if (count <= 1) {
|
|
844
|
+
this.bufferedGroupCounts.delete(groupId);
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
this.bufferedGroupCounts.set(groupId, count - 1);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
//# sourceMappingURL=pipeline.js.map
|