@blockcast/mmt-transport 0.2.2 → 0.3.0-main.1cd3cdea89da
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/README.md +44 -58
- package/dist/abr-controller.d.ts +34 -4
- package/dist/abr-controller.d.ts.map +1 -1
- package/dist/abr-controller.js +66 -17
- package/dist/abr-controller.js.map +1 -1
- package/dist/amt-gateway.d.ts.map +1 -1
- package/dist/amt-gateway.js +3 -4
- package/dist/amt-gateway.js.map +1 -1
- package/dist/driad-discovery.d.ts.map +1 -1
- package/dist/driad-discovery.js +3 -6
- package/dist/driad-discovery.js.map +1 -1
- package/dist/endpoint-config.d.ts +68 -0
- package/dist/endpoint-config.d.ts.map +1 -1
- package/dist/endpoint-config.js +118 -3
- package/dist/endpoint-config.js.map +1 -1
- package/dist/index.d.ts +10 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -11
- package/dist/index.js.map +1 -1
- package/dist/mmt-transport.no-modules.js +896 -61
- package/dist/mmt-transport.no-modules.js.map +4 -4
- package/dist/moqt-priority.d.ts +39 -0
- package/dist/moqt-priority.d.ts.map +1 -0
- package/dist/moqt-priority.js +41 -0
- package/dist/moqt-priority.js.map +1 -0
- package/dist/multicast-catalog-policy.d.ts +96 -0
- package/dist/multicast-catalog-policy.d.ts.map +1 -0
- package/dist/multicast-catalog-policy.js +660 -0
- package/dist/multicast-catalog-policy.js.map +1 -0
- package/dist/multicast-orchestrator.d.ts +35 -0
- package/dist/multicast-orchestrator.d.ts.map +1 -1
- package/dist/multicast-orchestrator.js +128 -10
- package/dist/multicast-orchestrator.js.map +1 -1
- package/dist/multicast-signaling-reconciler.d.ts +37 -0
- package/dist/multicast-signaling-reconciler.d.ts.map +1 -0
- package/dist/multicast-signaling-reconciler.js +161 -0
- package/dist/multicast-signaling-reconciler.js.map +1 -0
- package/dist/multicast-subscription-pool.d.ts.map +1 -1
- package/dist/multicast-subscription-pool.js +37 -10
- package/dist/multicast-subscription-pool.js.map +1 -1
- package/dist/multicast-tier.d.ts +19 -0
- package/dist/multicast-tier.d.ts.map +1 -1
- package/dist/multicast-tier.js +21 -4
- package/dist/multicast-tier.js.map +1 -1
- package/dist/no-modules.d.ts +40 -3
- package/dist/no-modules.d.ts.map +1 -1
- package/dist/no-modules.js +30 -3
- package/dist/no-modules.js.map +1 -1
- package/dist/packet-batch.d.ts +13 -0
- package/dist/packet-batch.d.ts.map +1 -0
- package/dist/packet-batch.js +14 -0
- package/dist/packet-batch.js.map +1 -0
- package/dist/stall-detector.d.ts +10 -0
- package/dist/stall-detector.d.ts.map +1 -1
- package/dist/stall-detector.js +21 -0
- package/dist/stall-detector.js.map +1 -1
- package/dist/takeover.d.ts +3 -0
- package/dist/takeover.d.ts.map +1 -1
- package/dist/takeover.js +35 -2
- package/dist/takeover.js.map +1 -1
- package/dist/transport-manager.d.ts +21 -17
- package/dist/transport-manager.d.ts.map +1 -1
- package/dist/transport-manager.js +63 -29
- package/dist/transport-manager.js.map +1 -1
- package/dist/types.d.ts +98 -123
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +12 -50
- package/dist/types.js.map +1 -1
- package/package.json +13 -7
- package/src/abr-controller.ts +81 -17
- package/src/amt-gateway.ts +3 -5
- package/src/driad-discovery.ts +3 -6
- package/src/endpoint-config.ts +157 -3
- package/src/index.ts +54 -33
- package/src/moqt-priority.ts +51 -0
- package/src/multicast-catalog-policy.ts +753 -0
- package/src/multicast-orchestrator.ts +138 -11
- package/src/multicast-signaling-reconciler.ts +156 -0
- package/src/multicast-subscription-pool.ts +46 -11
- package/src/multicast-tier.ts +30 -3
- package/src/no-modules.ts +54 -2
- package/src/packet-batch.ts +17 -0
- package/src/stall-detector.ts +37 -0
- package/src/takeover.ts +41 -3
- package/src/transport-manager.ts +64 -34
- package/src/types.ts +103 -160
- package/vectors/multicast-tier-switch/README.md +18 -0
- package/vectors/multicast-tier-switch/cases.json +646 -0
- package/dist/endpoint-config.test.d.ts +0 -2
- package/dist/endpoint-config.test.d.ts.map +0 -1
- package/dist/endpoint-config.test.js +0 -39
- package/dist/endpoint-config.test.js.map +0 -1
- package/dist/fec-client.d.ts +0 -442
- package/dist/fec-client.d.ts.map +0 -1
- package/dist/fec-client.js +0 -789
- package/dist/fec-client.js.map +0 -1
- package/dist/fec-client.test.d.ts +0 -8
- package/dist/fec-client.test.d.ts.map +0 -1
- package/dist/fec-client.test.js +0 -112
- package/dist/fec-client.test.js.map +0 -1
- package/dist/multicast-subscription-pool.test.d.ts +0 -2
- package/dist/multicast-subscription-pool.test.d.ts.map +0 -1
- package/dist/multicast-subscription-pool.test.js +0 -108
- package/dist/multicast-subscription-pool.test.js.map +0 -1
- package/dist/multicast-tier.test.d.ts +0 -2
- package/dist/multicast-tier.test.d.ts.map +0 -1
- package/dist/multicast-tier.test.js +0 -75
- package/dist/multicast-tier.test.js.map +0 -1
- package/dist/stall-detector.test.d.ts +0 -2
- package/dist/stall-detector.test.d.ts.map +0 -1
- package/dist/stall-detector.test.js +0 -33
- package/dist/stall-detector.test.js.map +0 -1
- package/dist/transport-manager.test.d.ts +0 -2
- package/dist/transport-manager.test.d.ts.map +0 -1
- package/dist/transport-manager.test.js +0 -155
- package/dist/transport-manager.test.js.map +0 -1
- package/src/endpoint-config.test.ts +0 -54
- package/src/fec-client.test.ts +0 -140
- package/src/fec-client.ts +0 -1103
- package/src/multicast-subscription-pool.test.ts +0 -115
- package/src/multicast-tier.test.ts +0 -80
- package/src/stall-detector.test.ts +0 -34
- package/src/transport-manager.test.ts +0 -192
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
function endpointKey(endpoint) {
|
|
5
5
|
return `${endpoint.sourceAddress ?? ""}|${endpoint.groupAddress}|${endpoint.port}`;
|
|
6
6
|
}
|
|
7
|
+
function carriesMedia(endpoint) {
|
|
8
|
+
return endpoint.carriesMedia !== false;
|
|
9
|
+
}
|
|
7
10
|
function selectMulticastTier(endpoints, estimate) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
const media = endpoints.filter(carriesMedia);
|
|
12
|
+
if (media.length <= 1) return media[0];
|
|
13
|
+
const ranked = media.filter((endpoint) => typeof endpoint.bandwidth === "number" && endpoint.bandwidth > 0).sort((a, b) => a.bandwidth - b.bandwidth);
|
|
14
|
+
if (ranked.length < 2) return media[0];
|
|
11
15
|
if (estimate === void 0) return ranked[0];
|
|
12
16
|
const budget = estimate * 0.8;
|
|
13
17
|
let selected;
|
|
@@ -18,7 +22,7 @@
|
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
// src/abr-controller.ts
|
|
21
|
-
var QUALITY_LEVELS = [
|
|
25
|
+
var QUALITY_LEVELS = Object.freeze([
|
|
22
26
|
{ name: "240p", bitrate: 3e5, width: 426, height: 240, fps: 15 },
|
|
23
27
|
{ name: "360p", bitrate: 5e5, width: 640, height: 360, fps: 24 },
|
|
24
28
|
{ name: "480p", bitrate: 1e6, width: 854, height: 480, fps: 30 },
|
|
@@ -26,8 +30,37 @@
|
|
|
26
30
|
{ name: "1080p", bitrate: 5e6, width: 1920, height: 1080, fps: 30 },
|
|
27
31
|
{ name: "1440p", bitrate: 8e6, width: 2560, height: 1440, fps: 30 },
|
|
28
32
|
{ name: "4K", bitrate: 15e6, width: 3840, height: 2160, fps: 30 }
|
|
29
|
-
];
|
|
33
|
+
].map((level) => Object.freeze(level)));
|
|
34
|
+
function positiveFinite(value) {
|
|
35
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
36
|
+
}
|
|
37
|
+
function validateQualityLadder(ladder) {
|
|
38
|
+
if (!Array.isArray(ladder) || ladder.length === 0) {
|
|
39
|
+
throw new TypeError("[ABR] ladder must list at least one rendition (e.g. the catalog's video tracks)");
|
|
40
|
+
}
|
|
41
|
+
const names = /* @__PURE__ */ new Set();
|
|
42
|
+
for (const level of ladder) {
|
|
43
|
+
if (typeof level?.name !== "string" || level.name.length === 0) {
|
|
44
|
+
throw new TypeError("[ABR] every ladder rendition needs a name");
|
|
45
|
+
}
|
|
46
|
+
if (!positiveFinite(level.bitrate)) {
|
|
47
|
+
throw new RangeError(`[ABR] rendition ${level.name}: bitrate must be a positive finite number, got ${String(level.bitrate)}`);
|
|
48
|
+
}
|
|
49
|
+
for (const field of ["width", "height", "fps"]) {
|
|
50
|
+
if (level[field] !== void 0 && !positiveFinite(level[field])) {
|
|
51
|
+
throw new RangeError(`[ABR] rendition ${level.name}: ${field} must be a positive finite number when stated, got ${String(level[field])}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (names.has(level.name)) throw new RangeError(`[ABR] rendition ${level.name} appears twice in the ladder`);
|
|
55
|
+
names.add(level.name);
|
|
56
|
+
}
|
|
57
|
+
return Object.freeze(
|
|
58
|
+
[...ladder].sort((a, b) => a.bitrate - b.bitrate).map((level) => Object.freeze({ ...level }))
|
|
59
|
+
);
|
|
60
|
+
}
|
|
30
61
|
var AdaptiveBitrateController = class {
|
|
62
|
+
/** The caller's renditions, ascending by bitrate (`validateQualityLadder`). */
|
|
63
|
+
ladder;
|
|
31
64
|
currentQualityIndex;
|
|
32
65
|
history = [];
|
|
33
66
|
historyWindow = 1e4;
|
|
@@ -47,15 +80,21 @@
|
|
|
47
80
|
recoveryRate: 1,
|
|
48
81
|
fecEnabled: false
|
|
49
82
|
};
|
|
50
|
-
constructor(
|
|
51
|
-
|
|
52
|
-
|
|
83
|
+
constructor(options) {
|
|
84
|
+
if (!options || typeof options !== "object") {
|
|
85
|
+
throw new TypeError("[ABR] AdaptiveBitrateController needs { ladder, initialQuality }");
|
|
86
|
+
}
|
|
87
|
+
this.ladder = validateQualityLadder(options.ladder);
|
|
88
|
+
this.currentQualityIndex = options.initialQuality === void 0 ? 0 : this.ladder.findIndex((q) => q.name === options.initialQuality);
|
|
89
|
+
if (this.currentQualityIndex < 0) {
|
|
90
|
+
throw new RangeError(`[ABR] initial rendition ${String(options.initialQuality)} is not in the ladder`);
|
|
91
|
+
}
|
|
53
92
|
}
|
|
54
93
|
setCallback(callback) {
|
|
55
94
|
this.onQualityChange = callback;
|
|
56
95
|
}
|
|
57
96
|
getCurrentQuality() {
|
|
58
|
-
return
|
|
97
|
+
return this.ladder[this.currentQualityIndex];
|
|
59
98
|
}
|
|
60
99
|
/** Selects the multicast (S,G) tier without replacing a player's unicast ABR manager. */
|
|
61
100
|
selectMulticastTier(endpoints, estimate = this.getMetrics().measuredBitrate || void 0) {
|
|
@@ -81,7 +120,7 @@
|
|
|
81
120
|
this.downgrade();
|
|
82
121
|
return;
|
|
83
122
|
}
|
|
84
|
-
if (measuredBitrate > currentQuality.bitrate * this.upgradeThreshold && effectiveLossRate < 0.01 && this.currentQualityIndex <
|
|
123
|
+
if (measuredBitrate > currentQuality.bitrate * this.upgradeThreshold && effectiveLossRate < 0.01 && this.currentQualityIndex < this.ladder.length - 1) {
|
|
85
124
|
this.upgrade();
|
|
86
125
|
}
|
|
87
126
|
}
|
|
@@ -114,7 +153,7 @@
|
|
|
114
153
|
return { measuredBitrate, lossRate };
|
|
115
154
|
}
|
|
116
155
|
upgrade() {
|
|
117
|
-
if (this.currentQualityIndex >=
|
|
156
|
+
if (this.currentQualityIndex >= this.ladder.length - 1) return;
|
|
118
157
|
this.currentQualityIndex++;
|
|
119
158
|
this.lastQualityChange = Date.now();
|
|
120
159
|
const newQuality = this.getCurrentQuality();
|
|
@@ -130,15 +169,15 @@
|
|
|
130
169
|
this.onQualityChange?.(newQuality);
|
|
131
170
|
}
|
|
132
171
|
/**
|
|
133
|
-
*
|
|
172
|
+
* Select a ladder rendition: the caller switched to it (a manual override,
|
|
173
|
+
* or a switch the caller made). Throws for a name the ladder does not carry.
|
|
134
174
|
*/
|
|
135
175
|
setQuality(name) {
|
|
136
|
-
const index =
|
|
137
|
-
if (index
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
176
|
+
const index = this.ladder.findIndex((q) => q.name === name);
|
|
177
|
+
if (index < 0) throw new RangeError(`[ABR] rendition ${name} is not in the ladder`);
|
|
178
|
+
this.currentQualityIndex = index;
|
|
179
|
+
this.lastQualityChange = Date.now();
|
|
180
|
+
this.onQualityChange?.(this.getCurrentQuality());
|
|
142
181
|
}
|
|
143
182
|
/**
|
|
144
183
|
* Get current metrics including both raw and effective loss rates
|
|
@@ -162,7 +201,85 @@
|
|
|
162
201
|
}
|
|
163
202
|
};
|
|
164
203
|
|
|
204
|
+
// node_modules/@blockcast/mmt-base/dist/mmtp.js
|
|
205
|
+
var MPU_HEADER_SIZE = 8;
|
|
206
|
+
var MPU_LENGTH_FIELD_SIZE = 2;
|
|
207
|
+
var MPU_HEADER_REMAINDER_SIZE = MPU_HEADER_SIZE - MPU_LENGTH_FIELD_SIZE;
|
|
208
|
+
var MMTP_SIGNALING_PACKET_ID = 0;
|
|
209
|
+
var MMTP_PACKET_ID_MAX = 65535;
|
|
210
|
+
|
|
211
|
+
// node_modules/@blockcast/mmt-base/dist/net-constants.js
|
|
212
|
+
var DEFAULT_AMT_RELAY_PORT = 2268;
|
|
213
|
+
|
|
214
|
+
// node_modules/@blockcast/mmt-base/dist/ntp-short.js
|
|
215
|
+
var NTP_SHORT_BITS = 32;
|
|
216
|
+
var NTP_SHORT_MODULUS = 2 ** NTP_SHORT_BITS;
|
|
217
|
+
var NTP_SHORT_HALF_RANGE = 2 ** (NTP_SHORT_BITS - 1);
|
|
218
|
+
function isNtpShort(value) {
|
|
219
|
+
return Number.isInteger(value) && value >= 0 && value < NTP_SHORT_MODULUS;
|
|
220
|
+
}
|
|
221
|
+
function compareNtpShort(a, b) {
|
|
222
|
+
if (!isNtpShort(a) || !isNtpShort(b)) {
|
|
223
|
+
throw new RangeError(`NTP-short values must be integers in [0, 2^${NTP_SHORT_BITS}), got ${String(a)} and ${String(b)}`);
|
|
224
|
+
}
|
|
225
|
+
if (a === b)
|
|
226
|
+
return "equal";
|
|
227
|
+
const forward = (a - b + NTP_SHORT_MODULUS) % NTP_SHORT_MODULUS;
|
|
228
|
+
if (forward < NTP_SHORT_HALF_RANGE)
|
|
229
|
+
return "after";
|
|
230
|
+
if (forward > NTP_SHORT_HALF_RANGE)
|
|
231
|
+
return "before";
|
|
232
|
+
return "unordered";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// node_modules/@blockcast/mmt-base/dist/fec-block-profile.js
|
|
236
|
+
var FEC_BLOCK_PROFILES = Object.freeze([
|
|
237
|
+
"iso-ssbg1-v1"
|
|
238
|
+
]);
|
|
239
|
+
|
|
165
240
|
// src/endpoint-config.ts
|
|
241
|
+
function toWindowMulticastSubscribeConfig(input) {
|
|
242
|
+
if (input.mode !== void 0 && !["auto", "native", "tunnel"].includes(input.mode)) {
|
|
243
|
+
throw new TypeError(`Unsupported multicast mode: ${String(input.mode)}`);
|
|
244
|
+
}
|
|
245
|
+
if (input.packetIds !== void 0 && !Array.isArray(input.packetIds)) {
|
|
246
|
+
throw new TypeError(
|
|
247
|
+
`multicast packetIds must be an array, got ${input.packetIds === null ? "null" : typeof input.packetIds}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (input.packetIds?.some((id) => !isMulticastTrackPacketId(id)) || input.tracks?.some((track) => !isMulticastTrackPacketId(track.packetId))) {
|
|
251
|
+
throw new RangeError(
|
|
252
|
+
`multicast packetId must be an integer from 1 to ${MMTP_PACKET_ID_MAX}; packetId 0 is the MMTP signaling flow and is never a track's`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const base = {
|
|
256
|
+
group: input.group,
|
|
257
|
+
port: input.port,
|
|
258
|
+
...input.source?.trim() ? { source: input.source.trim() } : {},
|
|
259
|
+
consume: "bytes",
|
|
260
|
+
...input.packetIds?.length ? { packetIds: [...input.packetIds] } : {}
|
|
261
|
+
};
|
|
262
|
+
const relay = input.amtRelay?.trim();
|
|
263
|
+
const wantsAmt = input.mode === "tunnel" || input.mode !== "native" && relay !== void 0 && relay !== "";
|
|
264
|
+
if (!wantsAmt) {
|
|
265
|
+
const native = {
|
|
266
|
+
...base,
|
|
267
|
+
origin: "ip-multicast",
|
|
268
|
+
...input.tracks?.length ? { tracks: input.tracks.map(({ name, packetId }) => ({ name, packetId })) } : {}
|
|
269
|
+
};
|
|
270
|
+
return native;
|
|
271
|
+
}
|
|
272
|
+
const relayPort = input.amtRelayPort;
|
|
273
|
+
if (!relay || relayPort === void 0 || !isPositivePort(relayPort)) {
|
|
274
|
+
throw new TypeError("AMT requires a resolved relay and a valid relay port");
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
...base,
|
|
278
|
+
origin: "amt",
|
|
279
|
+
relay,
|
|
280
|
+
relayPort
|
|
281
|
+
};
|
|
282
|
+
}
|
|
166
283
|
function isValidMulticastGroupAddress(address) {
|
|
167
284
|
const value = address?.trim().toLowerCase();
|
|
168
285
|
if (!value) return false;
|
|
@@ -204,20 +321,36 @@
|
|
|
204
321
|
normalized.amtRelayPort ?? ""
|
|
205
322
|
]);
|
|
206
323
|
}
|
|
324
|
+
function isMulticastTrackPacketId(value) {
|
|
325
|
+
return Number.isInteger(value) && value > MMTP_SIGNALING_PACKET_ID && value <= MMTP_PACKET_ID_MAX;
|
|
326
|
+
}
|
|
207
327
|
function isUsableMulticastEndpoint(endpoint) {
|
|
208
328
|
const port = Number(endpoint?.port);
|
|
209
329
|
return isValidMulticastGroupAddress(endpoint?.groupAddress) && isPositivePort(port);
|
|
210
330
|
}
|
|
331
|
+
var RETIRED_MULTICAST_URL_PARAMS = [
|
|
332
|
+
["amt_relay", "mc_amt_relay"],
|
|
333
|
+
["amt_relay_port", "mc_amt_relay_port"]
|
|
334
|
+
];
|
|
335
|
+
function assertNoRetiredMulticastUrlParams(params) {
|
|
336
|
+
for (const [retired, replacement] of RETIRED_MULTICAST_URL_PARAMS) {
|
|
337
|
+
if (params.has(retired)) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`multicast URL override: query parameter "${retired}" is retired; use "${replacement}"`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
211
344
|
function readUrlMulticastConfig(search = typeof location === "undefined" ? "" : location.search) {
|
|
212
345
|
const params = typeof search === "string" ? new URLSearchParams(search) : search;
|
|
213
346
|
const groupAddress = params.get("mc_group") || "";
|
|
214
347
|
const port = positivePort(params.get("mc_port"));
|
|
215
348
|
if (!groupAddress || !port) return void 0;
|
|
216
|
-
const amtRelay = params.get("mc_amt_relay") ||
|
|
217
|
-
const amtRelayPort = positivePort(params.get("mc_amt_relay_port")
|
|
349
|
+
const amtRelay = params.get("mc_amt_relay") || void 0;
|
|
350
|
+
const amtRelayPort = positivePort(params.get("mc_amt_relay_port"));
|
|
218
351
|
const requestedMode = params.get("mc_mode");
|
|
219
352
|
const mode = amtRelay ? "tunnel" : multicastMode(requestedMode);
|
|
220
|
-
|
|
353
|
+
const endpoint = normalizeMulticastEndpoint({
|
|
221
354
|
groupAddress,
|
|
222
355
|
sourceAddress: params.get("mc_source") || void 0,
|
|
223
356
|
port,
|
|
@@ -225,6 +358,8 @@
|
|
|
225
358
|
...amtRelay ? { amtRelay } : {},
|
|
226
359
|
...amtRelayPort ? { amtRelayPort } : {}
|
|
227
360
|
});
|
|
361
|
+
if (endpoint) assertNoRetiredMulticastUrlParams(params);
|
|
362
|
+
return endpoint;
|
|
228
363
|
}
|
|
229
364
|
function isMulticastExplicitlyDisabled(search = typeof location === "undefined" ? "" : location.search) {
|
|
230
365
|
const params = typeof search === "string" ? new URLSearchParams(search) : search;
|
|
@@ -270,7 +405,12 @@
|
|
|
270
405
|
#estimate;
|
|
271
406
|
#favored;
|
|
272
407
|
#joinStartedAt = 0;
|
|
273
|
-
|
|
408
|
+
/**
|
|
409
|
+
* Latest active-tier send time (NTP short), or undefined before the first.
|
|
410
|
+
* "Latest" is serial order, not the numerically largest value: after the
|
|
411
|
+
* field wraps, the largest value is the stale one.
|
|
412
|
+
*/
|
|
413
|
+
#highWater;
|
|
274
414
|
#dwellMs;
|
|
275
415
|
#joinDeadlineMs;
|
|
276
416
|
get state() {
|
|
@@ -291,19 +431,58 @@
|
|
|
291
431
|
}
|
|
292
432
|
onGoodput(sample) {
|
|
293
433
|
const bitrate = sample.bitrate;
|
|
294
|
-
this.#
|
|
295
|
-
if (!this.#current || this.options.adaptive === false) return void 0;
|
|
434
|
+
if (!this.#current) return void 0;
|
|
296
435
|
if (this.#state === "joining") return this.poll();
|
|
436
|
+
if (this.options.adaptive === false) return void 0;
|
|
437
|
+
if (bitrate === void 0 || !Number.isFinite(bitrate)) return void 0;
|
|
438
|
+
this.#estimate = bitrate;
|
|
297
439
|
const desired = selectMulticastTier(this.options.endpoints, bitrate);
|
|
298
440
|
if (!desired) return void 0;
|
|
299
441
|
const desiredKey = endpointKey(desired);
|
|
442
|
+
if (desiredKey === endpointKey(this.#current)) {
|
|
443
|
+
if (this.#favored?.source === "goodput") this.#favored = void 0;
|
|
444
|
+
return void 0;
|
|
445
|
+
}
|
|
446
|
+
if (typeof desired.bandwidth !== "number" || typeof this.#current.bandwidth !== "number" || desired.bandwidth >= this.#current.bandwidth) {
|
|
447
|
+
if (this.#favored?.source === "goodput") this.#favored = void 0;
|
|
448
|
+
return void 0;
|
|
449
|
+
}
|
|
450
|
+
const now = this.#now();
|
|
451
|
+
if (!this.#favored || this.#favored.key !== desiredKey || this.#favored.source !== "goodput") {
|
|
452
|
+
this.#favored = { key: desiredKey, since: now, source: "goodput" };
|
|
453
|
+
return void 0;
|
|
454
|
+
}
|
|
455
|
+
if (now - this.#favored.since < this.#dwellMs) return void 0;
|
|
456
|
+
this.#state = "joining";
|
|
457
|
+
this.#target = desired;
|
|
458
|
+
this.#joinStartedAt = now;
|
|
459
|
+
this.#favored = void 0;
|
|
460
|
+
return { kind: "join-target", current: this.#current, target: desired };
|
|
461
|
+
}
|
|
462
|
+
onHeadroom(sample) {
|
|
463
|
+
if (!this.#current || this.options.adaptive === false || this.#state === "joining") return void 0;
|
|
464
|
+
if (!sample.healthy) {
|
|
465
|
+
if (this.#favored?.source === "headroom") this.#favored = void 0;
|
|
466
|
+
return void 0;
|
|
467
|
+
}
|
|
468
|
+
if (this.#favored?.source === "goodput") return void 0;
|
|
469
|
+
const currentBandwidth = this.#current.bandwidth;
|
|
470
|
+
if (typeof currentBandwidth !== "number") return void 0;
|
|
471
|
+
const desired = this.options.endpoints.filter(
|
|
472
|
+
(endpoint) => carriesMedia(endpoint) && typeof endpoint.bandwidth === "number" && endpoint.bandwidth > currentBandwidth
|
|
473
|
+
).sort((a, b) => a.bandwidth - b.bandwidth)[0];
|
|
474
|
+
if (!desired) {
|
|
475
|
+
this.#favored = void 0;
|
|
476
|
+
return void 0;
|
|
477
|
+
}
|
|
478
|
+
const desiredKey = endpointKey(desired);
|
|
300
479
|
if (desiredKey === endpointKey(this.#current)) {
|
|
301
480
|
this.#favored = void 0;
|
|
302
481
|
return void 0;
|
|
303
482
|
}
|
|
304
483
|
const now = this.#now();
|
|
305
484
|
if (!this.#favored || this.#favored.key !== desiredKey) {
|
|
306
|
-
this.#favored = { key: desiredKey, since: now };
|
|
485
|
+
this.#favored = { key: desiredKey, since: now, source: "headroom" };
|
|
307
486
|
return void 0;
|
|
308
487
|
}
|
|
309
488
|
if (now - this.#favored.since < this.#dwellMs) return void 0;
|
|
@@ -313,21 +492,51 @@
|
|
|
313
492
|
this.#favored = void 0;
|
|
314
493
|
return { kind: "join-target", current: this.#current, target: desired };
|
|
315
494
|
}
|
|
495
|
+
/**
|
|
496
|
+
* Join `target` because the viewer selected it, not because goodput or
|
|
497
|
+
* headroom ranked it. It runs whether or not the orchestrator adapts, and
|
|
498
|
+
* takes the path an adaptive join takes: commit on the target's RAP at or
|
|
499
|
+
* beyond the high-water mark, abandon at the join deadline. While it is
|
|
500
|
+
* pending nothing re-ranks the ladder; goodput only advances the deadline.
|
|
501
|
+
*
|
|
502
|
+
* Returns undefined, changing nothing, while another join is pending (cancel
|
|
503
|
+
* it first), when `target` is the current tier, or when it carries no media.
|
|
504
|
+
*/
|
|
505
|
+
select(target) {
|
|
506
|
+
if (!this.#current || this.#state === "joining" || !carriesMedia(target)) return void 0;
|
|
507
|
+
if (endpointKey(target) === endpointKey(this.#current)) return void 0;
|
|
508
|
+
this.#state = "joining";
|
|
509
|
+
this.#target = target;
|
|
510
|
+
this.#joinStartedAt = this.#now();
|
|
511
|
+
this.#favored = void 0;
|
|
512
|
+
return { kind: "join-target", current: this.#current, target };
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Feed a packet from the active tier (to advance the high-water mark) or
|
|
516
|
+
* from the in-flight target (to find a committable RAP).
|
|
517
|
+
*
|
|
518
|
+
* Serial order is only the true order while the compared instants are less
|
|
519
|
+
* than `NTP_SHORT_HALF_RANGE` (2^15 s, ~9.1 h) apart. Feeding every
|
|
520
|
+
* active-tier packet keeps the mark that close to the live stream. A mark
|
|
521
|
+
* refreshed only by commits can fall further behind than that, and a
|
|
522
|
+
* target RAP may then compare as older, so the join is abandoned.
|
|
523
|
+
*/
|
|
316
524
|
onTierPacket(packet) {
|
|
317
525
|
if (!this.#current) return void 0;
|
|
318
|
-
|
|
319
|
-
|
|
526
|
+
const sentAt = tierPacketSendTime(packet);
|
|
527
|
+
if (packet.endpointKey === endpointKey(this.#current) && sentAt !== void 0 && (this.#highWater === void 0 || compareNtpShort(sentAt, this.#highWater) === "after")) {
|
|
528
|
+
this.#highWater = sentAt;
|
|
320
529
|
}
|
|
321
530
|
if (this.#state !== "joining" || !this.#target) return void 0;
|
|
322
531
|
const expired = this.poll();
|
|
323
532
|
if (expired) return expired;
|
|
324
|
-
if (packet.endpointKey !== endpointKey(this.#target) || !packet.keyframe || !packet.hasValidCodecConfig ||
|
|
533
|
+
if (packet.endpointKey !== endpointKey(this.#target) || !packet.keyframe || !packet.hasValidCodecConfig || sentAt === void 0 || !this.#reachesHighWater(sentAt)) return void 0;
|
|
325
534
|
const command = {
|
|
326
535
|
kind: "commit-swap",
|
|
327
536
|
target: this.#target,
|
|
328
537
|
old: this.#current
|
|
329
538
|
};
|
|
330
|
-
this.#
|
|
539
|
+
this.#highWater = sentAt;
|
|
331
540
|
this.#current = this.#target;
|
|
332
541
|
this.#target = void 0;
|
|
333
542
|
this.#state = "stable";
|
|
@@ -349,12 +558,481 @@
|
|
|
349
558
|
this.#state = "stable";
|
|
350
559
|
return command;
|
|
351
560
|
}
|
|
561
|
+
/**
|
|
562
|
+
* Whether a target RAP sent at `sentAt` is at or beyond the active tier's
|
|
563
|
+
* high-water mark. A pair exactly half the NTP-short space apart has no
|
|
564
|
+
* serial order, so it cannot show that and does not commit.
|
|
565
|
+
*/
|
|
566
|
+
#reachesHighWater(sentAt) {
|
|
567
|
+
if (this.#highWater === void 0) return true;
|
|
568
|
+
const order = compareNtpShort(sentAt, this.#highWater);
|
|
569
|
+
return order === "after" || order === "equal";
|
|
570
|
+
}
|
|
352
571
|
#now() {
|
|
353
572
|
const now = this.options.now();
|
|
354
573
|
if (!Number.isFinite(now)) throw new Error("Multicast orchestrator clock must be finite");
|
|
355
574
|
return now;
|
|
356
575
|
}
|
|
357
576
|
};
|
|
577
|
+
function tierPacketSendTime(packet) {
|
|
578
|
+
if (!Number.isFinite(packet.timestamp)) return void 0;
|
|
579
|
+
if (!isNtpShort(packet.timestamp)) {
|
|
580
|
+
throw new RangeError(
|
|
581
|
+
`Multicast tier packet timestamp must be an MMTP NTP-short send time, got ${packet.timestamp}`
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
return packet.timestamp;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// src/multicast-signaling-reconciler.ts
|
|
588
|
+
function createMulticastSignalingReconciler(catalog, options = {}) {
|
|
589
|
+
const initial = normalizeCatalogEndpoints(catalog);
|
|
590
|
+
const endpoints = new Map(initial.map((endpoint) => [endpoint.endpointId, copyEndpoint(endpoint)]));
|
|
591
|
+
const versions = new Map(initial.map((endpoint) => [endpoint.endpointId, endpoint.endpointVersion]));
|
|
592
|
+
const seen = /* @__PURE__ */ new Set();
|
|
593
|
+
const dormant = /* @__PURE__ */ new Set();
|
|
594
|
+
let catalogVersion = Number(catalog?.multicast?.version ?? catalog?.version ?? 0) || 0;
|
|
595
|
+
const snapshot = (now = Date.now()) => ({
|
|
596
|
+
catalogVersion,
|
|
597
|
+
endpoints: [...endpoints.values()].filter((endpoint) => !dormant.has(endpoint.endpointId) && !isExpired(endpoint, now)).map(copyEndpoint),
|
|
598
|
+
dormantEndpointIds: [...dormant].sort()
|
|
599
|
+
});
|
|
600
|
+
return {
|
|
601
|
+
apply(signal, now = Date.now()) {
|
|
602
|
+
if (!isObject(signal) || typeof signal.type !== "string" || typeof signal.msgId !== "string") return { applied: false, reason: "invalid-message" };
|
|
603
|
+
if (seen.has(signal.msgId)) return { applied: false, reason: "duplicate-message" };
|
|
604
|
+
if (signal.expiresAt != null && Number(signal.expiresAt) < now) return { applied: false, reason: "expired" };
|
|
605
|
+
const signalCatalogVersion = Number(signal.catalogVersion ?? 0);
|
|
606
|
+
if (!Number.isFinite(signalCatalogVersion)) return { applied: false, reason: "invalid-message" };
|
|
607
|
+
if (signalCatalogVersion < catalogVersion) return { applied: false, reason: "stale-catalog" };
|
|
608
|
+
const result = applyFresh(signal, now);
|
|
609
|
+
if (result.applied) {
|
|
610
|
+
seen.add(signal.msgId);
|
|
611
|
+
if (signal.type !== "heartbeat") {
|
|
612
|
+
catalogVersion = Math.max(catalogVersion, signalCatalogVersion);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return result;
|
|
616
|
+
},
|
|
617
|
+
snapshot,
|
|
618
|
+
toCatalog(now = Date.now()) {
|
|
619
|
+
const current = snapshot(now);
|
|
620
|
+
return { ...catalog, multicast: { ...catalog.multicast, version: current.catalogVersion, endpoints: current.endpoints } };
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
function applyFresh(signal, now) {
|
|
624
|
+
switch (signal.type) {
|
|
625
|
+
case "snapshot": {
|
|
626
|
+
const next = signal.payload?.endpoints;
|
|
627
|
+
if (!Array.isArray(next)) return { applied: false, reason: "invalid-message" };
|
|
628
|
+
const normalized = next.map((endpoint, index) => normalizeEndpoint(endpoint, index));
|
|
629
|
+
if (normalized.some((endpoint) => !endpoint || !isValidEndpoint(endpoint, options.catalog))) return { applied: false, reason: "invalid-endpoint" };
|
|
630
|
+
const ids = new Set(normalized.map((endpoint) => endpoint.endpointId));
|
|
631
|
+
if (ids.size !== normalized.length) return { applied: false, reason: "duplicate-endpoint" };
|
|
632
|
+
endpoints.clear();
|
|
633
|
+
versions.clear();
|
|
634
|
+
dormant.clear();
|
|
635
|
+
for (const endpoint of normalized) {
|
|
636
|
+
versions.set(endpoint.endpointId, endpoint.endpointVersion);
|
|
637
|
+
if (!isExpired(endpoint, now)) endpoints.set(endpoint.endpointId, copyEndpoint(endpoint));
|
|
638
|
+
}
|
|
639
|
+
return { applied: true, action: "snapshot" };
|
|
640
|
+
}
|
|
641
|
+
case "add":
|
|
642
|
+
return upsert(signal.payload?.endpoint, now);
|
|
643
|
+
case "update": {
|
|
644
|
+
const id = signal.payload?.endpointId;
|
|
645
|
+
const fields = signal.payload?.fields;
|
|
646
|
+
if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
|
|
647
|
+
if (isStale(id, signal.payload?.endpointVersion)) return { applied: false, reason: "stale-endpoint" };
|
|
648
|
+
if (!isObject(fields) || ["protocol", "sourceAddress", "groupAddress", "port"].some((key) => Object.hasOwn(fields, key))) return { applied: false, reason: "invalid-update" };
|
|
649
|
+
return upsert({ ...endpoints.get(id), ...fields, endpointVersion: signal.payload.endpointVersion }, now);
|
|
650
|
+
}
|
|
651
|
+
case "remove": {
|
|
652
|
+
const id = signal.payload?.endpointId;
|
|
653
|
+
if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
|
|
654
|
+
const endpointVersion = Number(signal.payload?.endpointVersion);
|
|
655
|
+
if (!Number.isFinite(endpointVersion)) return { applied: false, reason: "invalid-message" };
|
|
656
|
+
if (isStale(id, endpointVersion)) return { applied: false, reason: "stale-endpoint" };
|
|
657
|
+
endpoints.delete(id);
|
|
658
|
+
dormant.delete(id);
|
|
659
|
+
versions.set(id, endpointVersion);
|
|
660
|
+
return { applied: true, action: "drop" };
|
|
661
|
+
}
|
|
662
|
+
case "invalidate": {
|
|
663
|
+
const id = signal.payload?.endpointId;
|
|
664
|
+
if (!endpoints.has(id)) return { applied: false, reason: "unknown-endpoint" };
|
|
665
|
+
dormant.add(id);
|
|
666
|
+
return { applied: true, action: "drop" };
|
|
667
|
+
}
|
|
668
|
+
case "heartbeat":
|
|
669
|
+
return { applied: true, action: "heartbeat" };
|
|
670
|
+
default:
|
|
671
|
+
return { applied: false, reason: "unknown-message-type" };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function upsert(value, now) {
|
|
675
|
+
const endpoint = normalizeEndpoint(value, endpoints.size);
|
|
676
|
+
if (!endpoint || !isValidEndpoint(endpoint, options.catalog)) return { applied: false, reason: "invalid-endpoint" };
|
|
677
|
+
if (isStale(endpoint.endpointId, endpoint.endpointVersion)) return { applied: false, reason: "stale-endpoint" };
|
|
678
|
+
versions.set(endpoint.endpointId, endpoint.endpointVersion);
|
|
679
|
+
dormant.delete(endpoint.endpointId);
|
|
680
|
+
if (isExpired(endpoint, now)) endpoints.delete(endpoint.endpointId);
|
|
681
|
+
else endpoints.set(endpoint.endpointId, copyEndpoint(endpoint));
|
|
682
|
+
return { applied: true, action: isExpired(endpoint, now) ? "drop" : "candidate" };
|
|
683
|
+
}
|
|
684
|
+
function isStale(id, version) {
|
|
685
|
+
const current = versions.get(id);
|
|
686
|
+
return current !== void 0 && Number(version) <= current;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
function normalizeCatalogEndpoints(catalog) {
|
|
690
|
+
const endpoints = catalog?.multicast?.endpoints;
|
|
691
|
+
if (!Array.isArray(endpoints)) return [];
|
|
692
|
+
return endpoints.map(normalizeEndpoint).filter((endpoint) => !!endpoint && isValidEndpoint(endpoint, catalog));
|
|
693
|
+
}
|
|
694
|
+
function normalizeEndpoint(value, index) {
|
|
695
|
+
if (!isObject(value)) return void 0;
|
|
696
|
+
const endpoint = value;
|
|
697
|
+
const tracks = Array.isArray(endpoint.tracks) ? endpoint.tracks.map((track) => typeof track === "string" ? { name: track } : { ...track }).filter((track) => track.name) : [];
|
|
698
|
+
return { ...endpoint, endpointId: endpoint.endpointId || `${endpoint.sourceAddress || ""}|${endpoint.groupAddress || ""}|${endpoint.port || ""}|${index}`, endpointVersion: Number(endpoint.endpointVersion ?? 0), tracks };
|
|
699
|
+
}
|
|
700
|
+
function isValidEndpoint(endpoint, catalog) {
|
|
701
|
+
if (!endpoint.endpointId || !Number.isFinite(endpoint.endpointVersion)) return false;
|
|
702
|
+
if (!["ssm", "asm", "amt"].includes(endpoint.protocol)) return false;
|
|
703
|
+
if (endpoint.protocol === "ssm" && (typeof endpoint.sourceAddress !== "string" || endpoint.sourceAddress.trim().length === 0)) return false;
|
|
704
|
+
if (!isValidMulticastGroupAddress(endpoint.groupAddress) || !Number.isInteger(Number(endpoint.port)) || endpoint.port <= 0 || endpoint.port > 65535 || !endpoint.tracks.length) return false;
|
|
705
|
+
const catalogTracks = /* @__PURE__ */ new Set([...Object.keys(catalog?.video?.renditions ?? {}), ...Object.keys(catalog?.audio?.renditions ?? {})]);
|
|
706
|
+
return endpoint.tracks.every((track) => !!track.name && isMulticastTrackPacketId(track.packetId) && (catalogTracks.size === 0 || catalogTracks.has(track.name) || catalogTracks.has(track.name.replace(/\/repair$/, ""))));
|
|
707
|
+
}
|
|
708
|
+
function copyEndpoint(endpoint) {
|
|
709
|
+
return { ...endpoint, tracks: endpoint.tracks.map((track) => ({ ...track })) };
|
|
710
|
+
}
|
|
711
|
+
function isExpired(endpoint, now) {
|
|
712
|
+
return endpoint.validUntil != null && Number(endpoint.validUntil) < now;
|
|
713
|
+
}
|
|
714
|
+
function isObject(value) {
|
|
715
|
+
return value !== null && typeof value === "object";
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// src/multicast-catalog-policy.ts
|
|
719
|
+
function hasMulticastCatalogBaseline(catalog) {
|
|
720
|
+
const multicast = catalog?.multicast;
|
|
721
|
+
return normalizeCatalogEndpoints(catalog).length > 0 || Boolean(multicast?.groupAddress && multicast?.port);
|
|
722
|
+
}
|
|
723
|
+
function hasUnicastMediaRenditions(catalog) {
|
|
724
|
+
return Object.keys(catalog?.video?.renditions ?? {}).length > 0 || Object.keys(catalog?.audio?.renditions ?? {}).length > 0;
|
|
725
|
+
}
|
|
726
|
+
function isMulticastOnlyCatalog(catalog) {
|
|
727
|
+
return hasMulticastCatalogBaseline(catalog) && !hasUnicastMediaRenditions(catalog);
|
|
728
|
+
}
|
|
729
|
+
function requiresMulticastCapability(catalog, capabilities = {}) {
|
|
730
|
+
return isMulticastOnlyCatalog(catalog) && !capabilities.multicast;
|
|
731
|
+
}
|
|
732
|
+
function isRecoverableMulticastSignalReadError(error) {
|
|
733
|
+
const candidate = error;
|
|
734
|
+
const name = String(candidate?.name ?? "");
|
|
735
|
+
const message = String(candidate?.message ?? error ?? "");
|
|
736
|
+
return name === "SyntaxError" || /not valid JSON|unexpected token|JSON\.parse|encoded data was not valid|malformed/i.test(message);
|
|
737
|
+
}
|
|
738
|
+
function withMulticastEndpoints(catalog, endpoints, version) {
|
|
739
|
+
if (!catalog?.multicast) return catalog;
|
|
740
|
+
return {
|
|
741
|
+
...catalog,
|
|
742
|
+
multicast: {
|
|
743
|
+
...catalog.multicast,
|
|
744
|
+
version,
|
|
745
|
+
endpoints: endpoints.map(copyEndpoint2)
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
function selectMulticastCandidate(catalog, endpoints = normalizeCatalogEndpoints(catalog), search = currentSearchParams()) {
|
|
750
|
+
const params = search instanceof URLSearchParams ? search : new URLSearchParams(search);
|
|
751
|
+
return selectMulticastCandidateForTracks(catalog, endpoints, {
|
|
752
|
+
containerKind: params.get("packaging") || params.get("container") || "mmtp",
|
|
753
|
+
rendition: params.get("rendition") || void 0
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
function selectMulticastCandidateForTracks(catalog, endpoints = normalizeCatalogEndpoints(catalog), selection = {}) {
|
|
757
|
+
const packaging = selection.containerKind || "mmtp";
|
|
758
|
+
const renditionTrack = selection.rendition || "";
|
|
759
|
+
const preferredTrack = packaging === "mmtp" ? "video" : `video.${packaging}`;
|
|
760
|
+
const usableEndpoints = endpoints.filter(isUsableMulticastEndpoint);
|
|
761
|
+
const defaultRenditionTrack = renditionTrack ? "" : lowestServedVideoRendition(catalog, usableEndpoints);
|
|
762
|
+
return renditionTrack && usableEndpoints.find((endpoint) => endpoint.tracks?.some((track) => matchesPreferredTrack(track.name, renditionTrack))) || defaultRenditionTrack && usableEndpoints.find((endpoint) => endpoint.tracks?.some((track) => matchesPreferredTrack(track.name, defaultRenditionTrack))) || usableEndpoints.find((endpoint) => endpoint.tracks?.some((track) => matchesPreferredTrack(track.name, preferredTrack))) || usableEndpoints[0] || null;
|
|
763
|
+
}
|
|
764
|
+
function endpointToMulticastConfig(endpoint, catalog, selection) {
|
|
765
|
+
if (!endpoint || !isUsableMulticastEndpoint(endpoint) || advertisesInvalidPacketId(endpoint)) return null;
|
|
766
|
+
const config = {
|
|
767
|
+
group: endpoint.groupAddress,
|
|
768
|
+
source: endpoint.sourceAddress || "",
|
|
769
|
+
port: Number(endpoint.port)
|
|
770
|
+
};
|
|
771
|
+
const networkSource = catalog?.multicast?.networkSource;
|
|
772
|
+
const amt = findAmtRelay(networkSource);
|
|
773
|
+
if (amt) {
|
|
774
|
+
config.mode = "tunnel";
|
|
775
|
+
config.amtRelay = amt.relay;
|
|
776
|
+
config.amtRelayPort = amt.port;
|
|
777
|
+
} else if (endpoint.protocol === "amt") {
|
|
778
|
+
const amtEntries = amtNetworkSourceEntries(networkSource);
|
|
779
|
+
if (amtEntries.length > 0) config.amtNetworkSource = amtEntries;
|
|
780
|
+
}
|
|
781
|
+
const packetIds = selection === void 0 ? selectEndpointPacketIds(endpoint, catalog) : selectEndpointPacketIdsForTracks(endpoint, catalog, selection);
|
|
782
|
+
if (packetIds === null) return null;
|
|
783
|
+
if (packetIds.length > 0) config.packetIds = packetIds;
|
|
784
|
+
return config;
|
|
785
|
+
}
|
|
786
|
+
function selectEndpointPacketIds(endpoint, catalog, search = currentSearchParams()) {
|
|
787
|
+
const params = search instanceof URLSearchParams ? search : new URLSearchParams(search);
|
|
788
|
+
return selectEndpointPacketIdsForTracks(endpoint, catalog, {
|
|
789
|
+
containerKind: params.get("packaging") || params.get("container") || "mmtp",
|
|
790
|
+
rendition: params.get("rendition") || void 0
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
function selectEndpointPacketIdsForTracks(endpoint, catalog, selection = {}) {
|
|
794
|
+
if (!endpoint || !Array.isArray(endpoint.tracks)) return [];
|
|
795
|
+
if (advertisesInvalidPacketId(endpoint)) return null;
|
|
796
|
+
const tracks = endpoint.tracks.map((track) => ({
|
|
797
|
+
name: typeof track?.name === "string" ? track.name : "",
|
|
798
|
+
packetId: validPacketId(track?.packetId)
|
|
799
|
+
})).filter((track) => track.name.length > 0 && track.packetId !== void 0);
|
|
800
|
+
if (tracks.length === 0) return [];
|
|
801
|
+
const packaging = selection.containerKind || "mmtp";
|
|
802
|
+
const renditionTrack = selection.rendition || "";
|
|
803
|
+
const videoRenditions = catalogRenditions(catalog, "video");
|
|
804
|
+
const audioRenditions = catalogRenditions(catalog, "audio");
|
|
805
|
+
const videoTracks = tracks.filter((track) => Object.hasOwn(videoRenditions, track.name));
|
|
806
|
+
const audioTracks = tracks.filter((track) => Object.hasOwn(audioRenditions, track.name));
|
|
807
|
+
const preferredVideo = packaging === "mmtp" ? "video" : `video.${packaging}`;
|
|
808
|
+
const preferredAudio = packaging === "mmtp" ? "audio" : `audio.${packaging}`;
|
|
809
|
+
const video = (renditionTrack ? videoTracks.find((track) => track.name === renditionTrack) : void 0) ?? videoTracks.find((track) => track.name === preferredVideo) ?? [...videoTracks].sort((a, b) => videoRenditionRank(videoRenditions, a.name) - videoRenditionRank(videoRenditions, b.name))[0];
|
|
810
|
+
const audio = audioTracks.find((track) => track.name === preferredAudio) ?? audioTracks[0];
|
|
811
|
+
const selected = [video, audio].filter(
|
|
812
|
+
(track) => track !== void 0
|
|
813
|
+
);
|
|
814
|
+
if (selected.length === 0) return normalizePacketIds(tracks.map((track) => track.packetId));
|
|
815
|
+
const selectedRepairNames = new Set(selected.flatMap((track) => catalogRepairTracks(catalog, track.name)));
|
|
816
|
+
const selectedRepairTracks = tracks.filter((track) => selectedRepairNames.has(track.name));
|
|
817
|
+
return normalizePacketIds([...selected, ...selectedRepairTracks].map((track) => track.packetId));
|
|
818
|
+
}
|
|
819
|
+
function readPlayerMulticastConfig(search = currentSearchParams()) {
|
|
820
|
+
const params = search instanceof URLSearchParams ? search : new URLSearchParams(search || "");
|
|
821
|
+
const group = params.get("mc_group") || "";
|
|
822
|
+
if (!isValidMulticastGroupAddress(group)) return null;
|
|
823
|
+
const port = positivePort2(params.get("mc_port"));
|
|
824
|
+
if (!port) return null;
|
|
825
|
+
assertNoRetiredMulticastUrlParams(params);
|
|
826
|
+
const mode = params.get("mc_mode") || "";
|
|
827
|
+
const amtRelay = params.get("mc_amt_relay") || "";
|
|
828
|
+
const amtRelayPort = positivePort2(params.get("mc_amt_relay_port"));
|
|
829
|
+
const config = {
|
|
830
|
+
group,
|
|
831
|
+
source: params.get("mc_source") || "",
|
|
832
|
+
port
|
|
833
|
+
};
|
|
834
|
+
if (mode) config.mode = mode;
|
|
835
|
+
if (amtRelay) {
|
|
836
|
+
config.amtRelay = amtRelay;
|
|
837
|
+
if (amtRelay.trim()) config.mode = "tunnel";
|
|
838
|
+
}
|
|
839
|
+
if (amtRelayPort) config.amtRelayPort = amtRelayPort;
|
|
840
|
+
return config;
|
|
841
|
+
}
|
|
842
|
+
function normalizeMulticastConfig(config) {
|
|
843
|
+
if (!config?.group) return null;
|
|
844
|
+
if (Array.isArray(config.packetIds) && !config.packetIds.every(isMulticastTrackPacketId)) return null;
|
|
845
|
+
const amt = findAmtRelay(config.amtNetworkSource);
|
|
846
|
+
const amtRelay = config.amtRelay || amt?.relay || "";
|
|
847
|
+
const dialableRelay = amtRelay.trim();
|
|
848
|
+
const amtRelayPort = positivePort2(String(config.amtRelayPort ?? "")) ?? (dialableRelay ? amt?.port ?? DEFAULT_AMT_RELAY_PORT : void 0);
|
|
849
|
+
const packetIds = normalizePacketIds(config.packetIds);
|
|
850
|
+
const pendingAmt = dialableRelay ? [] : amtNetworkSourceEntries(config.amtNetworkSource);
|
|
851
|
+
return {
|
|
852
|
+
group: config.group,
|
|
853
|
+
source: config.source || "",
|
|
854
|
+
port: Number(config.port),
|
|
855
|
+
mode: dialableRelay ? "tunnel" : config.mode || "auto",
|
|
856
|
+
amtRelay,
|
|
857
|
+
...amtRelayPort ? { amtRelayPort } : {},
|
|
858
|
+
// Relay-pending DRIAD survives normalization while it is still pending:
|
|
859
|
+
// dropping it here would erase, one hop before the join, the only thing
|
|
860
|
+
// telling the call site "AMT was asked for and is unresolved" apart from
|
|
861
|
+
// "this endpoint was always native" (BLO-38184). Once a relay resolves
|
|
862
|
+
// the entries have done their job and are not carried. `amtNetworkSource`
|
|
863
|
+
// is deliberately not part of `sameMulticastConfig`: it is provenance for
|
|
864
|
+
// the relay, not endpoint identity, so it must not report a change.
|
|
865
|
+
//
|
|
866
|
+
// That holds only while no caller acts on the field — today a pending
|
|
867
|
+
// DRIAD entry and a native endpoint both join `ip-multicast`, so
|
|
868
|
+
// comparing equal reports the truth. Once a caller resolves DRIAD and
|
|
869
|
+
// tunnels, a catalog swapping native for DRIAD-AMT becomes a real
|
|
870
|
+
// join-behaviour change that would compare equal and never re-join:
|
|
871
|
+
// whoever wires that resolution adds `amtNetworkSource` to
|
|
872
|
+
// `sameMulticastConfig` in the same change.
|
|
873
|
+
...pendingAmt.length > 0 ? { amtNetworkSource: pendingAmt } : {},
|
|
874
|
+
...packetIds.length > 0 ? { packetIds } : {}
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
function sameMulticastConfig(a, b) {
|
|
878
|
+
const left = normalizeMulticastConfig(a);
|
|
879
|
+
const right = normalizeMulticastConfig(b);
|
|
880
|
+
return Boolean(left && right && left.group === right.group && left.source === right.source && left.port === right.port && left.mode === right.mode && left.amtRelay === right.amtRelay && (left.amtRelayPort ?? 0) === (right.amtRelayPort ?? 0) && JSON.stringify(left.packetIds ?? []) === JSON.stringify(right.packetIds ?? []));
|
|
881
|
+
}
|
|
882
|
+
function urlOverrideBlocksMulticastCandidate(candidateConfig, urlConfig = readPlayerMulticastConfig()) {
|
|
883
|
+
if (!urlConfig) return false;
|
|
884
|
+
const candidate = normalizeMulticastConfig(candidateConfig);
|
|
885
|
+
const explicit = normalizeMulticastConfig(urlConfig);
|
|
886
|
+
return !candidate || !explicit || candidate.group !== explicit.group || candidate.source !== explicit.source || candidate.port !== explicit.port;
|
|
887
|
+
}
|
|
888
|
+
function mergeUrlMulticastPolicy(candidateConfig, urlConfig = readPlayerMulticastConfig()) {
|
|
889
|
+
const candidate = normalizeMulticastConfig(candidateConfig);
|
|
890
|
+
if (!candidate || !urlConfig || urlOverrideBlocksMulticastCandidate(candidate, urlConfig)) return candidate;
|
|
891
|
+
const merged = { ...candidate };
|
|
892
|
+
if (urlConfig.mode !== void 0) {
|
|
893
|
+
merged.mode = urlConfig.mode;
|
|
894
|
+
if (urlConfig.mode !== "tunnel" && urlConfig.amtRelay === void 0) {
|
|
895
|
+
merged.amtRelay = "";
|
|
896
|
+
delete merged.amtRelayPort;
|
|
897
|
+
delete merged.amtNetworkSource;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (urlConfig.amtRelay !== void 0) {
|
|
901
|
+
merged.amtRelay = urlConfig.amtRelay;
|
|
902
|
+
delete merged.amtRelayPort;
|
|
903
|
+
if (urlConfig.amtRelay.trim()) {
|
|
904
|
+
merged.mode = "tunnel";
|
|
905
|
+
delete merged.amtNetworkSource;
|
|
906
|
+
} else if (urlConfig.mode === void 0 && candidate.amtRelay?.trim()) {
|
|
907
|
+
merged.mode = "auto";
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
if (urlConfig.amtRelayPort !== void 0) merged.amtRelayPort = urlConfig.amtRelayPort;
|
|
911
|
+
return normalizeMulticastConfig(merged);
|
|
912
|
+
}
|
|
913
|
+
function currentSearchParams() {
|
|
914
|
+
return typeof location === "undefined" ? new URLSearchParams() : new URLSearchParams(location.search);
|
|
915
|
+
}
|
|
916
|
+
function positivePort2(value) {
|
|
917
|
+
const port = Number(value);
|
|
918
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
|
|
919
|
+
}
|
|
920
|
+
function copyEndpoint2(endpoint) {
|
|
921
|
+
return { ...endpoint, tracks: endpoint.tracks.map((track) => ({ ...track })) };
|
|
922
|
+
}
|
|
923
|
+
function amtNetworkSourceEntries(networkSource) {
|
|
924
|
+
const sources = Array.isArray(networkSource) ? networkSource : networkSource ? [networkSource] : [];
|
|
925
|
+
return sources.filter((entry) => entry?.type === "amt").map((entry) => ({ ...entry }));
|
|
926
|
+
}
|
|
927
|
+
function findAmtRelay(networkSource) {
|
|
928
|
+
const source = amtNetworkSourceEntries(networkSource).find((candidate) => candidate?.discovery !== "driad" && typeof candidate?.relay === "string" && candidate.relay.trim());
|
|
929
|
+
if (!source) return void 0;
|
|
930
|
+
const port = positivePort2(String(source.port ?? ""));
|
|
931
|
+
return { relay: source.relay.trim(), port: port ?? DEFAULT_AMT_RELAY_PORT };
|
|
932
|
+
}
|
|
933
|
+
function validPacketId(value) {
|
|
934
|
+
return isMulticastTrackPacketId(value) ? value : void 0;
|
|
935
|
+
}
|
|
936
|
+
function advertisesInvalidPacketId(endpoint) {
|
|
937
|
+
return Array.isArray(endpoint.tracks) && endpoint.tracks.some((track) => track?.packetId !== void 0 && !isMulticastTrackPacketId(track.packetId));
|
|
938
|
+
}
|
|
939
|
+
function normalizePacketIds(packetIds) {
|
|
940
|
+
if (!Array.isArray(packetIds)) return [];
|
|
941
|
+
return [...new Set(packetIds.map(validPacketId).filter((id) => id !== void 0))].sort(
|
|
942
|
+
(a, b) => a - b
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
function matchesPreferredTrack(trackName, preferredTrack) {
|
|
946
|
+
return trackName === preferredTrack || trackName.startsWith(`${preferredTrack}/`);
|
|
947
|
+
}
|
|
948
|
+
function catalogRenditions(catalog, role) {
|
|
949
|
+
const renditions = { ...catalog?.[role]?.renditions ?? {} };
|
|
950
|
+
for (const track of Array.isArray(catalog?.tracks) ? catalog.tracks : []) {
|
|
951
|
+
if (track?.role === role && typeof track.name === "string" && track.name.length > 0) {
|
|
952
|
+
renditions[track.name] = track;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return renditions;
|
|
956
|
+
}
|
|
957
|
+
function assertNoRetiredTrackFields(catalog) {
|
|
958
|
+
if (catalog && typeof catalog === "object" && "commonTrackFields" in catalog) {
|
|
959
|
+
throw new Error("multicast catalog policy: $.commonTrackFields is retired; every track must declare its own packaging");
|
|
960
|
+
}
|
|
961
|
+
const tracks = Array.isArray(catalog?.tracks) ? catalog.tracks : [];
|
|
962
|
+
tracks.forEach((track, index) => {
|
|
963
|
+
if (track && typeof track === "object" && "selectionParams" in track) {
|
|
964
|
+
throw new Error(`multicast catalog policy: $.tracks[${index}].selectionParams is retired; selection fields are flat on the track`);
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
function catalogRepairTracks(catalog, name) {
|
|
969
|
+
assertNoRetiredTrackFields(catalog);
|
|
970
|
+
const tracks = Array.isArray(catalog?.tracks) ? catalog.tracks : [];
|
|
971
|
+
const track = tracks.find((candidate) => candidate?.name === name);
|
|
972
|
+
const rendition = catalog?.video?.renditions?.[name] ?? catalog?.audio?.renditions?.[name];
|
|
973
|
+
const fec = track?.fec ?? rendition?.fec;
|
|
974
|
+
if (!fec || fec.algorithm === "none") return [];
|
|
975
|
+
const names = /* @__PURE__ */ new Set();
|
|
976
|
+
if (typeof fec.repairTrack === "string" && fec.repairTrack.length > 0) names.add(fec.repairTrack);
|
|
977
|
+
const dependsOn = (entry) => Array.isArray(entry?.depends) && entry.depends.length === 1 && entry.depends[0] === name;
|
|
978
|
+
for (const candidate of tracks) {
|
|
979
|
+
const packaging = candidate?.packaging;
|
|
980
|
+
if (packaging === "fec-repair" && typeof candidate?.name === "string" && candidate.name.length > 0 && dependsOn(candidate)) {
|
|
981
|
+
names.add(candidate.name);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
const repairRenditions = catalog?.repair?.renditions;
|
|
985
|
+
if (repairRenditions && typeof repairRenditions === "object") {
|
|
986
|
+
for (const [repairName, entry] of Object.entries(repairRenditions)) {
|
|
987
|
+
if (repairName.length > 0 && dependsOn(entry)) names.add(repairName);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
return [...names];
|
|
991
|
+
}
|
|
992
|
+
function catalogQualityLadder(catalog, role = "video") {
|
|
993
|
+
return Object.entries(catalogRenditions(catalog, role)).map(([name, config]) => {
|
|
994
|
+
const { bitrate, codedWidth, codedHeight, framerate } = config ?? {};
|
|
995
|
+
if (!isPositiveFinite(bitrate)) {
|
|
996
|
+
throw new RangeError(`[ABR] catalog ${role} rendition ${name} states no positive bitrate to cost it by`);
|
|
997
|
+
}
|
|
998
|
+
const rung = { name, bitrate };
|
|
999
|
+
if (isPositiveFinite(codedWidth)) rung.width = codedWidth;
|
|
1000
|
+
if (isPositiveFinite(codedHeight)) rung.height = codedHeight;
|
|
1001
|
+
if (isPositiveFinite(framerate)) rung.fps = framerate;
|
|
1002
|
+
return rung;
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
function isPositiveFinite(value) {
|
|
1006
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
1007
|
+
}
|
|
1008
|
+
function videoRenditionRank(config, name) {
|
|
1009
|
+
if (typeof config?.bitrate === "number" && config.bitrate > 0) return config.bitrate;
|
|
1010
|
+
const width = typeof config?.codedWidth === "number" ? config.codedWidth : 0;
|
|
1011
|
+
const height = typeof config?.codedHeight === "number" ? config.codedHeight : 0;
|
|
1012
|
+
if (width > 0 && height > 0) return width * height;
|
|
1013
|
+
const match = /(?:^|[/_.-])(\d{3,4})p(?:$|[/_.-])/i.exec(name);
|
|
1014
|
+
return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
|
|
1015
|
+
}
|
|
1016
|
+
function lowestServedVideoRendition(catalog, endpoints) {
|
|
1017
|
+
const renditions = catalog?.video?.renditions ?? {};
|
|
1018
|
+
const servedTracks = new Set(endpoints.flatMap((endpoint) => endpoint.tracks?.map((track) => track.name) ?? []));
|
|
1019
|
+
let selected = "";
|
|
1020
|
+
let selectedRank = Number.POSITIVE_INFINITY;
|
|
1021
|
+
for (const [name, config] of Object.entries(renditions)) {
|
|
1022
|
+
if (!name || !servedTracks.has(name)) continue;
|
|
1023
|
+
const rank = videoRenditionRank(config, name);
|
|
1024
|
+
if (!selected || rank < selectedRank) {
|
|
1025
|
+
selected = name;
|
|
1026
|
+
selectedRank = rank;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return selected;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// src/packet-batch.ts
|
|
1033
|
+
function subscriptionDeliversPacketBatches(subscription) {
|
|
1034
|
+
return subscription.eventTypes?.includes("packets") === true;
|
|
1035
|
+
}
|
|
358
1036
|
|
|
359
1037
|
// src/multicast-subscription-pool.ts
|
|
360
1038
|
var MulticastSubscriptionPool = class {
|
|
@@ -420,19 +1098,28 @@
|
|
|
420
1098
|
return this.#entries.size;
|
|
421
1099
|
}
|
|
422
1100
|
async #createEntry(gateway, endpoint, onClosed) {
|
|
423
|
-
const subscription = await gateway.subscribe({
|
|
1101
|
+
const subscription = await gateway.subscribe(toWindowMulticastSubscribeConfig({
|
|
424
1102
|
group: endpoint.groupAddress,
|
|
425
1103
|
...endpoint.sourceAddress ? { source: endpoint.sourceAddress } : {},
|
|
426
1104
|
port: endpoint.port,
|
|
427
|
-
consume: "bytes",
|
|
428
1105
|
...endpoint.mode ? { mode: endpoint.mode } : {},
|
|
429
1106
|
...endpoint.amtRelay ? { amtRelay: endpoint.amtRelay } : {},
|
|
430
1107
|
...endpoint.amtRelayPort ? { amtRelayPort: endpoint.amtRelayPort } : {}
|
|
431
|
-
});
|
|
1108
|
+
}));
|
|
432
1109
|
const consumers = /* @__PURE__ */ new Map();
|
|
433
1110
|
let entry;
|
|
434
|
-
const
|
|
435
|
-
|
|
1111
|
+
const packetListener = subscriptionDeliversPacketBatches(subscription) ? {
|
|
1112
|
+
type: "packets",
|
|
1113
|
+
listener: (event) => {
|
|
1114
|
+
for (const data of event.detail) {
|
|
1115
|
+
this.#dispatch(consumers, (consumer) => consumer.onBytes(data));
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
} : {
|
|
1119
|
+
type: "bytes",
|
|
1120
|
+
listener: (event) => {
|
|
1121
|
+
this.#dispatch(consumers, (consumer) => consumer.onBytes(event.detail));
|
|
1122
|
+
}
|
|
436
1123
|
};
|
|
437
1124
|
const stateListener = (event) => {
|
|
438
1125
|
this.#dispatch(consumers, (consumer) => consumer.onState?.(event.detail));
|
|
@@ -445,8 +1132,12 @@
|
|
|
445
1132
|
const error = event.detail instanceof Error ? event.detail : new Error(String(event.detail));
|
|
446
1133
|
this.#dispatch(consumers, (consumer) => consumer.onError?.(error));
|
|
447
1134
|
};
|
|
448
|
-
entry = { subscription, consumers,
|
|
449
|
-
|
|
1135
|
+
entry = { subscription, consumers, packetListener, stateListener, errorListener, closed: false };
|
|
1136
|
+
if (packetListener.type === "packets") {
|
|
1137
|
+
subscription.addEventListener("packets", packetListener.listener);
|
|
1138
|
+
} else {
|
|
1139
|
+
subscription.addEventListener("bytes", packetListener.listener);
|
|
1140
|
+
}
|
|
450
1141
|
subscription.addEventListener("state", stateListener);
|
|
451
1142
|
subscription.addEventListener("error", errorListener);
|
|
452
1143
|
return entry;
|
|
@@ -461,7 +1152,12 @@
|
|
|
461
1152
|
return id;
|
|
462
1153
|
}
|
|
463
1154
|
#detach(entry) {
|
|
464
|
-
|
|
1155
|
+
const { packetListener } = entry;
|
|
1156
|
+
if (packetListener.type === "packets") {
|
|
1157
|
+
entry.subscription.removeEventListener("packets", packetListener.listener);
|
|
1158
|
+
} else {
|
|
1159
|
+
entry.subscription.removeEventListener("bytes", packetListener.listener);
|
|
1160
|
+
}
|
|
465
1161
|
entry.subscription.removeEventListener("state", entry.stateListener);
|
|
466
1162
|
entry.subscription.removeEventListener("error", entry.errorListener);
|
|
467
1163
|
}
|
|
@@ -477,7 +1173,70 @@
|
|
|
477
1173
|
};
|
|
478
1174
|
var multicastSubscriptionPool = new MulticastSubscriptionPool();
|
|
479
1175
|
|
|
1176
|
+
// src/moqt-priority.ts
|
|
1177
|
+
var MAX_MOQT_PRIORITY = 255;
|
|
1178
|
+
function moqtPriority(value, field) {
|
|
1179
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_MOQT_PRIORITY) {
|
|
1180
|
+
throw new RangeError(`${field} must be an integer in [0, ${MAX_MOQT_PRIORITY}], got ${value}`);
|
|
1181
|
+
}
|
|
1182
|
+
return value;
|
|
1183
|
+
}
|
|
1184
|
+
function moqtSubscriberPriority(value) {
|
|
1185
|
+
return moqtPriority(value, "MOQT subscriber priority");
|
|
1186
|
+
}
|
|
1187
|
+
function moqtPublisherPriority(value) {
|
|
1188
|
+
return moqtPriority(value, "MOQT publisher priority");
|
|
1189
|
+
}
|
|
1190
|
+
var MOQT_SUBSCRIBER_PRIORITY = Object.freeze({
|
|
1191
|
+
catalog: moqtSubscriberPriority(0),
|
|
1192
|
+
/**
|
|
1193
|
+
* @deprecated A repair FETCH's SUBSCRIBER_PRIORITY is derived per request
|
|
1194
|
+
* from the catalog source and repair priorities (draft-ramadan-moq-fec
|
|
1195
|
+
* §11.4.5), not a player constant. Use the `subscriberPriority` that
|
|
1196
|
+
* `@blockcast/mmt-container`'s `wireRepairFetch` puts on each
|
|
1197
|
+
* `RepairFetchRequest` (`repairFetchSubscriberPriority`).
|
|
1198
|
+
*/
|
|
1199
|
+
deadlineRepair: moqtSubscriberPriority(0),
|
|
1200
|
+
audio: moqtSubscriberPriority(1),
|
|
1201
|
+
video: moqtSubscriberPriority(2),
|
|
1202
|
+
/**
|
|
1203
|
+
* @deprecated See `deadlineRepair`: use the request's catalog-derived
|
|
1204
|
+
* `RepairFetchRequest.subscriberPriority` (draft-ramadan-moq-fec §11.4.5).
|
|
1205
|
+
*/
|
|
1206
|
+
backgroundRepair: moqtSubscriberPriority(3)
|
|
1207
|
+
});
|
|
1208
|
+
var MOQT_PUBLISHER_PRIORITY = Object.freeze({
|
|
1209
|
+
catalog: moqtPublisherPriority(32),
|
|
1210
|
+
source: moqtPublisherPriority(128),
|
|
1211
|
+
repair: moqtPublisherPriority(240)
|
|
1212
|
+
});
|
|
1213
|
+
|
|
480
1214
|
// src/stall-detector.ts
|
|
1215
|
+
function classifyStall({
|
|
1216
|
+
liveQ = null,
|
|
1217
|
+
decodeCallsDelta = null,
|
|
1218
|
+
fps = null
|
|
1219
|
+
} = {}) {
|
|
1220
|
+
if (liveQ != null && liveQ >= 20) {
|
|
1221
|
+
return {
|
|
1222
|
+
verdict: "DECODE-BOUND",
|
|
1223
|
+
reason: `decode queue backlogged now (liveQ=${liveQ})`
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
if (liveQ != null && liveQ <= 2 && decodeCallsDelta != null && decodeCallsDelta <= 1) {
|
|
1227
|
+
return {
|
|
1228
|
+
verdict: "PIPELINE-STARVED",
|
|
1229
|
+
reason: `queue empty (liveQ=${liveQ}) and decode flatlined (\u0394calls=${decodeCallsDelta}) \u2014 nothing reaching the decoder; suspect delivery/FEC/failback, not decode perf`
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
if (fps != null && fps < 45) {
|
|
1233
|
+
return {
|
|
1234
|
+
verdict: "STARVED-OR-SLOW",
|
|
1235
|
+
reason: `low decode rate (fps=${fps}) without a queue/flatline signal to disambiguate`
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
return { verdict: "INDETERMINATE", reason: "no signal supports a verdict" };
|
|
1239
|
+
}
|
|
481
1240
|
var TransportStallDetector = class {
|
|
482
1241
|
constructor(config) {
|
|
483
1242
|
this.config = config;
|
|
@@ -553,13 +1312,39 @@
|
|
|
553
1312
|
}
|
|
554
1313
|
async function wireStarvationDetector(args) {
|
|
555
1314
|
if (!args.session || args.fecInterleaveMs <= 0 || args.fecK <= 0) return null;
|
|
1315
|
+
const windowMs = args.deliveryWindowMs ?? Math.ceil(args.fecInterleaveMs * 3);
|
|
1316
|
+
if (!Number.isFinite(windowMs) || windowMs <= 0) {
|
|
1317
|
+
args.warn?.(`Unable to create starvation detector for ${args.trackName}: invalid delivery window ${windowMs}`);
|
|
1318
|
+
return null;
|
|
1319
|
+
}
|
|
556
1320
|
const detector = wireStallDetector({
|
|
557
1321
|
session: args.session,
|
|
558
|
-
windowMs
|
|
1322
|
+
windowMs,
|
|
559
1323
|
makeDetector: args.makeDetector
|
|
560
1324
|
});
|
|
561
1325
|
if (!detector) args.warn?.(`Unable to create starvation detector for ${args.trackName}`);
|
|
562
|
-
return detector;
|
|
1326
|
+
if (!detector || typeof args.session.state !== "string") return detector;
|
|
1327
|
+
let active = false;
|
|
1328
|
+
return {
|
|
1329
|
+
update(snapshot) {
|
|
1330
|
+
const state = args.session.state;
|
|
1331
|
+
const shouldObserve = state === "multicast" || state === "amt" || state === "failing";
|
|
1332
|
+
if (!shouldObserve) {
|
|
1333
|
+
if (active) detector.reset();
|
|
1334
|
+
active = false;
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
if (!active) {
|
|
1338
|
+
detector.reset();
|
|
1339
|
+
active = true;
|
|
1340
|
+
}
|
|
1341
|
+
detector.update(snapshot);
|
|
1342
|
+
},
|
|
1343
|
+
reset() {
|
|
1344
|
+
active = false;
|
|
1345
|
+
detector.reset();
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
563
1348
|
}
|
|
564
1349
|
|
|
565
1350
|
// src/driad-discovery.ts
|
|
@@ -619,10 +1404,7 @@
|
|
|
619
1404
|
if (dohRelay) {
|
|
620
1405
|
return [dohRelay];
|
|
621
1406
|
}
|
|
622
|
-
return [
|
|
623
|
-
{ host: "amt-relay.m2icast.net", port: 2268, priority: 10, weight: 100, asn: 0 },
|
|
624
|
-
{ host: "amt.akamaistream.net", port: 2268, priority: 20, weight: 50, asn: 0 }
|
|
625
|
-
];
|
|
1407
|
+
return [];
|
|
626
1408
|
}
|
|
627
1409
|
/**
|
|
628
1410
|
* Query DNS-over-HTTPS for DRIAD relay based on source address (RFC 8777)
|
|
@@ -643,7 +1425,7 @@
|
|
|
643
1425
|
if (result.Answer && result.Answer.length > 0) {
|
|
644
1426
|
return {
|
|
645
1427
|
host: result.Answer[0].data,
|
|
646
|
-
port:
|
|
1428
|
+
port: DEFAULT_AMT_RELAY_PORT,
|
|
647
1429
|
priority: 10,
|
|
648
1430
|
weight: 100,
|
|
649
1431
|
asn: 0
|
|
@@ -713,16 +1495,14 @@
|
|
|
713
1495
|
capabilities = null;
|
|
714
1496
|
subscription = null;
|
|
715
1497
|
driad = new DRIADDiscovery();
|
|
716
|
-
|
|
1498
|
+
/** Built by `connect()` from the caller's ladder; there is no built-in one. */
|
|
1499
|
+
abr = null;
|
|
717
1500
|
multicastOrchestrator = null;
|
|
718
1501
|
onOrchestratorCommand;
|
|
719
1502
|
connectionGeneration = 0;
|
|
720
1503
|
onPacket;
|
|
721
1504
|
onError;
|
|
722
1505
|
onStateChange;
|
|
723
|
-
constructor() {
|
|
724
|
-
this.abr = new AdaptiveBitrateController();
|
|
725
|
-
}
|
|
726
1506
|
getWindow() {
|
|
727
1507
|
return typeof window === "undefined" ? void 0 : window;
|
|
728
1508
|
}
|
|
@@ -773,11 +1553,31 @@
|
|
|
773
1553
|
* 1. window.multicast gateway bytes
|
|
774
1554
|
* 2. AMT proxy (specified or discovered via DRIAD)
|
|
775
1555
|
* 3. MOQ WebTransport (final fallback)
|
|
1556
|
+
*
|
|
1557
|
+
* The ABR ladder (`abrLadder`) and initial rendition (`initialQuality`)
|
|
1558
|
+
* are checked first, before anything is torn down. A config that fails is
|
|
1559
|
+
* refused with the same shape as an invalid endpoint, the error to the
|
|
1560
|
+
* error callback and `false` resolved; unlike one, it is caught before the
|
|
1561
|
+
* live subscription closes, so a refused reconnect leaves that subscription,
|
|
1562
|
+
* its state and any connect already in flight untouched.
|
|
776
1563
|
*/
|
|
777
1564
|
async connect(config) {
|
|
1565
|
+
let abr;
|
|
1566
|
+
try {
|
|
1567
|
+
abr = new AdaptiveBitrateController({
|
|
1568
|
+
ladder: config.abrLadder,
|
|
1569
|
+
initialQuality: config.initialQuality?.name
|
|
1570
|
+
});
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
const refused = error instanceof Error ? error : new Error(String(error));
|
|
1573
|
+
if (this.onError) this.onError(refused);
|
|
1574
|
+
else console.error("[Transport] ABR config refused, not connecting:", refused);
|
|
1575
|
+
return false;
|
|
1576
|
+
}
|
|
778
1577
|
const generation = ++this.connectionGeneration;
|
|
779
1578
|
this.subscription?.close();
|
|
780
1579
|
this.subscription = null;
|
|
1580
|
+
this.abr = null;
|
|
781
1581
|
this.state = {
|
|
782
1582
|
mode: "disconnected",
|
|
783
1583
|
connected: false,
|
|
@@ -809,10 +1609,8 @@
|
|
|
809
1609
|
};
|
|
810
1610
|
const caps = await this.detectCapabilities();
|
|
811
1611
|
if (generation !== this.connectionGeneration) return false;
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
}
|
|
815
|
-
this.abr.setCallback((quality) => {
|
|
1612
|
+
this.abr = abr;
|
|
1613
|
+
abr.setCallback((quality) => {
|
|
816
1614
|
this.state.quality = quality;
|
|
817
1615
|
this.subscription?.onqualitychange?.(quality);
|
|
818
1616
|
this.onStateChange?.(this.state);
|
|
@@ -877,7 +1675,7 @@
|
|
|
877
1675
|
connected: true,
|
|
878
1676
|
group: config.group,
|
|
879
1677
|
source: config.source,
|
|
880
|
-
quality: this.
|
|
1678
|
+
quality: this.requireAbr().getCurrentQuality(),
|
|
881
1679
|
packetsReceived: 0,
|
|
882
1680
|
bytesReceived: 0,
|
|
883
1681
|
lastPacketTime: 0
|
|
@@ -923,8 +1721,7 @@
|
|
|
923
1721
|
async connectAmtProxy(config, amtRelay, generation) {
|
|
924
1722
|
try {
|
|
925
1723
|
const relayHost = amtRelay.includes(":") && !amtRelay.startsWith("[") ? `[${amtRelay}]` : amtRelay;
|
|
926
|
-
const
|
|
927
|
-
const wsUrl = new URL(`wss://${relayAuthority}/amt-proxy`);
|
|
1724
|
+
const wsUrl = new URL(`wss://${relayHost}/amt-proxy`);
|
|
928
1725
|
wsUrl.searchParams.set("group", config.group);
|
|
929
1726
|
wsUrl.searchParams.set("source", config.source || "");
|
|
930
1727
|
wsUrl.searchParams.set("port", String(config.port));
|
|
@@ -954,7 +1751,7 @@
|
|
|
954
1751
|
group: config.group,
|
|
955
1752
|
source: config.source,
|
|
956
1753
|
amtRelay,
|
|
957
|
-
quality: this.
|
|
1754
|
+
quality: this.requireAbr().getCurrentQuality(),
|
|
958
1755
|
packetsReceived: 0,
|
|
959
1756
|
bytesReceived: 0,
|
|
960
1757
|
lastPacketTime: 0
|
|
@@ -999,7 +1796,7 @@
|
|
|
999
1796
|
this.state.packetsReceived++;
|
|
1000
1797
|
this.state.bytesReceived += data.byteLength;
|
|
1001
1798
|
this.state.lastPacketTime = Date.now();
|
|
1002
|
-
this.abr
|
|
1799
|
+
this.abr?.recordSample(data.byteLength, 0);
|
|
1003
1800
|
this.onPacket?.(data);
|
|
1004
1801
|
}
|
|
1005
1802
|
/**
|
|
@@ -1009,16 +1806,26 @@
|
|
|
1009
1806
|
return { ...this.state };
|
|
1010
1807
|
}
|
|
1011
1808
|
/**
|
|
1012
|
-
*
|
|
1809
|
+
* The rung of the live connection's `abrLadder` ABR is on; `null` when
|
|
1810
|
+
* there is no ladder (before `connect(config)` succeeds, after
|
|
1811
|
+
* `disconnect()`, or after a reconnect that was torn down and bailed).
|
|
1013
1812
|
*/
|
|
1014
1813
|
getQuality() {
|
|
1015
|
-
return this.abr
|
|
1814
|
+
return this.abr?.getCurrentQuality() ?? null;
|
|
1016
1815
|
}
|
|
1017
1816
|
/**
|
|
1018
|
-
* Force
|
|
1817
|
+
* Force the rung named `name` of the live connection's `abrLadder`.
|
|
1818
|
+
* Throws `Error` when there is no ladder (see `getQuality()`), and
|
|
1819
|
+
* `RangeError` when the ladder has no rung of that name; it never falls
|
|
1820
|
+
* back to another rung.
|
|
1019
1821
|
*/
|
|
1020
1822
|
setQuality(name) {
|
|
1021
|
-
this.
|
|
1823
|
+
this.requireAbr().setQuality(name);
|
|
1824
|
+
}
|
|
1825
|
+
/** The controller `connect()` built; before it there is no ladder to adapt over. */
|
|
1826
|
+
requireAbr() {
|
|
1827
|
+
if (!this.abr) throw new Error("[Transport] ABR has no ladder before connect(config)");
|
|
1828
|
+
return this.abr;
|
|
1022
1829
|
}
|
|
1023
1830
|
/** Configures multicast-only ABR while leaving the caller's MoQ ABR untouched. */
|
|
1024
1831
|
configureMulticastTiers(options, onCommand) {
|
|
@@ -1030,6 +1837,9 @@
|
|
|
1030
1837
|
recordMulticastGoodput(bitrate) {
|
|
1031
1838
|
return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onGoodput({ bitrate }));
|
|
1032
1839
|
}
|
|
1840
|
+
recordMulticastHeadroom(healthy) {
|
|
1841
|
+
return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onHeadroom({ healthy }));
|
|
1842
|
+
}
|
|
1033
1843
|
recordMulticastTierPacket(packet) {
|
|
1034
1844
|
return this.dispatchOrchestratorCommand(this.multicastOrchestrator?.onTierPacket(packet));
|
|
1035
1845
|
}
|
|
@@ -1061,6 +1871,7 @@
|
|
|
1061
1871
|
this.connectionGeneration++;
|
|
1062
1872
|
this.subscription?.close();
|
|
1063
1873
|
this.subscription = null;
|
|
1874
|
+
this.abr = null;
|
|
1064
1875
|
this.dispatchOrchestratorCommand(this.multicastOrchestrator?.cancel());
|
|
1065
1876
|
this.multicastOrchestrator = null;
|
|
1066
1877
|
this.onOrchestratorCommand = void 0;
|
|
@@ -1127,8 +1938,21 @@
|
|
|
1127
1938
|
// src/no-modules.ts
|
|
1128
1939
|
var api = {
|
|
1129
1940
|
AdaptiveBitrateController,
|
|
1941
|
+
validateQualityLadder,
|
|
1942
|
+
catalogQualityLadder,
|
|
1943
|
+
classifyStall,
|
|
1944
|
+
createMulticastSignalingReconciler,
|
|
1945
|
+
endpointToMulticastConfig,
|
|
1946
|
+
hasMulticastCatalogBaseline,
|
|
1947
|
+
hasUnicastMediaRenditions,
|
|
1948
|
+
isRecoverableMulticastSignalReadError,
|
|
1949
|
+
isMulticastOnlyCatalog,
|
|
1130
1950
|
MulticastOrchestrator,
|
|
1131
1951
|
MulticastSubscriptionPool,
|
|
1952
|
+
MOQT_PUBLISHER_PRIORITY,
|
|
1953
|
+
MOQT_SUBSCRIBER_PRIORITY,
|
|
1954
|
+
moqtPublisherPriority,
|
|
1955
|
+
moqtSubscriberPriority,
|
|
1132
1956
|
TransportManager,
|
|
1133
1957
|
TransportStallDetector,
|
|
1134
1958
|
endpointKey,
|
|
@@ -1136,11 +1960,22 @@
|
|
|
1136
1960
|
isUsableMulticastEndpoint,
|
|
1137
1961
|
isValidMulticastGroupAddress,
|
|
1138
1962
|
makeSourceSwitchHandler,
|
|
1963
|
+
mergeUrlMulticastPolicy,
|
|
1139
1964
|
multicastEndpointKey,
|
|
1965
|
+
normalizeCatalogEndpoints,
|
|
1966
|
+
normalizeMulticastConfig,
|
|
1140
1967
|
normalizeMulticastEndpoint,
|
|
1968
|
+
readPlayerMulticastConfig,
|
|
1141
1969
|
readUrlMulticastConfig,
|
|
1970
|
+
toWindowMulticastSubscribeConfig,
|
|
1971
|
+
requiresMulticastCapability,
|
|
1142
1972
|
runUnicastFallback,
|
|
1973
|
+
sameMulticastConfig,
|
|
1974
|
+
selectEndpointPacketIds,
|
|
1975
|
+
selectMulticastCandidate,
|
|
1143
1976
|
selectMulticastTier,
|
|
1977
|
+
urlOverrideBlocksMulticastCandidate,
|
|
1978
|
+
withMulticastEndpoints,
|
|
1144
1979
|
wireStarvationDetector
|
|
1145
1980
|
};
|
|
1146
1981
|
Object.defineProperty(globalThis, "__MmtTransport", {
|