@camstack/addon-pipeline 1.2.288 → 1.2.291
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/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-DTvsx_iX.mjs → default-detection-model-DcTy-nJl.mjs} +1 -1
- package/dist/{default-detection-model-D5kMByfZ.js → default-detection-model-jjdmKKr3.js} +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-mEtwupOP.mjs → dist-Bn6BQW8q.mjs} +1692 -920
- package/dist/{dist-CegiJ2Pw.js → dist-D7FonEUB.js} +1709 -919
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-B8p-rV9H.mjs → node-BN0Na3HN.mjs} +1 -1
- package/dist/{node-C8jhiPW2.js → node-D3C1krEi.js} +1 -1
- package/dist/pipeline-runner/index.js +3 -3
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-C5bRH8-t.mjs → process-memory-CeZ8ms80.mjs} +1 -1
- package/dist/{process-memory-lPbJ1ta2.js → process-memory-DI4jrCtX.js} +1 -1
- package/dist/recorder/index.js +7432 -7264
- package/dist/recorder/index.mjs +7432 -7264
- package/dist/{segment-demux-js-BP5vqRPY.mjs → segment-demux-js-CNPzlcyO.mjs} +1 -1
- package/dist/{segment-demux-js-Dv1EPPdH.js → segment-demux-js-COooJ-t0.js} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BqjkSRCu.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-BrD7TK3W.mjs} +2 -2
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-pSFdAaR0.mjs +26 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DhImOdbA.mjs +26 -0
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-C4EuFQ_w.mjs → hostInit-CbSGtrCj.mjs} +2 -2
- package/dist/stream-broker/index.js +1465 -133
- package/dist/stream-broker/index.mjs +1465 -133
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +5 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DqcT4fRG.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DCbVs3IC.mjs +0 -26
|
@@ -3,12 +3,12 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
const require_chunk = require("../chunk-emK7D4bc.js");
|
|
6
|
-
const require_dist = require("../dist-
|
|
7
|
-
const require_node = require("../node-
|
|
6
|
+
const require_dist = require("../dist-D7FonEUB.js");
|
|
7
|
+
const require_node = require("../node-D3C1krEi.js");
|
|
8
8
|
const require_restream_intent = require("../restream-intent-7uDNqMKs.js");
|
|
9
9
|
const require_remote_restream = require("../remote-restream-CO36Sr30.js");
|
|
10
10
|
const require_retire_root_keys = require("../retire-root-keys-Csb8z2f3.js");
|
|
11
|
-
const require_segment_demux_js = require("../segment-demux-js-
|
|
11
|
+
const require_segment_demux_js = require("../segment-demux-js-COooJ-t0.js");
|
|
12
12
|
let node_os = require("node:os");
|
|
13
13
|
node_os = require_chunk.__toESM(node_os);
|
|
14
14
|
let node_crypto = require("node:crypto");
|
|
@@ -23181,6 +23181,246 @@ var H264Repacketizer = class {
|
|
|
23181
23181
|
}
|
|
23182
23182
|
};
|
|
23183
23183
|
//#endregion
|
|
23184
|
+
//#region src/stream-broker/webrtc/delivery-meter.ts
|
|
23185
|
+
/** The window the ladder judges delivery over: at least one GOP. */
|
|
23186
|
+
function deliveryWindowMs(idrIntervalMs, config) {
|
|
23187
|
+
const gop = idrIntervalMs !== null && Number.isFinite(idrIntervalMs) && idrIntervalMs > 0 ? idrIntervalMs : 0;
|
|
23188
|
+
return Math.min(config.maxWindowMs, Math.max(config.minWindowMs, gop));
|
|
23189
|
+
}
|
|
23190
|
+
/**
|
|
23191
|
+
* When a session's meter may start counting client reports. A session created
|
|
23192
|
+
* by a tier change starts after the change blackout (review I1): until the
|
|
23193
|
+
* client swaps its player onto the new PC (after the new stream's first
|
|
23194
|
+
* decode, 2–5 s), its reporter still reads the OLD PC — 0 kbps — and the
|
|
23195
|
+
* first report after the swap is 0 too; both fall inside the new session's
|
|
23196
|
+
* sends and read as a failed rung.
|
|
23197
|
+
*/
|
|
23198
|
+
function deliveryMeterStart(now, afterTierChange, waitAfterChangeMs) {
|
|
23199
|
+
return afterTierChange ? now + waitAfterChangeMs : now;
|
|
23200
|
+
}
|
|
23201
|
+
/**
|
|
23202
|
+
* Bucket width. 100 ms, and edges round to the NEAREST boundary, so a frame is
|
|
23203
|
+
* put on the wrong side of a window edge only when it was sent within 50 ms of
|
|
23204
|
+
* it — about one frame at 25 fps. Rounding edges inward instead dropped a whole
|
|
23205
|
+
* IDR that sat 160 ms inside a window and read a whole rung as 1.5× delivered.
|
|
23206
|
+
* 32 s of history is 320 slots.
|
|
23207
|
+
*/
|
|
23208
|
+
var SEND_BUCKET_MS = 100;
|
|
23209
|
+
/**
|
|
23210
|
+
* Bytes this session sent, bucketed by time. A fixed ring: `record` is on the
|
|
23211
|
+
* RTP send path, so it allocates nothing.
|
|
23212
|
+
*/
|
|
23213
|
+
var SendHistory = class {
|
|
23214
|
+
bytes;
|
|
23215
|
+
/** The bucket index each slot currently holds; -1 = empty. */
|
|
23216
|
+
ids;
|
|
23217
|
+
constructor(spanMs) {
|
|
23218
|
+
const slots = Math.max(1, Math.ceil(spanMs / SEND_BUCKET_MS));
|
|
23219
|
+
this.bytes = new Float64Array(slots);
|
|
23220
|
+
this.ids = new Float64Array(slots).fill(-1);
|
|
23221
|
+
}
|
|
23222
|
+
record(byteCount, now) {
|
|
23223
|
+
const id = Math.floor(now / SEND_BUCKET_MS);
|
|
23224
|
+
const slot = id % this.ids.length;
|
|
23225
|
+
if (this.ids[slot] !== id) {
|
|
23226
|
+
this.ids[slot] = id;
|
|
23227
|
+
this.bytes[slot] = 0;
|
|
23228
|
+
}
|
|
23229
|
+
this.bytes[slot] = (this.bytes[slot] ?? 0) + byteCount;
|
|
23230
|
+
}
|
|
23231
|
+
bitsBetween(fromMs, toMs) {
|
|
23232
|
+
const first = Math.round(fromMs / SEND_BUCKET_MS);
|
|
23233
|
+
const last = Math.round(toMs / SEND_BUCKET_MS) - 1;
|
|
23234
|
+
let bytes = 0;
|
|
23235
|
+
for (let id = first; id <= last; id += 1) {
|
|
23236
|
+
const slot = id % this.ids.length;
|
|
23237
|
+
if (this.ids[slot] === id) bytes += this.bytes[slot] ?? 0;
|
|
23238
|
+
}
|
|
23239
|
+
return bytes * 8;
|
|
23240
|
+
}
|
|
23241
|
+
};
|
|
23242
|
+
/**
|
|
23243
|
+
* The client's recent reports, and the ratio they imply against a
|
|
23244
|
+
* {@link SendHistoryReader}. One meter per adaptive session: `notBefore` is
|
|
23245
|
+
* the session's start, because the reports are DEVICE-scoped and a report
|
|
23246
|
+
* about time before this session describes the previous one (a re-offer).
|
|
23247
|
+
*/
|
|
23248
|
+
var DeliveryMeter = class {
|
|
23249
|
+
config;
|
|
23250
|
+
notBefore;
|
|
23251
|
+
reports = [];
|
|
23252
|
+
constructor(config, notBefore) {
|
|
23253
|
+
this.config = config;
|
|
23254
|
+
this.notBefore = notBefore;
|
|
23255
|
+
}
|
|
23256
|
+
/** Fold one client report. The same report read on several ticks counts once. */
|
|
23257
|
+
observe(kbps, endAt) {
|
|
23258
|
+
if (!Number.isFinite(kbps) || kbps < 0 || !Number.isFinite(endAt)) return;
|
|
23259
|
+
if (endAt - this.config.clientSampleSpanMs < this.notBefore) return;
|
|
23260
|
+
if (this.reports.some((r) => r.endAt === endAt)) return;
|
|
23261
|
+
const horizon = endAt - this.config.historySpanMs;
|
|
23262
|
+
this.reports = [...this.reports.filter((r) => r.endAt > horizon), {
|
|
23263
|
+
kbps,
|
|
23264
|
+
endAt
|
|
23265
|
+
}].sort((a, b) => a.endAt - b.endAt);
|
|
23266
|
+
}
|
|
23267
|
+
/**
|
|
23268
|
+
* Is the client reporting at all? A fresh report that does not yet cover a
|
|
23269
|
+
* whole window is "not measured YET" — different from a client that sends
|
|
23270
|
+
* no telemetry, and it must not be climbed on (a 10 %-delivery link would
|
|
23271
|
+
* otherwise promote in the seconds before its first window closes).
|
|
23272
|
+
*/
|
|
23273
|
+
isReporting(now) {
|
|
23274
|
+
const newest = this.reports.at(-1);
|
|
23275
|
+
return newest !== void 0 && now - newest.endAt <= this.config.maxClientSampleAgeMs;
|
|
23276
|
+
}
|
|
23277
|
+
/**
|
|
23278
|
+
* The newest report alone against the same 2 s of sends — the short window
|
|
23279
|
+
* a total freeze is judged on. Null when there is none, or it is stale.
|
|
23280
|
+
*/
|
|
23281
|
+
evaluateNewest(history, now) {
|
|
23282
|
+
const newest = this.reports.at(-1);
|
|
23283
|
+
if (newest === void 0 || now - newest.endAt > this.config.maxClientSampleAgeMs) return null;
|
|
23284
|
+
const span = this.config.clientSampleSpanMs;
|
|
23285
|
+
return {
|
|
23286
|
+
receivedKbps: newest.kbps,
|
|
23287
|
+
sentKbps: history.bitsBetween(newest.endAt - span, newest.endAt) / span,
|
|
23288
|
+
windowMs: span
|
|
23289
|
+
};
|
|
23290
|
+
}
|
|
23291
|
+
/**
|
|
23292
|
+
* Received vs sent over the newest reports that together cover `windowMs`,
|
|
23293
|
+
* or `null` when they do not (yet) — or the newest one is stale.
|
|
23294
|
+
*/
|
|
23295
|
+
evaluate(history, windowMs, now) {
|
|
23296
|
+
const newest = this.reports.at(-1);
|
|
23297
|
+
if (newest === void 0 || now - newest.endAt > this.config.maxClientSampleAgeMs) return null;
|
|
23298
|
+
const span = this.config.clientSampleSpanMs;
|
|
23299
|
+
let covered = 0;
|
|
23300
|
+
let receivedBits = 0;
|
|
23301
|
+
let sentBits = 0;
|
|
23302
|
+
for (let i = this.reports.length - 1; i >= 0 && covered < windowMs; i -= 1) {
|
|
23303
|
+
const r = this.reports[i];
|
|
23304
|
+
if (r === void 0 || now - (r.endAt - span) > this.config.historySpanMs) break;
|
|
23305
|
+
covered += span;
|
|
23306
|
+
receivedBits += r.kbps * span;
|
|
23307
|
+
sentBits += history.bitsBetween(r.endAt - span, r.endAt);
|
|
23308
|
+
}
|
|
23309
|
+
if (covered < windowMs) return null;
|
|
23310
|
+
return {
|
|
23311
|
+
receivedKbps: receivedBits / covered,
|
|
23312
|
+
sentKbps: sentBits / covered,
|
|
23313
|
+
windowMs: covered
|
|
23314
|
+
};
|
|
23315
|
+
}
|
|
23316
|
+
};
|
|
23317
|
+
//#endregion
|
|
23318
|
+
//#region src/stream-broker/webrtc/adaptive-config.ts
|
|
23319
|
+
/** How often the adaptive controller re-evaluates the ladder (ms). */
|
|
23320
|
+
var ADAPTIVE_TICK_MS = 2e3;
|
|
23321
|
+
/** Ladder tuning shared by every adaptive session. */
|
|
23322
|
+
var ADAPTIVE_CONFIG = {
|
|
23323
|
+
analyzeWindowMs: 15e3,
|
|
23324
|
+
minFailPercent: .05,
|
|
23325
|
+
waitAfterResetMs: 8e3,
|
|
23326
|
+
upgradeHealthyMs: 4e3,
|
|
23327
|
+
upgradeHealthyGrowthFactor: 3,
|
|
23328
|
+
maxProgressiveUpgradeHealthyMs: 3e4,
|
|
23329
|
+
minTimeBetweenChangesMs: 4e3,
|
|
23330
|
+
maxUpgradeBackoffMs: 9e5,
|
|
23331
|
+
stallDeliveryRatio: .25,
|
|
23332
|
+
recoverDeliveryRatio: .5,
|
|
23333
|
+
freezeDeliveryRatio: .1,
|
|
23334
|
+
freezeMinSentKbps: 16,
|
|
23335
|
+
climbDeliveryRatio: .9,
|
|
23336
|
+
stallConfirmMs: 6e3,
|
|
23337
|
+
waitAfterChangeMs: 8e3,
|
|
23338
|
+
climbCapacityMargin: 1.2,
|
|
23339
|
+
tryClimbDeliveryRatio: .95,
|
|
23340
|
+
tryClimbMaxLossRatio: .01,
|
|
23341
|
+
tryBackoffBaseMs: 3e4,
|
|
23342
|
+
tryBackoffMaxMs: 6e5,
|
|
23343
|
+
trialMs: 2e4,
|
|
23344
|
+
trialMaxMs: 4e4,
|
|
23345
|
+
trialRevertRatio: .9,
|
|
23346
|
+
trialRevertConsecutive: 2
|
|
23347
|
+
};
|
|
23348
|
+
/** How the delivery window is built from the client's reports. */
|
|
23349
|
+
var ADAPTIVE_DELIVERY_CONFIG = {
|
|
23350
|
+
clientSampleSpanMs: 2e3,
|
|
23351
|
+
minWindowMs: 2e3,
|
|
23352
|
+
maxWindowMs: 16e3,
|
|
23353
|
+
maxClientSampleAgeMs: 1e4,
|
|
23354
|
+
historySpanMs: 32e3
|
|
23355
|
+
};
|
|
23356
|
+
//#endregion
|
|
23357
|
+
//#region src/stream-broker/webrtc/climb-capacity.ts
|
|
23358
|
+
/** The client ladder's thresholds (`DOWNLINK_MID_MBPS`, `DOWNLINK_HIGH_MBPS`), kbps. */
|
|
23359
|
+
var UNMEASURED_RUNG_BAR_KBPS = {
|
|
23360
|
+
low: 0,
|
|
23361
|
+
mid: 2500,
|
|
23362
|
+
high: 8e3
|
|
23363
|
+
};
|
|
23364
|
+
/** The kbps the next rung needs; `(1800 × 1.2)` or the client threshold. */
|
|
23365
|
+
function capacityBarKbps(tier, rungKbps, margin) {
|
|
23366
|
+
return rungKbps !== null && rungKbps > 0 ? rungKbps * margin : UNMEASURED_RUNG_BAR_KBPS[tier];
|
|
23367
|
+
}
|
|
23368
|
+
function known(v) {
|
|
23369
|
+
return v !== null && Number.isFinite(v) && v > 0;
|
|
23370
|
+
}
|
|
23371
|
+
function judgeCapacity(evidence, nextTier, nextRungKbps, margin) {
|
|
23372
|
+
const neededKbps = capacityBarKbps(nextTier, nextRungKbps, margin);
|
|
23373
|
+
const { rembKbps, probeKbps, probeFresh } = evidence;
|
|
23374
|
+
if (known(probeKbps) && probeFresh && probeKbps >= neededKbps) return {
|
|
23375
|
+
kind: "sufficient",
|
|
23376
|
+
source: "probe",
|
|
23377
|
+
kbps: probeKbps,
|
|
23378
|
+
neededKbps
|
|
23379
|
+
};
|
|
23380
|
+
if (known(rembKbps) && rembKbps >= neededKbps) return {
|
|
23381
|
+
kind: "sufficient",
|
|
23382
|
+
source: "remb",
|
|
23383
|
+
kbps: rembKbps,
|
|
23384
|
+
neededKbps
|
|
23385
|
+
};
|
|
23386
|
+
if (known(probeKbps) && probeKbps < neededKbps) return {
|
|
23387
|
+
kind: "probe-short",
|
|
23388
|
+
kbps: probeKbps,
|
|
23389
|
+
neededKbps
|
|
23390
|
+
};
|
|
23391
|
+
return {
|
|
23392
|
+
kind: "none",
|
|
23393
|
+
bestKbps: known(rembKbps) ? rembKbps : null,
|
|
23394
|
+
neededKbps
|
|
23395
|
+
};
|
|
23396
|
+
}
|
|
23397
|
+
/**
|
|
23398
|
+
* REMB as it can be trusted. werift stores the LAST received estimate on the
|
|
23399
|
+
* sender and never ages it, so a peer that stopped sending REMB (backgrounded,
|
|
23400
|
+
* gone) would vouch for room indefinitely (review m5). Chrome re-estimates
|
|
23401
|
+
* about once a second and the value moves; an estimate unchanged for
|
|
23402
|
+
* `staleMs` (30 000 ms — fifteen ticks) is treated as no estimate at all.
|
|
23403
|
+
*/
|
|
23404
|
+
var RembWatch = class {
|
|
23405
|
+
staleMs;
|
|
23406
|
+
last = null;
|
|
23407
|
+
changedAt = 0;
|
|
23408
|
+
constructor(staleMs) {
|
|
23409
|
+
this.staleMs = staleMs;
|
|
23410
|
+
}
|
|
23411
|
+
read(kbps, now) {
|
|
23412
|
+
if (kbps === null) {
|
|
23413
|
+
this.last = null;
|
|
23414
|
+
return null;
|
|
23415
|
+
}
|
|
23416
|
+
if (kbps !== this.last) {
|
|
23417
|
+
this.last = kbps;
|
|
23418
|
+
this.changedAt = now;
|
|
23419
|
+
}
|
|
23420
|
+
return now - this.changedAt > this.staleMs ? null : kbps;
|
|
23421
|
+
}
|
|
23422
|
+
};
|
|
23423
|
+
//#endregion
|
|
23184
23424
|
//#region src/stream-broker/webrtc/transcode-egress-leg.ts
|
|
23185
23425
|
/**
|
|
23186
23426
|
* The request a browser session makes. Structured only: the primitive's schema
|
|
@@ -23295,6 +23535,8 @@ function selectAudioClock(mimeType) {
|
|
|
23295
23535
|
*
|
|
23296
23536
|
* Uses werift (optional peer dependency) for server-side WebRTC.
|
|
23297
23537
|
*/
|
|
23538
|
+
/** A REMB unchanged this long is no estimate (`RembWatch`). */
|
|
23539
|
+
var REMB_STALE_MS = 3e4;
|
|
23298
23540
|
var _werift;
|
|
23299
23541
|
async function loadWerift() {
|
|
23300
23542
|
if (_werift) return _werift;
|
|
@@ -24816,6 +25058,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
24816
25058
|
this.maybeRebaseRtp(pkt.header.sequenceNumber, pkt.header.timestamp);
|
|
24817
25059
|
try {
|
|
24818
25060
|
this.videoSender.sendRtp(pkt);
|
|
25061
|
+
this.videoSendHistory.record(pkt.payload.length, Date.now());
|
|
24819
25062
|
this.lastVideoRtpTs = pkt.header.timestamp >>> 0;
|
|
24820
25063
|
if (this.firstVideoRtpTs === null) {
|
|
24821
25064
|
this.firstVideoRtpTs = this.lastVideoRtpTs;
|
|
@@ -25387,6 +25630,26 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
25387
25630
|
get rtpPacketsSentTotal() {
|
|
25388
25631
|
return this.rtpPacketsSent;
|
|
25389
25632
|
}
|
|
25633
|
+
/**
|
|
25634
|
+
* Video RTP PAYLOAD bytes this session sent, bucketed by time — the "sent"
|
|
25635
|
+
* half of the adaptive ladder's delivery ratio (D635). Payload, not wire
|
|
25636
|
+
* size, because the client's `bytesReceived` excludes RTP headers.
|
|
25637
|
+
*/
|
|
25638
|
+
videoSendHistory = new SendHistory(ADAPTIVE_DELIVERY_CONFIG.historySpanMs);
|
|
25639
|
+
get sentVideoHistory() {
|
|
25640
|
+
return this.videoSendHistory;
|
|
25641
|
+
}
|
|
25642
|
+
/**
|
|
25643
|
+
* The receiver's bandwidth estimate (REMB) in kbps, or `null` when the peer
|
|
25644
|
+
* has sent none. werift stores the last REMB on the sender; `-1`/absent in
|
|
25645
|
+
* the loss line means the same "no estimate", which is not "zero".
|
|
25646
|
+
*/
|
|
25647
|
+
receiverEstimateKbps(now) {
|
|
25648
|
+
const raw = this.videoSender?.receiverEstimatedMaxBitrate;
|
|
25649
|
+
return this.rembWatch.read(typeof raw === "bigint" && raw > 0n ? Number(raw) / 1e3 : null, now);
|
|
25650
|
+
}
|
|
25651
|
+
/** Ages the REMB werift keeps forever — see `RembWatch`. */
|
|
25652
|
+
rembWatch = new RembWatch(REMB_STALE_MS);
|
|
25390
25653
|
rtpLogCounter = 0;
|
|
25391
25654
|
/** One-shot guards for the "first video/audio RTP" debug markers. */
|
|
25392
25655
|
firstVideoRtpLogged = false;
|
|
@@ -25709,6 +25972,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
25709
25972
|
} });
|
|
25710
25973
|
}
|
|
25711
25974
|
this.videoSender.sendRtp(pkt);
|
|
25975
|
+
this.videoSendHistory.record(payload.length, Date.now());
|
|
25712
25976
|
this.rtpPacketsSent++;
|
|
25713
25977
|
this.logFirstVideoRtp("writeVideoNals", codec);
|
|
25714
25978
|
if (this.debug && (this.rtpPacketsSent === 1 || this.rtpPacketsSent % 500 === 0)) this.logger.info("RTP packets sent", { meta: {
|
|
@@ -26176,6 +26440,42 @@ var LossTracker = class {
|
|
|
26176
26440
|
//#endregion
|
|
26177
26441
|
//#region src/stream-broker/webrtc/stall-tracker.ts
|
|
26178
26442
|
/**
|
|
26443
|
+
* The one place the device-scoped telemetry is judged ownable. With more than
|
|
26444
|
+
* one adaptive session of the device, the record may be another viewer's:
|
|
26445
|
+
* delivery is unknown ("no telemetry" semantics — the loss path judges) and
|
|
26446
|
+
* the sample says so, so the ladder refuses a try-climb on it.
|
|
26447
|
+
*/
|
|
26448
|
+
function buildStallSample(input) {
|
|
26449
|
+
if (input.adaptiveSessionsOnDevice > 1) return {
|
|
26450
|
+
delivery: null,
|
|
26451
|
+
recent: null,
|
|
26452
|
+
clientReporting: false,
|
|
26453
|
+
telemetryShared: true,
|
|
26454
|
+
serverSending: input.serverSending
|
|
26455
|
+
};
|
|
26456
|
+
return {
|
|
26457
|
+
delivery: input.delivery,
|
|
26458
|
+
recent: input.recent,
|
|
26459
|
+
clientReporting: input.clientReporting,
|
|
26460
|
+
telemetryShared: false,
|
|
26461
|
+
serverSending: input.serverSending
|
|
26462
|
+
};
|
|
26463
|
+
}
|
|
26464
|
+
var UNKNOWN_EVIDENCE = {
|
|
26465
|
+
clientKbps: null,
|
|
26466
|
+
sentKbps: null,
|
|
26467
|
+
deliveryRatio: null,
|
|
26468
|
+
deliveryWindowMs: null,
|
|
26469
|
+
stalledForMs: 0,
|
|
26470
|
+
serverSending: false
|
|
26471
|
+
};
|
|
26472
|
+
function ratioOf(delivery) {
|
|
26473
|
+
if (delivery === null) return null;
|
|
26474
|
+
const { receivedKbps, sentKbps } = delivery;
|
|
26475
|
+
if (!Number.isFinite(receivedKbps) || !Number.isFinite(sentKbps) || sentKbps <= 0) return null;
|
|
26476
|
+
return receivedKbps / sentKbps;
|
|
26477
|
+
}
|
|
26478
|
+
/**
|
|
26179
26479
|
* Windowed "the media stopped arriving" detector. Pure state machine: no
|
|
26180
26480
|
* clock of its own, no side effects — `observe` is the only mutator and every
|
|
26181
26481
|
* decision is a function of the samples it was given.
|
|
@@ -26185,11 +26485,9 @@ var StallTracker = class {
|
|
|
26185
26485
|
/** When the current uninterrupted stall began; null when not stalling. */
|
|
26186
26486
|
stallStartedAt = null;
|
|
26187
26487
|
blackoutUntil = 0;
|
|
26188
|
-
lastEvidence =
|
|
26189
|
-
|
|
26190
|
-
|
|
26191
|
-
serverSending: false
|
|
26192
|
-
};
|
|
26488
|
+
lastEvidence = UNKNOWN_EVIDENCE;
|
|
26489
|
+
clientReporting = false;
|
|
26490
|
+
telemetryShared = false;
|
|
26193
26491
|
constructor(config) {
|
|
26194
26492
|
this.config = config;
|
|
26195
26493
|
}
|
|
@@ -26202,38 +26500,36 @@ var StallTracker = class {
|
|
|
26202
26500
|
* Fold one evaluation into the window. Returns nothing; ask `isStalled`.
|
|
26203
26501
|
*
|
|
26204
26502
|
* Three outcomes, and the middle one is the reason this is not a simple
|
|
26205
|
-
* boolean: a
|
|
26206
|
-
* below `
|
|
26207
|
-
* (or unknown, or
|
|
26208
|
-
*
|
|
26503
|
+
* boolean: a ratio at or above `recoverDeliveryRatio` CLEARS the stall, a
|
|
26504
|
+
* ratio below `stallDeliveryRatio` with the server sending starts or extends
|
|
26505
|
+
* it, and anything between the two (or unknown, or nothing sent) HOLDS
|
|
26506
|
+
* whatever the window already believed.
|
|
26209
26507
|
*/
|
|
26210
26508
|
observe(sample, now) {
|
|
26509
|
+
const ratio = ratioOf(sample.delivery);
|
|
26510
|
+
const measured = (stalledForMs) => ({
|
|
26511
|
+
clientKbps: sample.delivery?.receivedKbps ?? null,
|
|
26512
|
+
sentKbps: sample.delivery?.sentKbps ?? null,
|
|
26513
|
+
deliveryRatio: ratio,
|
|
26514
|
+
deliveryWindowMs: sample.delivery?.windowMs ?? null,
|
|
26515
|
+
stalledForMs,
|
|
26516
|
+
serverSending: sample.serverSending
|
|
26517
|
+
});
|
|
26518
|
+
this.clientReporting = sample.clientReporting;
|
|
26519
|
+
this.telemetryShared = sample.telemetryShared;
|
|
26211
26520
|
if (now < this.blackoutUntil) {
|
|
26212
26521
|
this.stallStartedAt = null;
|
|
26213
|
-
this.lastEvidence =
|
|
26214
|
-
clientKbps: sample.clientKbps,
|
|
26215
|
-
stalledForMs: 0,
|
|
26216
|
-
serverSending: sample.serverSending
|
|
26217
|
-
};
|
|
26522
|
+
this.lastEvidence = measured(0);
|
|
26218
26523
|
return;
|
|
26219
26524
|
}
|
|
26220
|
-
const
|
|
26221
|
-
const
|
|
26222
|
-
|
|
26223
|
-
|
|
26224
|
-
|
|
26225
|
-
|
|
26226
|
-
|
|
26227
|
-
|
|
26228
|
-
return;
|
|
26229
|
-
}
|
|
26230
|
-
if (kbps >= this.config.recoverKbpsFloor) this.stallStartedAt = null;
|
|
26231
|
-
else if (kbps < this.config.stallKbpsFloor && sample.serverSending) this.stallStartedAt ??= now;
|
|
26232
|
-
this.lastEvidence = {
|
|
26233
|
-
clientKbps: kbps,
|
|
26234
|
-
stalledForMs: this.stalledForMs(now),
|
|
26235
|
-
serverSending: sample.serverSending
|
|
26236
|
-
};
|
|
26525
|
+
const recent = sample.recent !== null && sample.recent.sentKbps >= this.config.freezeMinSentKbps ? ratioOf(sample.recent) : null;
|
|
26526
|
+
const frozen = recent !== null && recent < this.config.freezeDeliveryRatio;
|
|
26527
|
+
const recovered = recent !== null && recent >= this.config.recoverDeliveryRatio;
|
|
26528
|
+
const starved = ratio !== null && ratio < this.config.stallDeliveryRatio && !recovered;
|
|
26529
|
+
const current = recent ?? ratio;
|
|
26530
|
+
if ((frozen || starved) && sample.serverSending) this.stallStartedAt ??= now;
|
|
26531
|
+
else if (current !== null && current >= this.config.recoverDeliveryRatio) this.stallStartedAt = null;
|
|
26532
|
+
this.lastEvidence = measured(this.stalledForMs(now));
|
|
26237
26533
|
}
|
|
26238
26534
|
stalledForMs(now) {
|
|
26239
26535
|
return this.stallStartedAt === null ? 0 : now - this.stallStartedAt;
|
|
@@ -26244,25 +26540,42 @@ var StallTracker = class {
|
|
|
26244
26540
|
return this.stalledForMs(now) >= this.config.stallConfirmMs;
|
|
26245
26541
|
}
|
|
26246
26542
|
/**
|
|
26247
|
-
* May the ladder PROMOTE on this evaluation?
|
|
26543
|
+
* May the ladder PROMOTE on this evaluation, as far as the transport knows?
|
|
26248
26544
|
*
|
|
26249
|
-
*
|
|
26250
|
-
*
|
|
26251
|
-
*
|
|
26252
|
-
*
|
|
26253
|
-
* reports almost no loss. Same doctrine as `LossTracker.hasEvidence` — a
|
|
26254
|
-
* climb needs POSITIVE evidence, not the absence of a complaint.
|
|
26545
|
+
* No while a stall is building, and no while the rung we are on is not
|
|
26546
|
+
* landing (nearly) whole — promoting there spends bandwidth the session has
|
|
26547
|
+
* just been shown not to have, and the loss path cannot object: a stream
|
|
26548
|
+
* delivering almost nothing reports almost no loss.
|
|
26255
26549
|
*
|
|
26256
|
-
* Unknown
|
|
26550
|
+
* Unknown refuses only while the client IS reporting — the window is simply
|
|
26551
|
+
* not covered yet, and climbing then would promote a link measured at 10 %
|
|
26552
|
+
* a few seconds later. A deployment with no client telemetry at all must
|
|
26257
26553
|
* behave exactly as it did before this tracker existed; the loss path's own
|
|
26258
26554
|
* `hasEvidence` is the gate there.
|
|
26259
26555
|
*/
|
|
26260
|
-
|
|
26261
|
-
if (now < this.blackoutUntil) return true;
|
|
26262
|
-
if (this.stallStartedAt !== null) return
|
|
26263
|
-
|
|
26264
|
-
|
|
26265
|
-
|
|
26556
|
+
climbSafety(now) {
|
|
26557
|
+
if (now < this.blackoutUntil) return { safe: true };
|
|
26558
|
+
if (this.stallStartedAt !== null) return {
|
|
26559
|
+
safe: false,
|
|
26560
|
+
reason: "stall-building"
|
|
26561
|
+
};
|
|
26562
|
+
const ratio = this.lastEvidence.deliveryRatio;
|
|
26563
|
+
if (ratio === null) return this.clientReporting ? {
|
|
26564
|
+
safe: false,
|
|
26565
|
+
reason: "delivery"
|
|
26566
|
+
} : { safe: true };
|
|
26567
|
+
return ratio >= this.config.climbDeliveryRatio ? { safe: true } : {
|
|
26568
|
+
safe: false,
|
|
26569
|
+
reason: "delivery"
|
|
26570
|
+
};
|
|
26571
|
+
}
|
|
26572
|
+
/** Was the device's client record possibly another viewer's at the last evaluation? */
|
|
26573
|
+
get isTelemetryShared() {
|
|
26574
|
+
return this.telemetryShared;
|
|
26575
|
+
}
|
|
26576
|
+
/** Was the client reporting at the last evaluation (even if not yet covering a window)? */
|
|
26577
|
+
get isClientReporting() {
|
|
26578
|
+
return this.clientReporting;
|
|
26266
26579
|
}
|
|
26267
26580
|
/** What the last `observe` measured — for the switch's log line. */
|
|
26268
26581
|
evidence(now) {
|
|
@@ -26273,9 +26586,142 @@ var StallTracker = class {
|
|
|
26273
26586
|
}
|
|
26274
26587
|
};
|
|
26275
26588
|
//#endregion
|
|
26589
|
+
//#region src/stream-broker/webrtc/rtt-stability.ts
|
|
26590
|
+
/** 16 s — the longest delivery window, so both halves of a try look at the same span. */
|
|
26591
|
+
var RTT_WINDOW_MS = 16e3;
|
|
26592
|
+
var MIN_SAMPLES = 3;
|
|
26593
|
+
var MIN_SPREAD_MS = 20;
|
|
26594
|
+
var SPREAD_FRACTION = .5;
|
|
26595
|
+
var RttWindow = class {
|
|
26596
|
+
samples = [];
|
|
26597
|
+
observe(rttMs, now) {
|
|
26598
|
+
if (rttMs === null || !Number.isFinite(rttMs) || rttMs <= 0) return;
|
|
26599
|
+
this.samples = [...this.samples.filter((s) => now - s.at <= RTT_WINDOW_MS), {
|
|
26600
|
+
rttMs,
|
|
26601
|
+
at: now
|
|
26602
|
+
}];
|
|
26603
|
+
}
|
|
26604
|
+
stability(now) {
|
|
26605
|
+
const recent = this.samples.filter((s) => now - s.at <= RTT_WINDOW_MS);
|
|
26606
|
+
if (recent.length < MIN_SAMPLES) return "unknown";
|
|
26607
|
+
const values = recent.map((s) => s.rttMs);
|
|
26608
|
+
const min = Math.min(...values);
|
|
26609
|
+
return Math.max(...values) - min <= Math.max(MIN_SPREAD_MS, min * SPREAD_FRACTION) ? "stable" : "unstable";
|
|
26610
|
+
}
|
|
26611
|
+
};
|
|
26612
|
+
//#endregion
|
|
26613
|
+
//#region src/stream-broker/webrtc/try-climb.ts
|
|
26614
|
+
var NO_TRY_HISTORY = {
|
|
26615
|
+
failures: 0,
|
|
26616
|
+
notBefore: 0,
|
|
26617
|
+
lastRevert: null,
|
|
26618
|
+
trialFrom: null
|
|
26619
|
+
};
|
|
26620
|
+
/** Backoff after the Nth consecutive failed try: base × 2^(N−1), capped. */
|
|
26621
|
+
function tryBackoffMs(failures, baseMs, maxMs) {
|
|
26622
|
+
if (failures <= 0) return 0;
|
|
26623
|
+
return Math.min(baseMs * 2 ** (failures - 1), maxMs);
|
|
26624
|
+
}
|
|
26625
|
+
/** Watches one tried rung through its first window. Pure: fed, never reads a clock. */
|
|
26626
|
+
var TrialWatch = class {
|
|
26627
|
+
from;
|
|
26628
|
+
config;
|
|
26629
|
+
startedAt = null;
|
|
26630
|
+
lowStreak = 0;
|
|
26631
|
+
measured = 0;
|
|
26632
|
+
constructor(from, config) {
|
|
26633
|
+
this.from = from;
|
|
26634
|
+
this.config = config;
|
|
26635
|
+
}
|
|
26636
|
+
/**
|
|
26637
|
+
* Start the trial over: it must be judged afresh on live media. Called when
|
|
26638
|
+
* recorded playback takes the track (review m14) — time off live must never
|
|
26639
|
+
* count toward keeping a rung that was never measured.
|
|
26640
|
+
*/
|
|
26641
|
+
restart() {
|
|
26642
|
+
this.startedAt = null;
|
|
26643
|
+
this.lowStreak = 0;
|
|
26644
|
+
this.measured = 0;
|
|
26645
|
+
}
|
|
26646
|
+
judge(now, input) {
|
|
26647
|
+
this.startedAt ??= now;
|
|
26648
|
+
if (input.stalled || input.lossFailing) return {
|
|
26649
|
+
kind: "revert",
|
|
26650
|
+
deliveryRatio: input.deliveryRatio,
|
|
26651
|
+
stalled: input.stalled
|
|
26652
|
+
};
|
|
26653
|
+
if (input.deliveryRatio !== null) {
|
|
26654
|
+
this.measured += 1;
|
|
26655
|
+
this.lowStreak = input.deliveryRatio < this.config.trialRevertRatio ? this.lowStreak + 1 : 0;
|
|
26656
|
+
if (this.lowStreak >= this.config.trialRevertConsecutive) return {
|
|
26657
|
+
kind: "revert",
|
|
26658
|
+
deliveryRatio: input.deliveryRatio,
|
|
26659
|
+
stalled: false
|
|
26660
|
+
};
|
|
26661
|
+
}
|
|
26662
|
+
const forMs = now - this.startedAt;
|
|
26663
|
+
const judged = this.measured >= this.config.trialRevertConsecutive || !input.clientReporting;
|
|
26664
|
+
if (forMs >= this.config.trialMs && judged || forMs >= this.config.trialMaxMs) return { kind: "kept" };
|
|
26665
|
+
return {
|
|
26666
|
+
kind: "running",
|
|
26667
|
+
forMs
|
|
26668
|
+
};
|
|
26669
|
+
}
|
|
26670
|
+
};
|
|
26671
|
+
/**
|
|
26672
|
+
* The try-climb memory per DEVICE on this broker, with a TTL (review I4).
|
|
26673
|
+
* The per-session intent survives only a re-offer; without this, every new
|
|
26674
|
+
* viewer session of a camera whose next rung keeps failing started again at
|
|
26675
|
+
* try #1. The trial itself (`trialFrom`) never carries to a new session.
|
|
26676
|
+
*/
|
|
26677
|
+
var DeviceTryMemory = class {
|
|
26678
|
+
ttlMs;
|
|
26679
|
+
entries = /* @__PURE__ */ new Map();
|
|
26680
|
+
/** @param ttlMs forget a device not refreshed for this long — 600 000 ms, the backoff cap. */
|
|
26681
|
+
constructor(ttlMs) {
|
|
26682
|
+
this.ttlMs = ttlMs;
|
|
26683
|
+
}
|
|
26684
|
+
set(deviceId, carry, now) {
|
|
26685
|
+
this.entries.set(deviceId, {
|
|
26686
|
+
carry,
|
|
26687
|
+
at: now
|
|
26688
|
+
});
|
|
26689
|
+
}
|
|
26690
|
+
get(deviceId, now) {
|
|
26691
|
+
const entry = this.entries.get(deviceId);
|
|
26692
|
+
if (entry === void 0) return null;
|
|
26693
|
+
if (now - entry.at > this.ttlMs) {
|
|
26694
|
+
this.entries.delete(deviceId);
|
|
26695
|
+
return null;
|
|
26696
|
+
}
|
|
26697
|
+
return {
|
|
26698
|
+
...entry.carry,
|
|
26699
|
+
trialFrom: null
|
|
26700
|
+
};
|
|
26701
|
+
}
|
|
26702
|
+
};
|
|
26703
|
+
//#endregion
|
|
26276
26704
|
//#region src/stream-broker/webrtc/adaptive-controller.ts
|
|
26277
26705
|
var TIER_ORDER = require_dist.CAM_PROFILE_ORDER;
|
|
26278
26706
|
/**
|
|
26707
|
+
* The cause as the wire (`WebrtcLadderDecision.cause`) can carry it. The wire
|
|
26708
|
+
* enum predates the try-climb, and widening it would make a server built
|
|
26709
|
+
* before it reject this broker's debug answer (the router validates output).
|
|
26710
|
+
* A trial revert is reported as what undelivered bytes are — `stall` when the
|
|
26711
|
+
* stall confirmed, else `loss` — and the climb hold carries the exact account
|
|
26712
|
+
* ("tried mid, reverted: delivery 0.60").
|
|
26713
|
+
*/
|
|
26714
|
+
function wireLadderCause(decision, stallConfirmMs) {
|
|
26715
|
+
if (decision.cause !== "trial-revert") return { cause: decision.cause };
|
|
26716
|
+
return {
|
|
26717
|
+
cause: decision.stall.stalledForMs >= stallConfirmMs ? "stall" : "loss",
|
|
26718
|
+
causeDetail: "trial-revert"
|
|
26719
|
+
};
|
|
26720
|
+
}
|
|
26721
|
+
function positiveOrNull(v) {
|
|
26722
|
+
return v !== null && Number.isFinite(v) && v > 0 ? v : null;
|
|
26723
|
+
}
|
|
26724
|
+
/**
|
|
26279
26725
|
* Ladder state machine for an adaptive WebRTC session. Fed loss samples
|
|
26280
26726
|
* (RTCP RR or client-reported) via `onLoss`; `tick` evaluates the ladder.
|
|
26281
26727
|
* Downgrade order: tier high→mid→low, then MTU reduction at low, then
|
|
@@ -26289,7 +26735,16 @@ var TIER_ORDER = require_dist.CAM_PROFILE_ORDER;
|
|
|
26289
26735
|
* a session that has produced no RTCP at all is not "healthy", it is
|
|
26290
26736
|
* silent, and climbing on silence spends bandwidth never shown to exist;
|
|
26291
26737
|
* 3. the target tier's re-attempt backoff, which doubles per measured
|
|
26292
|
-
* failure of that tier in this session
|
|
26738
|
+
* failure of that tier in this session;
|
|
26739
|
+
* 4. the transport showing the CURRENT rung landing whole
|
|
26740
|
+
* (`StallTracker.climbSafety`, a delivery ratio over one GOP — D635);
|
|
26741
|
+
* 5. capacity: evidence (REMB or the viewer's probe) clearing the next
|
|
26742
|
+
* rung's bar confirms the climb; a MEASURED probe below it holds; with no
|
|
26743
|
+
* evidence either way the ladder TRIES the rung on a clean path (delivery
|
|
26744
|
+
* ≥ 0.95, loss < 1 %, RTT stable) and reverts within the rung's first
|
|
26745
|
+
* window if it does not land, backing off 30 s → 10 min (D635, round 2).
|
|
26746
|
+
*
|
|
26747
|
+
* Every tick that does not promote leaves a {@link ClimbHold} naming why.
|
|
26293
26748
|
*/
|
|
26294
26749
|
var AdaptiveController = class {
|
|
26295
26750
|
opts;
|
|
@@ -26310,6 +26765,16 @@ var AdaptiveController = class {
|
|
|
26310
26765
|
armed = false;
|
|
26311
26766
|
failures;
|
|
26312
26767
|
upgradeCount;
|
|
26768
|
+
capacity = {
|
|
26769
|
+
rembKbps: null,
|
|
26770
|
+
probeKbps: null,
|
|
26771
|
+
probeFresh: false
|
|
26772
|
+
};
|
|
26773
|
+
rtt = new RttWindow();
|
|
26774
|
+
tryCarry;
|
|
26775
|
+
/** Non-null while the current rung is a trial. */
|
|
26776
|
+
trial;
|
|
26777
|
+
hold = null;
|
|
26313
26778
|
constructor(opts) {
|
|
26314
26779
|
this.opts = opts;
|
|
26315
26780
|
this.tracker = new LossTracker(opts.config);
|
|
@@ -26317,6 +26782,16 @@ var AdaptiveController = class {
|
|
|
26317
26782
|
this.tier = opts.initialTier;
|
|
26318
26783
|
this.failures = new Map(opts.priorFailures ?? []);
|
|
26319
26784
|
this.upgradeCount = Math.max(0, opts.priorUpgradeCount ?? 0);
|
|
26785
|
+
const carried = opts.tryClimb ?? NO_TRY_HISTORY;
|
|
26786
|
+
this.tryCarry = carried.trialFrom === opts.initialTier ? {
|
|
26787
|
+
...carried,
|
|
26788
|
+
trialFrom: null
|
|
26789
|
+
} : carried;
|
|
26790
|
+
this.trial = this.tryCarry.trialFrom !== null ? new TrialWatch(this.tryCarry.trialFrom, opts.config) : null;
|
|
26791
|
+
}
|
|
26792
|
+
/** What the NEXT controller of this viewer session must inherit about tries. */
|
|
26793
|
+
get tryClimbCarry() {
|
|
26794
|
+
return this.tryCarry;
|
|
26320
26795
|
}
|
|
26321
26796
|
get currentTier() {
|
|
26322
26797
|
return this.tier;
|
|
@@ -26349,6 +26824,27 @@ var AdaptiveController = class {
|
|
|
26349
26824
|
this.stalls.observe(sample, now);
|
|
26350
26825
|
}
|
|
26351
26826
|
/**
|
|
26827
|
+
* The capacity witnesses, kbps — `null` when unknown: the receiver's REMB
|
|
26828
|
+
* (capped by what is sent, so evidence only FOR room) and the viewer's
|
|
26829
|
+
* downlink probe for this session (a path measurement, evidence both ways).
|
|
26830
|
+
* See `climb-capacity.ts`.
|
|
26831
|
+
*/
|
|
26832
|
+
onCapacity(evidence) {
|
|
26833
|
+
this.capacity = {
|
|
26834
|
+
rembKbps: positiveOrNull(evidence.rembKbps),
|
|
26835
|
+
probeKbps: positiveOrNull(evidence.probeKbps),
|
|
26836
|
+
probeFresh: evidence.probeFresh
|
|
26837
|
+
};
|
|
26838
|
+
}
|
|
26839
|
+
/** The client's round-trip time, ms; null/0 = unknown. One of a try's conditions. */
|
|
26840
|
+
onRtt(rttMs, now) {
|
|
26841
|
+
this.rtt.observe(rttMs, now);
|
|
26842
|
+
}
|
|
26843
|
+
/** Why the ladder did not climb on the last evaluation; null = at the top, or it just moved. */
|
|
26844
|
+
get climbHold() {
|
|
26845
|
+
return this.hold;
|
|
26846
|
+
}
|
|
26847
|
+
/**
|
|
26352
26848
|
* Freeze the ladder for this evaluation instead of ticking it.
|
|
26353
26849
|
*
|
|
26354
26850
|
* Called while the session's track is NOT carrying the live source — D42
|
|
@@ -26369,6 +26865,8 @@ var AdaptiveController = class {
|
|
|
26369
26865
|
this.lastChangeAt = now;
|
|
26370
26866
|
}
|
|
26371
26867
|
this.healthySince = now;
|
|
26868
|
+
this.trial?.restart();
|
|
26869
|
+
this.setHold("suspended", now);
|
|
26372
26870
|
this.stalls.markChanged(now);
|
|
26373
26871
|
}
|
|
26374
26872
|
tick(now) {
|
|
@@ -26377,26 +26875,132 @@ var AdaptiveController = class {
|
|
|
26377
26875
|
this.healthySince = now;
|
|
26378
26876
|
this.lastChangeAt = now;
|
|
26379
26877
|
}
|
|
26878
|
+
if (this.trial !== null && this.judgeTrial(now)) return;
|
|
26380
26879
|
if (this.stalls.isStalled(now)) {
|
|
26381
26880
|
this.healthySince = now;
|
|
26881
|
+
this.hold = null;
|
|
26382
26882
|
this.handleFailing(now, "stall");
|
|
26383
26883
|
return;
|
|
26384
26884
|
}
|
|
26385
26885
|
if (this.tracker.isFailing(now)) {
|
|
26386
26886
|
this.healthySince = now;
|
|
26887
|
+
this.hold = null;
|
|
26387
26888
|
this.handleFailing(now, "loss");
|
|
26388
26889
|
return;
|
|
26389
26890
|
}
|
|
26390
|
-
|
|
26891
|
+
const safety = this.stalls.climbSafety(now);
|
|
26892
|
+
if (!safety.safe) {
|
|
26391
26893
|
this.healthySince = now;
|
|
26894
|
+
this.setHold(safety.reason, now);
|
|
26392
26895
|
return;
|
|
26393
26896
|
}
|
|
26394
26897
|
if (!this.tracker.hasEvidence(now)) {
|
|
26395
26898
|
this.healthySince = now;
|
|
26899
|
+
this.setHold("no-evidence", now);
|
|
26396
26900
|
return;
|
|
26397
26901
|
}
|
|
26398
26902
|
this.handleHealthy(now);
|
|
26399
26903
|
}
|
|
26904
|
+
nextTier() {
|
|
26905
|
+
const idx = TIER_ORDER.indexOf(this.tier);
|
|
26906
|
+
return idx <= 0 ? null : TIER_ORDER[idx - 1] ?? null;
|
|
26907
|
+
}
|
|
26908
|
+
/**
|
|
26909
|
+
* Record why this evaluation did not climb. At the top rung there is nothing
|
|
26910
|
+
* to hold — except a `trial`, which names the rung being tried.
|
|
26911
|
+
*/
|
|
26912
|
+
setHold(code, now, extra = {}) {
|
|
26913
|
+
const next = code === "trial" ? this.tier : this.nextTier();
|
|
26914
|
+
if (next === null) {
|
|
26915
|
+
this.hold = null;
|
|
26916
|
+
return;
|
|
26917
|
+
}
|
|
26918
|
+
const ev = this.stalls.evidence(now);
|
|
26919
|
+
const clockRunning = code === "warming" || code === "cooldown" || code === "capacity" || code === "try-unclean" || code === "try-backoff";
|
|
26920
|
+
const nextTierKbps = this.opts.expectedTierKbps?.(next) ?? null;
|
|
26921
|
+
this.hold = {
|
|
26922
|
+
code,
|
|
26923
|
+
nextTier: next,
|
|
26924
|
+
deliveryRatio: ev.deliveryRatio,
|
|
26925
|
+
deliveryWindowMs: ev.deliveryWindowMs,
|
|
26926
|
+
climbDeliveryRatio: this.opts.config.climbDeliveryRatio,
|
|
26927
|
+
capacityKbps: this.capacity.rembKbps,
|
|
26928
|
+
probeKbps: this.capacity.probeKbps,
|
|
26929
|
+
nextTierKbps,
|
|
26930
|
+
capacityMargin: this.opts.config.climbCapacityMargin,
|
|
26931
|
+
neededKbps: capacityBarKbps(next, nextTierKbps, this.opts.config.climbCapacityMargin),
|
|
26932
|
+
tryBlockedBy: extra.tryBlockedBy ?? null,
|
|
26933
|
+
tryDeliveryRatio: this.opts.config.tryClimbDeliveryRatio,
|
|
26934
|
+
tryMaxLossRatio: this.opts.config.tryClimbMaxLossRatio,
|
|
26935
|
+
retryInMs: code === "try-backoff" ? Math.max(0, this.tryCarry.notBefore - now) : null,
|
|
26936
|
+
lastRevert: this.tryCarry.lastRevert,
|
|
26937
|
+
trialForMs: code === "trial" ? extra.trialForMs ?? 0 : null,
|
|
26938
|
+
trialMs: code === "trial" ? this.opts.config.trialMs : null,
|
|
26939
|
+
healthyForMs: clockRunning ? now - this.healthySince : null,
|
|
26940
|
+
requiredHealthyMs: this.requiredHealthyMs(next),
|
|
26941
|
+
since: this.hold?.code === code ? this.hold.since : now
|
|
26942
|
+
};
|
|
26943
|
+
}
|
|
26944
|
+
/**
|
|
26945
|
+
* Judge the rung on trial. Returns true when this evaluation is consumed
|
|
26946
|
+
* (still running, or reverted); false when the trial was just kept and the
|
|
26947
|
+
* ordinary ladder takes over.
|
|
26948
|
+
*/
|
|
26949
|
+
judgeTrial(now) {
|
|
26950
|
+
const watch = this.trial;
|
|
26951
|
+
if (watch === null) return false;
|
|
26952
|
+
const verdict = watch.judge(now, {
|
|
26953
|
+
deliveryRatio: this.stalls.evidence(now).deliveryRatio,
|
|
26954
|
+
clientReporting: this.stalls.isClientReporting,
|
|
26955
|
+
stalled: this.stalls.isStalled(now),
|
|
26956
|
+
lossFailing: this.tracker.isFailing(now)
|
|
26957
|
+
});
|
|
26958
|
+
if (verdict.kind === "running") {
|
|
26959
|
+
this.healthySince = now;
|
|
26960
|
+
this.setHold("trial", now, { trialForMs: verdict.forMs });
|
|
26961
|
+
return true;
|
|
26962
|
+
}
|
|
26963
|
+
this.trial = null;
|
|
26964
|
+
if (verdict.kind === "kept") {
|
|
26965
|
+
this.tryCarry = NO_TRY_HISTORY;
|
|
26966
|
+
this.healthySince = now;
|
|
26967
|
+
return false;
|
|
26968
|
+
}
|
|
26969
|
+
const failures = this.tryCarry.failures + 1;
|
|
26970
|
+
const tried = this.tier;
|
|
26971
|
+
this.tryCarry = {
|
|
26972
|
+
failures,
|
|
26973
|
+
notBefore: now + tryBackoffMs(failures, this.opts.config.tryBackoffBaseMs, this.opts.config.tryBackoffMaxMs),
|
|
26974
|
+
lastRevert: {
|
|
26975
|
+
tier: tried,
|
|
26976
|
+
deliveryRatio: verdict.deliveryRatio,
|
|
26977
|
+
stalled: verdict.stalled,
|
|
26978
|
+
at: now
|
|
26979
|
+
},
|
|
26980
|
+
trialFrom: null
|
|
26981
|
+
};
|
|
26982
|
+
this.hold = null;
|
|
26983
|
+
this.tier = watch.from;
|
|
26984
|
+
this.mtuReduced = false;
|
|
26985
|
+
this.transcodeEngaged = false;
|
|
26986
|
+
this.opts.onSwitchTier(watch.from, this.decisionAt(now, "trial-revert"));
|
|
26987
|
+
this.commitChange(now);
|
|
26988
|
+
this.healthySince = now;
|
|
26989
|
+
return true;
|
|
26990
|
+
}
|
|
26991
|
+
/**
|
|
26992
|
+
* Which clean-path condition refuses a TRY, or null when the path is clean.
|
|
26993
|
+
* A client that sends no telemetry at all cannot measure delivery; there the
|
|
26994
|
+
* loss path is the judge, as it was before delivery existed.
|
|
26995
|
+
*/
|
|
26996
|
+
tryBlocker(now) {
|
|
26997
|
+
if (this.stalls.isTelemetryShared) return "shared-telemetry";
|
|
26998
|
+
const ratio = this.stalls.evidence(now).deliveryRatio;
|
|
26999
|
+
if (ratio === null ? this.stalls.isClientReporting : ratio < this.opts.config.tryClimbDeliveryRatio) return "delivery";
|
|
27000
|
+
if (this.tracker.lossRatio() >= this.opts.config.tryClimbMaxLossRatio) return "loss";
|
|
27001
|
+
if (this.rtt.stability(now) === "unstable") return "rtt";
|
|
27002
|
+
return null;
|
|
27003
|
+
}
|
|
26400
27004
|
canUpgrade(now) {
|
|
26401
27005
|
return now - this.lastChangeAt >= this.opts.config.minTimeBetweenChangesMs;
|
|
26402
27006
|
}
|
|
@@ -26417,11 +27021,12 @@ var AdaptiveController = class {
|
|
|
26417
27021
|
this.stalls.markChanged(now);
|
|
26418
27022
|
}
|
|
26419
27023
|
/** The measurement this decision rests on, snapshotted for the log line. */
|
|
26420
|
-
decisionAt(now, cause) {
|
|
27024
|
+
decisionAt(now, cause, trial = false) {
|
|
26421
27025
|
return {
|
|
26422
27026
|
cause,
|
|
26423
27027
|
lossRatio: this.tracker.lossRatio(),
|
|
26424
|
-
stall: this.stalls.evidence(now)
|
|
27028
|
+
stall: this.stalls.evidence(now),
|
|
27029
|
+
trial
|
|
26425
27030
|
};
|
|
26426
27031
|
}
|
|
26427
27032
|
handleFailing(now, cause) {
|
|
@@ -26448,21 +27053,223 @@ var AdaptiveController = class {
|
|
|
26448
27053
|
}
|
|
26449
27054
|
}
|
|
26450
27055
|
handleHealthy(now) {
|
|
26451
|
-
const
|
|
26452
|
-
if (
|
|
26453
|
-
|
|
26454
|
-
|
|
26455
|
-
|
|
27056
|
+
const next = this.nextTier();
|
|
27057
|
+
if (next === null) {
|
|
27058
|
+
this.hold = null;
|
|
27059
|
+
return;
|
|
27060
|
+
}
|
|
27061
|
+
const capacity = judgeCapacity(this.capacity, next, this.opts.expectedTierKbps?.(next) ?? null, this.opts.config.climbCapacityMargin);
|
|
27062
|
+
if (capacity.kind === "probe-short") {
|
|
27063
|
+
this.setHold("capacity", now);
|
|
27064
|
+
return;
|
|
27065
|
+
}
|
|
27066
|
+
if (capacity.kind === "none" && now < this.tryCarry.notBefore) {
|
|
27067
|
+
this.setHold("try-backoff", now);
|
|
27068
|
+
return;
|
|
27069
|
+
}
|
|
27070
|
+
if (now - this.healthySince < this.requiredHealthyMs(next)) {
|
|
27071
|
+
this.setHold("warming", now);
|
|
27072
|
+
return;
|
|
27073
|
+
}
|
|
27074
|
+
if (!this.canUpgrade(now)) {
|
|
27075
|
+
this.setHold("cooldown", now);
|
|
27076
|
+
return;
|
|
27077
|
+
}
|
|
27078
|
+
if (capacity.kind === "sufficient") {
|
|
27079
|
+
this.promote(now, next, false);
|
|
27080
|
+
return;
|
|
27081
|
+
}
|
|
27082
|
+
const blocker = this.tryBlocker(now);
|
|
27083
|
+
if (blocker !== null) {
|
|
27084
|
+
this.setHold("try-unclean", now, { tryBlockedBy: blocker });
|
|
27085
|
+
return;
|
|
27086
|
+
}
|
|
27087
|
+
this.promote(now, next, true);
|
|
27088
|
+
}
|
|
27089
|
+
promote(now, next, trial) {
|
|
27090
|
+
const from = this.tier;
|
|
27091
|
+
this.hold = null;
|
|
26456
27092
|
this.tier = next;
|
|
26457
27093
|
this.mtuReduced = false;
|
|
26458
27094
|
this.transcodeEngaged = false;
|
|
26459
27095
|
this.upgradeCount += 1;
|
|
26460
|
-
this.
|
|
27096
|
+
this.tryCarry = {
|
|
27097
|
+
...this.tryCarry,
|
|
27098
|
+
trialFrom: trial ? from : null
|
|
27099
|
+
};
|
|
27100
|
+
this.trial = trial ? new TrialWatch(from, this.opts.config) : null;
|
|
27101
|
+
this.opts.onSwitchTier(next, this.decisionAt(now, "healthy", trial));
|
|
26461
27102
|
this.commitChange(now);
|
|
26462
27103
|
this.healthySince = now;
|
|
26463
27104
|
}
|
|
26464
27105
|
};
|
|
26465
27106
|
//#endregion
|
|
27107
|
+
//#region src/stream-broker/webrtc/climb-hold.ts
|
|
27108
|
+
/** The wire shape: the hold plus the line the broker logs. */
|
|
27109
|
+
function toWireClimbHold(h) {
|
|
27110
|
+
return {
|
|
27111
|
+
...h,
|
|
27112
|
+
summary: describeClimbHold(h)
|
|
27113
|
+
};
|
|
27114
|
+
}
|
|
27115
|
+
function seconds(ms) {
|
|
27116
|
+
return `${(ms / 1e3).toFixed(1)}s`;
|
|
27117
|
+
}
|
|
27118
|
+
/** A rate, or "unknown" — never a fabricated 0 kbps (D393). */
|
|
27119
|
+
function kbps(v) {
|
|
27120
|
+
return v === null ? "unknown" : `${String(Math.round(v))} kbps`;
|
|
27121
|
+
}
|
|
27122
|
+
function deliveryPart(h) {
|
|
27123
|
+
if (h.deliveryRatio === null || h.deliveryWindowMs === null) return null;
|
|
27124
|
+
return `delivery ${h.deliveryRatio.toFixed(2)} over ${seconds(h.deliveryWindowMs)}`;
|
|
27125
|
+
}
|
|
27126
|
+
/** `mid needs 2160 kbps (1800 × 1.2)`, or `(client threshold)` for an unmeasured rung. */
|
|
27127
|
+
function barPart(h) {
|
|
27128
|
+
const basis = h.nextTierKbps !== null && h.nextTierKbps > 0 ? `(${String(Math.round(h.nextTierKbps))} × ${String(h.capacityMargin)})` : "(client threshold)";
|
|
27129
|
+
return `${h.nextTier} needs ${kbps(h.neededKbps)} ${basis}`;
|
|
27130
|
+
}
|
|
27131
|
+
function witnesses(h) {
|
|
27132
|
+
return `${h.capacityKbps === null ? "no REMB" : `REMB ${kbps(h.capacityKbps)}`}, ${h.probeKbps === null ? "no probe" : `probe ${kbps(h.probeKbps)}`}`;
|
|
27133
|
+
}
|
|
27134
|
+
function blockerPart(h) {
|
|
27135
|
+
switch (h.tryBlockedBy) {
|
|
27136
|
+
case "delivery": return h.deliveryRatio === null ? "delivery not measured" : `delivery ${h.deliveryRatio.toFixed(2)} < ${h.tryDeliveryRatio.toFixed(2)}`;
|
|
27137
|
+
case "loss": return `loss ≥ ${String(Math.round(h.tryMaxLossRatio * 1e3) / 10)}%`;
|
|
27138
|
+
case "rtt": return "RTT unstable";
|
|
27139
|
+
case "shared-telemetry": return "another viewer shares this camera's telemetry";
|
|
27140
|
+
case null: return "unknown";
|
|
27141
|
+
}
|
|
27142
|
+
}
|
|
27143
|
+
function revertPart(h) {
|
|
27144
|
+
const r = h.lastRevert;
|
|
27145
|
+
if (r === null) return "a try reverted";
|
|
27146
|
+
const why = r.stalled ? "stall" : r.deliveryRatio === null ? "loss" : `delivery ${r.deliveryRatio.toFixed(2)}`;
|
|
27147
|
+
return `tried ${r.tier}, reverted: ${why}`;
|
|
27148
|
+
}
|
|
27149
|
+
/**
|
|
27150
|
+
* One line, English, numbers included — the same text the broker logs and the
|
|
27151
|
+
* viewer shows under its localized hold label. Every number is one the
|
|
27152
|
+
* controller actually used; an unknown one is said to be unknown.
|
|
27153
|
+
*/
|
|
27154
|
+
function describeClimbHold(h) {
|
|
27155
|
+
const delivery = deliveryPart(h);
|
|
27156
|
+
switch (h.code) {
|
|
27157
|
+
case "stall-building": return `held: stall building (${delivery ?? "delivery unknown"})`;
|
|
27158
|
+
case "delivery": return delivery === null ? "held: delivery not yet measured over one GOP" : `held: ${delivery} < ${h.climbDeliveryRatio.toFixed(2)}`;
|
|
27159
|
+
case "no-evidence": return "held: no receiver feedback in the loss window";
|
|
27160
|
+
case "warming": return `climbing: healthy ${seconds(h.healthyForMs ?? 0)} of ${seconds(h.requiredHealthyMs ?? 0)} before ${h.nextTier}`;
|
|
27161
|
+
case "cooldown": return "held: too soon after the last change";
|
|
27162
|
+
case "capacity": return `held: probe ${kbps(h.probeKbps)}, ${h.capacityKbps === null ? "no REMB" : `REMB ${kbps(h.capacityKbps)}`} < ${barPart(h)}`;
|
|
27163
|
+
case "try-unclean": return `held: no capacity evidence for ${h.nextTier} (${witnesses(h)}); not clean enough to try: ${blockerPart(h)}`;
|
|
27164
|
+
case "try-backoff": return `held: ${revertPart(h)}; next try in ${seconds(h.retryInMs ?? 0)}`;
|
|
27165
|
+
case "trial": return `trying ${h.nextTier}: ${seconds(h.trialForMs ?? 0)} of ${seconds(h.trialMs ?? 0)} (reverts if it does not land)`;
|
|
27166
|
+
case "suspended": return "held: recorded playback on the track (ladder suspended)";
|
|
27167
|
+
}
|
|
27168
|
+
}
|
|
27169
|
+
/**
|
|
27170
|
+
* Which hold changes deserve a log line (review m3). A code logs when it
|
|
27171
|
+
* differs from the last one logged AND has not itself been logged within
|
|
27172
|
+
* `quietMs` — a link hovering at 0.90–0.95 delivery cycles delivery →
|
|
27173
|
+
* warming → try-unclean and used to write a line every 4–8 s for ever.
|
|
27174
|
+
* Progress (`warming`, `trial`) never logs; the switch line covers moves.
|
|
27175
|
+
*/
|
|
27176
|
+
var HoldLogGate = class {
|
|
27177
|
+
quietMs;
|
|
27178
|
+
last = null;
|
|
27179
|
+
loggedAt = /* @__PURE__ */ new Map();
|
|
27180
|
+
constructor(quietMs) {
|
|
27181
|
+
this.quietMs = quietMs;
|
|
27182
|
+
}
|
|
27183
|
+
shouldLog(code, now) {
|
|
27184
|
+
if (code === "warming" || code === "trial") return false;
|
|
27185
|
+
if (code === this.last) return false;
|
|
27186
|
+
const at = this.loggedAt.get(code);
|
|
27187
|
+
if (at !== void 0 && now - at < this.quietMs) return false;
|
|
27188
|
+
this.last = code;
|
|
27189
|
+
this.loggedAt.set(code, now);
|
|
27190
|
+
return true;
|
|
27191
|
+
}
|
|
27192
|
+
};
|
|
27193
|
+
//#endregion
|
|
27194
|
+
//#region src/stream-broker/webrtc/rung-bitrate.ts
|
|
27195
|
+
/**
|
|
27196
|
+
* Shortest lifetime whose average means anything: 4 000 ms ≈ one GOP on the
|
|
27197
|
+
* cameras this hub serves; less than that is one IDR divided by nothing.
|
|
27198
|
+
*/
|
|
27199
|
+
var MIN_AVERAGE_UPTIME_MS = 4e3;
|
|
27200
|
+
function expectedRungKbps(stats) {
|
|
27201
|
+
const lifetime = stats.totalBytes > 0 && stats.uptimeMs >= MIN_AVERAGE_UPTIME_MS ? Math.round(stats.totalBytes * 8 / stats.uptimeMs) : null;
|
|
27202
|
+
const windowKbps = stats.status === "streaming" && stats.bitrateKbps > 0 ? stats.bitrateKbps : null;
|
|
27203
|
+
if (windowKbps === null) return lifetime;
|
|
27204
|
+
return lifetime === null ? windowKbps : Math.max(windowKbps, lifetime);
|
|
27205
|
+
}
|
|
27206
|
+
/** The client's probe cache TTL (`DOWNLINK_TTL_MS`): the oldest a hinted probe can be. */
|
|
27207
|
+
var HINT_PROBE_MAX_AGE_MS = 3e4;
|
|
27208
|
+
function mbpsToKbps(mbps) {
|
|
27209
|
+
return mbps !== void 0 && Number.isFinite(mbps) && mbps > 0 ? mbps * 1e3 : null;
|
|
27210
|
+
}
|
|
27211
|
+
/** The probe an offer carried, dated conservatively; null when it carried none. */
|
|
27212
|
+
function probeFromHints(downlinkMbps, now) {
|
|
27213
|
+
const kbps = mbpsToKbps(downlinkMbps);
|
|
27214
|
+
return kbps === null ? null : {
|
|
27215
|
+
kbps,
|
|
27216
|
+
at: now - HINT_PROBE_MAX_AGE_MS
|
|
27217
|
+
};
|
|
27218
|
+
}
|
|
27219
|
+
/** The probe a telemetry report carried; null when it carried none. */
|
|
27220
|
+
function probeFromReport(report, now) {
|
|
27221
|
+
const kbps = mbpsToKbps(report.downlinkMbps);
|
|
27222
|
+
if (kbps === null) return null;
|
|
27223
|
+
return {
|
|
27224
|
+
kbps,
|
|
27225
|
+
at: now - Math.max(0, report.reportAgeMs) - Math.max(0, report.downlinkAgeMs)
|
|
27226
|
+
};
|
|
27227
|
+
}
|
|
27228
|
+
/** The more recent of two probes (either may be absent). */
|
|
27229
|
+
function newerProbe(a, b) {
|
|
27230
|
+
if (a === null) return b;
|
|
27231
|
+
if (b === null) return a;
|
|
27232
|
+
return b.at >= a.at ? b : a;
|
|
27233
|
+
}
|
|
27234
|
+
function isProbeFresh(probe, now) {
|
|
27235
|
+
return probe !== null && now - probe.at <= 12e4;
|
|
27236
|
+
}
|
|
27237
|
+
/**
|
|
27238
|
+
* How long the broker keeps a device's probe at all. 600 000 ms (10 min, the
|
|
27239
|
+
* try-backoff cap). A SHORT probe holds a climb until a newer probe replaces
|
|
27240
|
+
* it — and a viewer that cannot probe (no token yet, a share link, whose token
|
|
27241
|
+
* is not session-grade) never supplies one, so without an end one viewer's
|
|
27242
|
+
* 1.2 Mbps reading would hold every such viewer of the camera on `capacity`
|
|
27243
|
+
* until the broker restarted (review N1).
|
|
27244
|
+
*/
|
|
27245
|
+
var PROBE_MEMORY_TTL_MS = 6e5;
|
|
27246
|
+
/** The newest probe per device, bounded in time and forgotten with the device's last session. */
|
|
27247
|
+
var DeviceProbeMemory = class {
|
|
27248
|
+
ttlMs;
|
|
27249
|
+
probes = /* @__PURE__ */ new Map();
|
|
27250
|
+
constructor(ttlMs) {
|
|
27251
|
+
this.ttlMs = ttlMs;
|
|
27252
|
+
}
|
|
27253
|
+
/** Keep `probe` if it is newer than what is held; an absent one changes nothing. */
|
|
27254
|
+
record(deviceId, probe) {
|
|
27255
|
+
const kept = newerProbe(this.probes.get(deviceId) ?? null, probe);
|
|
27256
|
+
if (kept !== null) this.probes.set(deviceId, kept);
|
|
27257
|
+
}
|
|
27258
|
+
get(deviceId, now) {
|
|
27259
|
+
const probe = this.probes.get(deviceId);
|
|
27260
|
+
if (probe === void 0) return null;
|
|
27261
|
+
if (now - probe.at > this.ttlMs) {
|
|
27262
|
+
this.probes.delete(deviceId);
|
|
27263
|
+
return null;
|
|
27264
|
+
}
|
|
27265
|
+
return probe;
|
|
27266
|
+
}
|
|
27267
|
+
/** The device's last adaptive session ended: nobody's probe speaks for the next viewer. */
|
|
27268
|
+
forget(deviceId) {
|
|
27269
|
+
this.probes.delete(deviceId);
|
|
27270
|
+
}
|
|
27271
|
+
};
|
|
27272
|
+
//#endregion
|
|
26466
27273
|
//#region src/stream-broker/webrtc/session-debug.ts
|
|
26467
27274
|
/** `<deviceId>/<camStreamId>` → the camStreamId, or null when it is not one. */
|
|
26468
27275
|
function camStreamIdOf(brokerId) {
|
|
@@ -26501,7 +27308,8 @@ var DEAD = {
|
|
|
26501
27308
|
trackMode: null,
|
|
26502
27309
|
lastDecision: null,
|
|
26503
27310
|
tierFailures: [],
|
|
26504
|
-
upgradeCount: null
|
|
27311
|
+
upgradeCount: null,
|
|
27312
|
+
climbHold: null
|
|
26505
27313
|
};
|
|
26506
27314
|
function projectSessionDebug(input) {
|
|
26507
27315
|
const { session } = input;
|
|
@@ -26538,7 +27346,8 @@ function projectSessionDebug(input) {
|
|
|
26538
27346
|
tier,
|
|
26539
27347
|
failures
|
|
26540
27348
|
})),
|
|
26541
|
-
upgradeCount: input.ladder?.upgradeCount ?? null
|
|
27349
|
+
upgradeCount: input.ladder?.upgradeCount ?? null,
|
|
27350
|
+
climbHold: input.ladder?.climbHold ?? null
|
|
26542
27351
|
};
|
|
26543
27352
|
}
|
|
26544
27353
|
var PLAYBACK_PROFILE_FALLBACK_ORDER = [
|
|
@@ -26564,6 +27373,22 @@ function gapReachable(gap, epochMs) {
|
|
|
26564
27373
|
}
|
|
26565
27374
|
//#endregion
|
|
26566
27375
|
//#region src/stream-broker/recorded/control-protocol.ts
|
|
27376
|
+
/**
|
|
27377
|
+
* The rates a clip may be OFFERED at — re-exported from the capability, which
|
|
27378
|
+
* is where the one list lives (D612). The broker owns the clamp; the cap owns
|
|
27379
|
+
* the ladder; a second list here would be the second authority D62 forbids.
|
|
27380
|
+
*
|
|
27381
|
+
* Declared at the top of the file because the clamp and the `setRate` bound
|
|
27382
|
+
* are DERIVED from its ends (D621). They were three literals — this array, a
|
|
27383
|
+
* `Math.min(4, …)` and a `.max(4)` — and that is precisely how 8 and 16 came
|
|
27384
|
+
* to be offered while the clamp served 4 with no line anywhere saying so. One
|
|
27385
|
+
* decision now has one place to be written down.
|
|
27386
|
+
*/
|
|
27387
|
+
var BROKER_PACED_RATES = require_dist.CLIP_BROKER_PACED_RATES;
|
|
27388
|
+
/** The slowest offered rate. Anything slower is clamped up to it. */
|
|
27389
|
+
var MIN_PACED_RATE = Math.min(...BROKER_PACED_RATES);
|
|
27390
|
+
/** The fastest offered rate. Anything faster is refused at the wire. */
|
|
27391
|
+
var MAX_PACED_RATE = Math.max(...BROKER_PACED_RATES);
|
|
26567
27392
|
var ControlMessageSchema = require_dist.discriminatedUnion("t", [
|
|
26568
27393
|
require_dist.object({
|
|
26569
27394
|
t: require_dist.literal("playRecorded"),
|
|
@@ -26630,7 +27455,7 @@ var ControlMessageSchema = require_dist.discriminatedUnion("t", [
|
|
|
26630
27455
|
}),
|
|
26631
27456
|
require_dist.object({
|
|
26632
27457
|
t: require_dist.literal("setRate"),
|
|
26633
|
-
rate: require_dist.number().min(0).max(
|
|
27458
|
+
rate: require_dist.number().min(0).max(MAX_PACED_RATE)
|
|
26634
27459
|
}),
|
|
26635
27460
|
require_dist.object({
|
|
26636
27461
|
t: require_dist.literal("stepFrame"),
|
|
@@ -26643,22 +27468,41 @@ var ControlMessageSchema = require_dist.discriminatedUnion("t", [
|
|
|
26643
27468
|
})
|
|
26644
27469
|
]);
|
|
26645
27470
|
/**
|
|
26646
|
-
* Clamp a requested playback rate onto the supported domain:
|
|
26647
|
-
* 0
|
|
26648
|
-
*
|
|
26649
|
-
*
|
|
27471
|
+
* Clamp a requested playback rate onto the supported domain:
|
|
27472
|
+
* {0} ∪ [{@link MIN_PACED_RATE}, {@link MAX_PACED_RATE}] — the ends of
|
|
27473
|
+
* {@link BROKER_PACED_RATES} itself. 0 (and any non-finite / non-positive
|
|
27474
|
+
* input) pauses; anything else is a continuous rate clamped into that band.
|
|
27475
|
+
* Single server-side authority — the feeder applies it to every inbound
|
|
27476
|
+
* `setRate`.
|
|
27477
|
+
*
|
|
27478
|
+
* That every ladder entry survives this unchanged is now structural rather
|
|
27479
|
+
* than a discipline. What is NOT structural, and is therefore the gate that
|
|
27480
|
+
* matters, is whether the pacer really empties a clip `rate` times faster:
|
|
27481
|
+
* `clip-stream-feeder.spec` drives every entry and measures it.
|
|
26650
27482
|
*/
|
|
26651
27483
|
function clampPlaybackRate(rate) {
|
|
26652
27484
|
if (!Number.isFinite(rate) || rate <= 0) return 0;
|
|
26653
|
-
return Math.min(
|
|
27485
|
+
return Math.min(MAX_PACED_RATE, Math.max(MIN_PACED_RATE, rate));
|
|
26654
27486
|
}
|
|
26655
27487
|
/**
|
|
26656
|
-
*
|
|
26657
|
-
*
|
|
26658
|
-
*
|
|
26659
|
-
* `
|
|
27488
|
+
* Does a clip carry SOUND at this rate?
|
|
27489
|
+
*
|
|
27490
|
+
* ONE predicate, read by the branch that DROPS the audio
|
|
27491
|
+
* (`ClipStreamFeeder.pushNextAudio`) and by the branch that TELLS the viewer
|
|
27492
|
+
* why it is silent — because a second copy of `rate !== 1` is exactly the
|
|
27493
|
+
* disagreeing second switch D62 forbids, and the telling would then be able
|
|
27494
|
+
* to describe a drop that did not happen (or miss one that did).
|
|
27495
|
+
*
|
|
27496
|
+
* *"L'audio non serve in velocità non 1x!!!"* — the operator, 2026-09-23.
|
|
27497
|
+
* Resampling would need a decoder this path has not got, and pitch-shifted
|
|
27498
|
+
* sound is worse than none.
|
|
27499
|
+
*
|
|
27500
|
+
* Rate 0 is PAUSE, not a rate: nothing plays at all there, so it is not this
|
|
27501
|
+
* predicate's business and callers hold their last non-zero rate instead.
|
|
26660
27502
|
*/
|
|
26661
|
-
|
|
27503
|
+
function audioPlaysAtRate(rate) {
|
|
27504
|
+
return rate === 1;
|
|
27505
|
+
}
|
|
26662
27506
|
var ServerMessageSchema = require_dist.discriminatedUnion("t", [
|
|
26663
27507
|
require_dist.object({
|
|
26664
27508
|
t: require_dist.literal("state"),
|
|
@@ -26699,6 +27543,14 @@ var ServerMessageSchema = require_dist.discriminatedUnion("t", [
|
|
|
26699
27543
|
clipId: require_dist.string().min(1),
|
|
26700
27544
|
durationMs: require_dist.number().positive()
|
|
26701
27545
|
}),
|
|
27546
|
+
require_dist.object({
|
|
27547
|
+
t: require_dist.literal("clipIncomplete"),
|
|
27548
|
+
clipId: require_dist.string().min(1),
|
|
27549
|
+
code: require_dist._enum(["stream-short", "stream-empty"]),
|
|
27550
|
+
/** The provider's own sentence, already Latin-1 safe. Shown where the
|
|
27551
|
+
* stream error is shown. */
|
|
27552
|
+
reason: require_dist.string().min(1)
|
|
27553
|
+
}),
|
|
26702
27554
|
require_dist.object({
|
|
26703
27555
|
t: require_dist.literal("clipAudio"),
|
|
26704
27556
|
clipId: require_dist.string().min(1),
|
|
@@ -26711,8 +27563,23 @@ var ServerMessageSchema = require_dist.discriminatedUnion("t", [
|
|
|
26711
27563
|
"no-audio-track",
|
|
26712
27564
|
"unsupported-codec",
|
|
26713
27565
|
"codec-mismatch",
|
|
26714
|
-
"stream-failed"
|
|
26715
|
-
|
|
27566
|
+
"stream-failed",
|
|
27567
|
+
"rate"
|
|
27568
|
+
]).optional(),
|
|
27569
|
+
/**
|
|
27570
|
+
* The rate the sound is suppressed AT. Present iff `reason` is `rate`,
|
|
27571
|
+
* and refused otherwise — see the check below.
|
|
27572
|
+
*/
|
|
27573
|
+
rate: require_dist.number().positive().optional()
|
|
27574
|
+
}).check((ctx) => {
|
|
27575
|
+
const value = ctx.value;
|
|
27576
|
+
if (value.reason === "rate" === (value.rate !== void 0)) return;
|
|
27577
|
+
ctx.issues.push({
|
|
27578
|
+
code: "custom",
|
|
27579
|
+
message: "`rate` is present exactly when `reason` is `rate`",
|
|
27580
|
+
input: value,
|
|
27581
|
+
path: ["rate"]
|
|
27582
|
+
});
|
|
26716
27583
|
})
|
|
26717
27584
|
]);
|
|
26718
27585
|
function parseControlMessage(raw) {
|
|
@@ -27684,7 +28551,7 @@ var RecordedFeeder = class {
|
|
|
27684
28551
|
/** Audio clock (PCMU 8 kHz / Opus 48 kHz); re-anchored together with video. */
|
|
27685
28552
|
audioClock;
|
|
27686
28553
|
clockHasAdvanced = false;
|
|
27687
|
-
/** Playback rate: 0 (paused) or a continuous factor in [0.25,
|
|
28554
|
+
/** Playback rate: 0 (paused) or a continuous factor in [0.25, 8]. */
|
|
27688
28555
|
rate = 1;
|
|
27689
28556
|
disposed = false;
|
|
27690
28557
|
seekToken = 0;
|
|
@@ -28265,7 +29132,7 @@ var RecordedFeeder = class {
|
|
|
28265
29132
|
}
|
|
28266
29133
|
/**
|
|
28267
29134
|
* Set playback rate. 0 pauses in place (the pacing loop aborts on its next
|
|
28268
|
-
* guard check); any other value is clamped to [0.25,
|
|
29135
|
+
* guard check); any other value is clamped to [0.25, 8].
|
|
28269
29136
|
*
|
|
28270
29137
|
* Rate-change strategy (documented choice): every transition TO a non-zero
|
|
28271
29138
|
* rate — resume from pause AND a playing→playing rate change — re-enters via
|
|
@@ -30130,17 +30997,25 @@ var TimelineSession = class {
|
|
|
30130
30997
|
this.lastPositionMs = null;
|
|
30131
30998
|
const feeder = this.enterRecorded();
|
|
30132
30999
|
await feeder.seek(offsetMs);
|
|
30133
|
-
await Promise.all([
|
|
31000
|
+
await Promise.all([
|
|
31001
|
+
this.reportClipDuration(clipId, feeder),
|
|
31002
|
+
this.reportClipAudio(clipId, feeder),
|
|
31003
|
+
this.reportClipIncomplete(clipId, feeder)
|
|
31004
|
+
]);
|
|
30134
31005
|
}
|
|
30135
31006
|
/**
|
|
30136
31007
|
* Tell the viewer whether this clip has sound.
|
|
30137
31008
|
*
|
|
30138
|
-
* Silence has
|
|
30139
|
-
*
|
|
30140
|
-
*
|
|
30141
|
-
*
|
|
30142
|
-
* that
|
|
30143
|
-
*
|
|
31009
|
+
* Silence has causes the operator cannot tell apart by listening — a camera
|
|
31010
|
+
* with no microphone, a transport that carried no audio, a session that
|
|
31011
|
+
* negotiated none, and (D621) a clip playing at anything but 1×, where the
|
|
31012
|
+
* broker drops every frame on purpose — so the viewer is told which. Every
|
|
31013
|
+
* branch that sends nothing says so (D391): a feeder that cannot answer, an
|
|
31014
|
+
* answer that rejected, and an answer that landed after the pointer had left
|
|
31015
|
+
* the clip, which must not be pinned on whatever came next.
|
|
31016
|
+
*
|
|
31017
|
+
* The first answer SETTLES; the rate does not, so this also subscribes for
|
|
31018
|
+
* as long as the clip is on the pointer.
|
|
30144
31019
|
*/
|
|
30145
31020
|
async reportClipAudio(clipId, feeder) {
|
|
30146
31021
|
if (feeder.audio === void 0) {
|
|
@@ -30161,11 +31036,25 @@ var TimelineSession = class {
|
|
|
30161
31036
|
});
|
|
30162
31037
|
return;
|
|
30163
31038
|
}
|
|
31039
|
+
this.sendClipAudio(clipId, report);
|
|
31040
|
+
feeder.watchAudio?.((later) => this.sendClipAudio(clipId, later));
|
|
31041
|
+
}
|
|
31042
|
+
/**
|
|
31043
|
+
* Send one `clipAudio`, or say why nothing was sent.
|
|
31044
|
+
*
|
|
31045
|
+
* ONE place, for the settle and for every later change: they are the same
|
|
31046
|
+
* statement about the same clip, and a second builder is how the two would
|
|
31047
|
+
* come to disagree about what `reason` means. The clip-identity guard lives
|
|
31048
|
+
* here too — a report that lands after the pointer has left names a clip
|
|
31049
|
+
* nobody is playing, and the viewer would mute whatever came next.
|
|
31050
|
+
*/
|
|
31051
|
+
sendClipAudio(clipId, report) {
|
|
30164
31052
|
if (this.clip?.clipId !== clipId) {
|
|
30165
31053
|
this.deps.onLog?.("clip playback: audio dropped — the pointer has left this clip", {
|
|
30166
31054
|
deviceId: this.deps.deviceId,
|
|
30167
31055
|
clipId,
|
|
30168
|
-
carried: report.carried
|
|
31056
|
+
carried: report.carried,
|
|
31057
|
+
reason: report.reason
|
|
30169
31058
|
});
|
|
30170
31059
|
return;
|
|
30171
31060
|
}
|
|
@@ -30175,6 +31064,7 @@ var TimelineSession = class {
|
|
|
30175
31064
|
carried: report.carried,
|
|
30176
31065
|
codec: report.codec,
|
|
30177
31066
|
reason: report.reason,
|
|
31067
|
+
rate: report.rate,
|
|
30178
31068
|
detail: report.detail
|
|
30179
31069
|
});
|
|
30180
31070
|
this.deps.session.sendControl(serializeServerMessage({
|
|
@@ -30182,7 +31072,63 @@ var TimelineSession = class {
|
|
|
30182
31072
|
clipId,
|
|
30183
31073
|
carried: report.carried,
|
|
30184
31074
|
...report.codec !== null ? { codec: report.codec } : {},
|
|
30185
|
-
...report.reason !== null ? { reason: report.reason } : {}
|
|
31075
|
+
...report.reason !== null ? { reason: report.reason } : {},
|
|
31076
|
+
...report.reason === "rate" && report.rate !== void 0 ? { rate: report.rate } : {}
|
|
31077
|
+
}));
|
|
31078
|
+
}
|
|
31079
|
+
/**
|
|
31080
|
+
* Tell the operator that the clip he is watching did not arrive whole
|
|
31081
|
+
* (D623), in the surface that already shows him a stream error.
|
|
31082
|
+
*
|
|
31083
|
+
* Awaited AFTER the stream ends, which is when the provider gets to speak,
|
|
31084
|
+
* and guarded by the clip identity for the same reason the audio report is:
|
|
31085
|
+
* a late answer names a clip nobody is playing and the viewer would put the
|
|
31086
|
+
* complaint on whatever came next.
|
|
31087
|
+
*
|
|
31088
|
+
* There is no retry here and there must not be one. *"Se cade la sessione
|
|
31089
|
+
* cade non fa niente"* — the operator, 2026-09-23. A second dial would
|
|
31090
|
+
* contend for the camera's single replay lane exactly while that lane is
|
|
31091
|
+
* already failing.
|
|
31092
|
+
*/
|
|
31093
|
+
async reportClipIncomplete(clipId, feeder) {
|
|
31094
|
+
if (feeder.incomplete === void 0) {
|
|
31095
|
+
this.deps.onLog?.("clip playback: completeness not reported — this feeder cannot ask the provider", {
|
|
31096
|
+
deviceId: this.deps.deviceId,
|
|
31097
|
+
clipId
|
|
31098
|
+
});
|
|
31099
|
+
return;
|
|
31100
|
+
}
|
|
31101
|
+
let report;
|
|
31102
|
+
try {
|
|
31103
|
+
report = await feeder.incomplete();
|
|
31104
|
+
} catch (err) {
|
|
31105
|
+
this.deps.onLog?.("clip playback: completeness not reported — the answer failed", {
|
|
31106
|
+
deviceId: this.deps.deviceId,
|
|
31107
|
+
clipId,
|
|
31108
|
+
error: err instanceof Error ? err.message : String(err)
|
|
31109
|
+
});
|
|
31110
|
+
return;
|
|
31111
|
+
}
|
|
31112
|
+
if (report === null) return;
|
|
31113
|
+
if (this.clip?.clipId !== clipId) {
|
|
31114
|
+
this.deps.onLog?.("clip playback: completeness dropped — the pointer has left this clip", {
|
|
31115
|
+
deviceId: this.deps.deviceId,
|
|
31116
|
+
clipId,
|
|
31117
|
+
code: report.code
|
|
31118
|
+
});
|
|
31119
|
+
return;
|
|
31120
|
+
}
|
|
31121
|
+
this.deps.onLog?.("clip playback: the clip did not arrive whole", {
|
|
31122
|
+
deviceId: this.deps.deviceId,
|
|
31123
|
+
clipId,
|
|
31124
|
+
code: report.code,
|
|
31125
|
+
reason: report.reason
|
|
31126
|
+
});
|
|
31127
|
+
this.deps.session.sendControl(serializeServerMessage({
|
|
31128
|
+
t: "clipIncomplete",
|
|
31129
|
+
clipId,
|
|
31130
|
+
code: report.code,
|
|
31131
|
+
reason: report.reason
|
|
30186
31132
|
}));
|
|
30187
31133
|
}
|
|
30188
31134
|
/**
|
|
@@ -31584,7 +32530,7 @@ function createFmp4StreamDemuxer(options = {}) {
|
|
|
31584
32530
|
* faster than the camera sends.
|
|
31585
32531
|
* - `stepFrame(-1)` and the scrub verbs are refused by name; `stepFrame(+1)`
|
|
31586
32532
|
* pushes the next buffered unit.
|
|
31587
|
-
* - `setRate` is the PLAYER's rate over frames already here ({0} ∪ [0.25,
|
|
32533
|
+
* - `setRate` is the PLAYER's rate over frames already here ({0} ∪ [0.25, 8],
|
|
31588
32534
|
* the recorded domain). The camera's own `playSpeed` (8, 16 — real, the app
|
|
31589
32535
|
* reopens the replay at 8) is a different mechanism and is NOT wired: it
|
|
31590
32536
|
* would go on the dial (`videoclips.dialClipStream` → the cmd-5 replay's
|
|
@@ -31650,6 +32596,11 @@ function createFmp4StreamDemuxer(options = {}) {
|
|
|
31650
32596
|
* `data`, which the broker's `onLog` puts in `tags`.
|
|
31651
32597
|
*/
|
|
31652
32598
|
/**
|
|
32599
|
+
* The reason an in-flight open is abandoned, carried on the abort so the
|
|
32600
|
+
* opener's refusal and the provider's log line say the same thing.
|
|
32601
|
+
*/
|
|
32602
|
+
var CLIP_STREAM_ABANDONED = "clip-stream-abandoned";
|
|
32603
|
+
/**
|
|
31653
32604
|
* The same ceiling as the file path (`CLIP_SOURCE_MAX_BYTES`): a clip the
|
|
31654
32605
|
* file path would refuse `too-large` is one this path stops taking. Frames
|
|
31655
32606
|
* behind the head are released as they are pushed, so the backlog is the
|
|
@@ -31672,6 +32623,8 @@ var ClipStreamFeeder = class {
|
|
|
31672
32623
|
deps;
|
|
31673
32624
|
phase = "idle";
|
|
31674
32625
|
connection = null;
|
|
32626
|
+
/** Armed for the length of one `open()`; aborted by {@link teardown}. */
|
|
32627
|
+
opening = null;
|
|
31675
32628
|
demuxer;
|
|
31676
32629
|
clock = new MonotonicClock();
|
|
31677
32630
|
/** The RTP rate the audio sender negotiated — a CONVERSION, not a clock. */
|
|
@@ -31684,10 +32637,28 @@ var ClipStreamFeeder = class {
|
|
|
31684
32637
|
audioPushed = 0;
|
|
31685
32638
|
audioDroppedLate = 0;
|
|
31686
32639
|
audioDroppedRate = 0;
|
|
32640
|
+
/**
|
|
32641
|
+
* The last report handed to a caller — the settle promise's value, or the
|
|
32642
|
+
* newest one {@link watchAudio}'s listener was given. What a change is
|
|
32643
|
+
* measured against, so the same answer is never announced twice.
|
|
32644
|
+
*/
|
|
32645
|
+
audioEmitted = null;
|
|
32646
|
+
/**
|
|
32647
|
+
* The last NON-ZERO rate. Pause is the absence of a rate, not a rate: a
|
|
32648
|
+
* `clipAudio` blaming the speed while the clip is stopped would be false,
|
|
32649
|
+
* and un-pausing would have to take it back.
|
|
32650
|
+
*/
|
|
32651
|
+
audioRate = 1;
|
|
32652
|
+
audioListener = null;
|
|
31687
32653
|
audioDroppedSeek = 0;
|
|
31688
32654
|
firstAudioLogged = false;
|
|
31689
32655
|
audioReport = null;
|
|
31690
32656
|
audioWaiters = [];
|
|
32657
|
+
/** The provider's last word, and whether it has been given. Settled is not
|
|
32658
|
+
* the same as non-null: a whole stream settles `null` (D623). */
|
|
32659
|
+
incompleteSettled = false;
|
|
32660
|
+
incompleteReport = null;
|
|
32661
|
+
incompleteWaiters = [];
|
|
31691
32662
|
clockHasAdvanced = false;
|
|
31692
32663
|
rate = 1;
|
|
31693
32664
|
queue = [];
|
|
@@ -31719,12 +32690,16 @@ var ClipStreamFeeder = class {
|
|
|
31719
32690
|
firstFrameLogged = false;
|
|
31720
32691
|
maxBufferedBytes;
|
|
31721
32692
|
stallTimeoutMs;
|
|
32693
|
+
prerollMs;
|
|
32694
|
+
/** True while the pacer is waiting for the cushion to refill after a starve. */
|
|
32695
|
+
rebuffering = false;
|
|
31722
32696
|
now;
|
|
31723
32697
|
tags;
|
|
31724
32698
|
constructor(deps) {
|
|
31725
32699
|
this.deps = deps;
|
|
31726
32700
|
this.maxBufferedBytes = deps.maxBufferedBytes ?? CLIP_STREAM_BUFFER_MAX_BYTES;
|
|
31727
32701
|
this.stallTimeoutMs = deps.stallTimeoutMs ?? 15e3;
|
|
32702
|
+
this.prerollMs = deps.prerollMs ?? 600;
|
|
31728
32703
|
this.now = deps.now ?? (() => Date.now());
|
|
31729
32704
|
this.tags = {
|
|
31730
32705
|
deviceId: deps.handle.deviceId,
|
|
@@ -31822,6 +32797,8 @@ var ClipStreamFeeder = class {
|
|
|
31822
32797
|
this.clock.setRate(next);
|
|
31823
32798
|
const wasPaused = this.rate === 0;
|
|
31824
32799
|
this.rate = next;
|
|
32800
|
+
this.audioRate = next;
|
|
32801
|
+
this.refreshAudioReport();
|
|
31825
32802
|
if (this.phase !== "streaming" || this.pendingSeekMs !== null) return;
|
|
31826
32803
|
if (wasPaused) this.emitState("playing", "set-rate-resume", this.seekToken);
|
|
31827
32804
|
this.startPacing();
|
|
@@ -31856,6 +32833,19 @@ var ClipStreamFeeder = class {
|
|
|
31856
32833
|
return new Promise((resolve) => this.durationWaiters.push(resolve));
|
|
31857
32834
|
}
|
|
31858
32835
|
/**
|
|
32836
|
+
* Did the provider deliver this clip whole? (D623)
|
|
32837
|
+
*
|
|
32838
|
+
* Resolves `null` for a whole stream AND for one that never got to say —
|
|
32839
|
+
* cut, disposed, or opened against a provider that adds no trailer. Both are
|
|
32840
|
+
* "nothing to tell the operator", and neither may be reported as a defect:
|
|
32841
|
+
* a cut already announces itself as `state: error`, and inventing a second
|
|
32842
|
+
* complaint for it would file one fault twice.
|
|
32843
|
+
*/
|
|
32844
|
+
incomplete() {
|
|
32845
|
+
if (this.incompleteSettled) return Promise.resolve(this.incompleteReport);
|
|
32846
|
+
return new Promise((resolve) => this.incompleteWaiters.push(resolve));
|
|
32847
|
+
}
|
|
32848
|
+
/**
|
|
31859
32849
|
* What this clip turned out to carry as sound — the answer the viewer needs
|
|
31860
32850
|
* to say "this clip has no audio" rather than leave the operator wondering.
|
|
31861
32851
|
*
|
|
@@ -31865,15 +32855,31 @@ var ClipStreamFeeder = class {
|
|
|
31865
32855
|
*/
|
|
31866
32856
|
audio() {
|
|
31867
32857
|
const settled = this.audioReport;
|
|
31868
|
-
if (settled !== null) return Promise.resolve(settled);
|
|
32858
|
+
if (settled !== null) return Promise.resolve(this.audioNow(settled));
|
|
31869
32859
|
return new Promise((resolve) => this.audioWaiters.push(resolve));
|
|
31870
32860
|
}
|
|
32861
|
+
/**
|
|
32862
|
+
* Follow the audio answer for as long as this clip is on the pointer.
|
|
32863
|
+
*
|
|
32864
|
+
* {@link audio} settles ONCE, because what the stream carries is decided
|
|
32865
|
+
* once — at the `moov`. The RATE is not: it moves under the operator's
|
|
32866
|
+
* finger, and a one-shot report cannot say that the clip which had sound a
|
|
32867
|
+
* second ago is silent now because he pressed 2×. The listener is told only
|
|
32868
|
+
* when the answer really changes, never the settle it already awaited.
|
|
32869
|
+
*
|
|
32870
|
+
* One listener, replaced rather than added to: there is one session behind
|
|
32871
|
+
* one clip, and a list would outlive the pointer.
|
|
32872
|
+
*/
|
|
32873
|
+
watchAudio(listener) {
|
|
32874
|
+
this.audioListener = listener;
|
|
32875
|
+
}
|
|
31871
32876
|
dispose() {
|
|
31872
32877
|
if (this.phase === "disposed") return;
|
|
31873
32878
|
const before = this.phase;
|
|
31874
32879
|
this.phase = "disposed";
|
|
31875
32880
|
this.teardown();
|
|
31876
32881
|
this.settleDuration(null);
|
|
32882
|
+
this.settleIncomplete(null);
|
|
31877
32883
|
this.settleAudio({
|
|
31878
32884
|
carried: false,
|
|
31879
32885
|
codec: null,
|
|
@@ -31944,13 +32950,15 @@ var ClipStreamFeeder = class {
|
|
|
31944
32950
|
audioReason: answer.audioReason ?? null
|
|
31945
32951
|
});
|
|
31946
32952
|
}
|
|
32953
|
+
const abandon = new AbortController();
|
|
32954
|
+
this.opening = abandon;
|
|
31947
32955
|
let opened;
|
|
31948
32956
|
try {
|
|
31949
32957
|
opened = await this.deps.open(answer.url, {
|
|
31950
32958
|
onData: (chunk) => this.onData(chunk),
|
|
31951
|
-
onEnd: () => this.onEnd(),
|
|
32959
|
+
onEnd: (report) => this.onEnd(report),
|
|
31952
32960
|
onError: (err) => this.cut("stream-failed", err.message)
|
|
31953
|
-
});
|
|
32961
|
+
}, abandon.signal);
|
|
31954
32962
|
} catch (err) {
|
|
31955
32963
|
opened = {
|
|
31956
32964
|
kind: "refused",
|
|
@@ -31959,8 +32967,15 @@ var ClipStreamFeeder = class {
|
|
|
31959
32967
|
detail: describe(err)
|
|
31960
32968
|
};
|
|
31961
32969
|
}
|
|
32970
|
+
if (this.opening === abandon) this.opening = null;
|
|
31962
32971
|
if (this.phase !== "opening") {
|
|
31963
32972
|
if (opened.kind === "open") opened.connection.destroy();
|
|
32973
|
+
this.log("clip-stream:open-abandoned", {
|
|
32974
|
+
phase: this.phase,
|
|
32975
|
+
opened: opened.kind,
|
|
32976
|
+
sinceDialMs: this.now() - (this.dialedAt ?? this.now()),
|
|
32977
|
+
...opened.kind === "refused" ? { code: opened.code } : {}
|
|
32978
|
+
});
|
|
31964
32979
|
return;
|
|
31965
32980
|
}
|
|
31966
32981
|
if (opened.kind === "refused") {
|
|
@@ -31980,6 +32995,7 @@ var ClipStreamFeeder = class {
|
|
|
31980
32995
|
this.phase = "refused";
|
|
31981
32996
|
this.teardown();
|
|
31982
32997
|
this.settleDuration(null);
|
|
32998
|
+
this.settleIncomplete(null);
|
|
31983
32999
|
this.settleAudio({
|
|
31984
33000
|
carried: false,
|
|
31985
33001
|
codec: null,
|
|
@@ -32101,10 +33117,24 @@ var ClipStreamFeeder = class {
|
|
|
32101
33117
|
return;
|
|
32102
33118
|
}
|
|
32103
33119
|
while (this.rate === 0 && this.pendingSteps > 0 && this.pushNext()) this.pendingSteps -= 1;
|
|
32104
|
-
if (this.rate
|
|
33120
|
+
if (this.rate === 0) return;
|
|
33121
|
+
if (this.rebuffering) {
|
|
33122
|
+
if (this.cushionShortfallMs() > 0) return;
|
|
33123
|
+
this.rebuffering = false;
|
|
33124
|
+
this.log("clip-stream:refilled", {
|
|
33125
|
+
headMs: this.headMs,
|
|
33126
|
+
arrivedMs: this.arrivedMs,
|
|
33127
|
+
queued: this.queue.length,
|
|
33128
|
+
starved: this.starved
|
|
33129
|
+
});
|
|
33130
|
+
this.startPacing();
|
|
33131
|
+
return;
|
|
33132
|
+
}
|
|
33133
|
+
if (this.paceTimer === null) this.tick();
|
|
32105
33134
|
}
|
|
32106
|
-
onEnd() {
|
|
33135
|
+
onEnd(report) {
|
|
32107
33136
|
if (!this.takesBytes()) return;
|
|
33137
|
+
this.settleIncomplete(report);
|
|
32108
33138
|
this.streamEnded = true;
|
|
32109
33139
|
this.clearStall();
|
|
32110
33140
|
this.streamEnd = this.demuxer.end();
|
|
@@ -32122,13 +33152,20 @@ var ClipStreamFeeder = class {
|
|
|
32122
33152
|
this.resolvePendingSeek(this.seekToken);
|
|
32123
33153
|
return;
|
|
32124
33154
|
}
|
|
32125
|
-
if (this.rate
|
|
33155
|
+
if (this.rate === 0) return;
|
|
33156
|
+
if (this.rebuffering || this.anchorWall > this.now()) {
|
|
33157
|
+
this.rebuffering = false;
|
|
33158
|
+
this.startPacing();
|
|
33159
|
+
return;
|
|
33160
|
+
}
|
|
33161
|
+
if (this.paceTimer === null) this.tick();
|
|
32126
33162
|
}
|
|
32127
33163
|
cut(reason, detail) {
|
|
32128
33164
|
if (this.phase !== "streaming" && this.phase !== "opening") return;
|
|
32129
33165
|
this.phase = "cut";
|
|
32130
33166
|
this.teardown();
|
|
32131
33167
|
this.settleDuration(null);
|
|
33168
|
+
this.settleIncomplete(null);
|
|
32132
33169
|
this.settleAudio({
|
|
32133
33170
|
carried: false,
|
|
32134
33171
|
codec: null,
|
|
@@ -32262,9 +33299,24 @@ var ClipStreamFeeder = class {
|
|
|
32262
33299
|
this.audioQueue = this.audioQueue.slice(drop);
|
|
32263
33300
|
this.audioDroppedSeek += drop;
|
|
32264
33301
|
}
|
|
33302
|
+
/**
|
|
33303
|
+
* How much of the cushion is MISSING, in milliseconds of media.
|
|
33304
|
+
*
|
|
33305
|
+
* Zero once the stream has ended — there is nothing more coming, so waiting
|
|
33306
|
+
* for it would hang the clip — and zero for any source that already holds
|
|
33307
|
+
* more than the cushion, which is every fast one.
|
|
33308
|
+
*/
|
|
33309
|
+
cushionShortfallMs() {
|
|
33310
|
+
if (this.streamEnded) return 0;
|
|
33311
|
+
const head = this.headMs;
|
|
33312
|
+
const arrived = this.arrivedMs;
|
|
33313
|
+
if (head === null || arrived === null) return this.prerollMs;
|
|
33314
|
+
return Math.max(0, this.prerollMs - (arrived - head));
|
|
33315
|
+
}
|
|
32265
33316
|
startPacing() {
|
|
32266
33317
|
this.stopPacing();
|
|
32267
|
-
|
|
33318
|
+
const shortfall = this.cushionShortfallMs();
|
|
33319
|
+
this.anchorWall = this.now() + shortfall;
|
|
32268
33320
|
this.anchorPts = this.headMs ?? this.queue[0]?.ptsMs ?? 0;
|
|
32269
33321
|
this.startPositionReports();
|
|
32270
33322
|
this.tick();
|
|
@@ -32289,7 +33341,10 @@ var ClipStreamFeeder = class {
|
|
|
32289
33341
|
const audio = this.audioQueue[0];
|
|
32290
33342
|
if (video === void 0 && audio === void 0) {
|
|
32291
33343
|
if (this.streamEnded) this.finish();
|
|
32292
|
-
else
|
|
33344
|
+
else {
|
|
33345
|
+
this.rebuffering = true;
|
|
33346
|
+
this.noteStarved();
|
|
33347
|
+
}
|
|
32293
33348
|
return;
|
|
32294
33349
|
}
|
|
32295
33350
|
const takeVideo = video !== void 0 && (audio === void 0 || video.ptsMs <= audio.ptsMs);
|
|
@@ -32325,7 +33380,7 @@ var ClipStreamFeeder = class {
|
|
|
32325
33380
|
if (frame === void 0) return false;
|
|
32326
33381
|
this.audioQueue = this.audioQueue.slice(1);
|
|
32327
33382
|
this.audioQueuedBytes -= frame.data.byteLength;
|
|
32328
|
-
if (this.rate
|
|
33383
|
+
if (!audioPlaysAtRate(this.rate)) {
|
|
32329
33384
|
this.audioDroppedRate += 1;
|
|
32330
33385
|
return true;
|
|
32331
33386
|
}
|
|
@@ -32423,11 +33478,53 @@ var ClipStreamFeeder = class {
|
|
|
32423
33478
|
audioQueued: this.audioQueue.length
|
|
32424
33479
|
};
|
|
32425
33480
|
}
|
|
33481
|
+
/** One-shot, like the duration beside it: the provider speaks once, at the
|
|
33482
|
+
* end of the body, and a cut settles `null` so nobody waits for ever. */
|
|
33483
|
+
settleIncomplete(report) {
|
|
33484
|
+
if (this.incompleteSettled) return;
|
|
33485
|
+
this.incompleteSettled = true;
|
|
33486
|
+
this.incompleteReport = report;
|
|
33487
|
+
if (report !== null) this.log("clip-stream:incomplete", {
|
|
33488
|
+
code: report.code,
|
|
33489
|
+
reason: report.reason
|
|
33490
|
+
});
|
|
33491
|
+
for (const resolve of this.incompleteWaiters.splice(0)) resolve(report);
|
|
33492
|
+
}
|
|
32426
33493
|
settleAudio(report) {
|
|
32427
33494
|
if (this.audioReport !== null) return;
|
|
32428
33495
|
this.audioReport = report;
|
|
33496
|
+
const now = this.audioNow(report);
|
|
33497
|
+
this.audioEmitted = now;
|
|
32429
33498
|
const waiters = this.audioWaiters.splice(0);
|
|
32430
|
-
for (const resolve of waiters) resolve(
|
|
33499
|
+
for (const resolve of waiters) resolve(now);
|
|
33500
|
+
}
|
|
33501
|
+
/**
|
|
33502
|
+
* The audio answer AS IT STANDS — the stream's own settled fact, with the
|
|
33503
|
+
* rate laid over it.
|
|
33504
|
+
*
|
|
33505
|
+
* The overlay only ever applies to a clip that really is carrying sound. A
|
|
33506
|
+
* clip whose camera has no microphone is silent at 1× too, and replacing
|
|
33507
|
+
* `no-audio-track` with `rate` would swap the true cause for a transient
|
|
33508
|
+
* one and send the operator to the speed chip.
|
|
33509
|
+
*/
|
|
33510
|
+
audioNow(settled) {
|
|
33511
|
+
if (!settled.carried || audioPlaysAtRate(this.audioRate)) return settled;
|
|
33512
|
+
return {
|
|
33513
|
+
carried: false,
|
|
33514
|
+
codec: null,
|
|
33515
|
+
reason: "rate",
|
|
33516
|
+
rate: this.audioRate
|
|
33517
|
+
};
|
|
33518
|
+
}
|
|
33519
|
+
/** Tell the listener when — and only when — the answer has actually moved. */
|
|
33520
|
+
refreshAudioReport() {
|
|
33521
|
+
const settled = this.audioReport;
|
|
33522
|
+
if (settled === null) return;
|
|
33523
|
+
const next = this.audioNow(settled);
|
|
33524
|
+
const last = this.audioEmitted;
|
|
33525
|
+
if (last !== null && last.reason === next.reason && last.rate === next.rate) return;
|
|
33526
|
+
this.audioEmitted = next;
|
|
33527
|
+
this.audioListener?.(next);
|
|
32431
33528
|
}
|
|
32432
33529
|
settleDuration(value) {
|
|
32433
33530
|
if (this.duration !== null) return;
|
|
@@ -32442,6 +33539,9 @@ var ClipStreamFeeder = class {
|
|
|
32442
33539
|
this.stopPacing();
|
|
32443
33540
|
this.stopPositionReports();
|
|
32444
33541
|
this.clearStall();
|
|
33542
|
+
const opening = this.opening;
|
|
33543
|
+
this.opening = null;
|
|
33544
|
+
opening?.abort(/* @__PURE__ */ new Error(CLIP_STREAM_ABANDONED));
|
|
32445
33545
|
const connection = this.connection;
|
|
32446
33546
|
this.connection = null;
|
|
32447
33547
|
connection?.destroy();
|
|
@@ -32483,6 +33583,62 @@ var ClipStreamFeeder = class {
|
|
|
32483
33583
|
function describe(err) {
|
|
32484
33584
|
return err instanceof Error ? err.message : String(err);
|
|
32485
33585
|
}
|
|
33586
|
+
//#endregion
|
|
33587
|
+
//#region src/stream-broker/recorded/clip-stream-http.ts
|
|
33588
|
+
/**
|
|
33589
|
+
* clip-stream-http — the `node:http` half of {@link ClipStreamOpener}.
|
|
33590
|
+
*
|
|
33591
|
+
* Dials the URL `videoclips.dialClipStream` handed back — plain HTTP on the
|
|
33592
|
+
* provider's own token-gated listener, never the hub's proxy (an addon holds
|
|
33593
|
+
* no user credential, and the hub proxy takes only those) — and turns the
|
|
33594
|
+
* response into what the feeder drives: `pause`/`resume`/`destroy` on the
|
|
33595
|
+
* socket and the three events.
|
|
33596
|
+
*
|
|
33597
|
+
* A status other than 200 is a REFUSAL, named by the provider's own headers
|
|
33598
|
+
* (`x-camstack-reason-code`, `x-camstack-reason`), never a stream of nothing.
|
|
33599
|
+
* A 200 that does not declare `x-camstack-clip-transport: forward-only` is
|
|
33600
|
+
* refused too: it means the URL is not the stream route, and playing a
|
|
33601
|
+
* progressive file through a forward-only feeder would look like it worked
|
|
33602
|
+
* until the first seek.
|
|
33603
|
+
*/
|
|
33604
|
+
/**
|
|
33605
|
+
* The codes a provider may add AFTER its body, and the only two this hop
|
|
33606
|
+
* carries onward.
|
|
33607
|
+
*
|
|
33608
|
+
* A stream's status line is spent on its first fragment, so a serve the camera
|
|
33609
|
+
* cut short has nothing left to say it with but a trailer — and a trailer this
|
|
33610
|
+
* reader did not know about would be read as a whole clip, which is the defect
|
|
33611
|
+
* D623 is about, one hop further out. Anything else a provider writes there is
|
|
33612
|
+
* ITS diagnostic and stays in ITS log: a viewer is shown what it can act on.
|
|
33613
|
+
*/
|
|
33614
|
+
var CARRIED_TRAILER_CODES = ["stream-short", "stream-empty"];
|
|
33615
|
+
function trailerText(trailers, name) {
|
|
33616
|
+
const value = trailers[name];
|
|
33617
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
33618
|
+
}
|
|
33619
|
+
/**
|
|
33620
|
+
* What the provider said about its own stream once the last byte was out.
|
|
33621
|
+
*
|
|
33622
|
+
* `null` for a stream that added nothing, which is a whole one — the absence
|
|
33623
|
+
* is the statement, and it is never read as "unknown". A code with no sentence
|
|
33624
|
+
* is still reported, with the code standing in for one: a consumer that
|
|
33625
|
+
* branches on the code must not lose the branch because the prose was dropped
|
|
33626
|
+
* by a proxy.
|
|
33627
|
+
*/
|
|
33628
|
+
function readClipStreamTrailers(trailers) {
|
|
33629
|
+
const code = trailerText(trailers, require_dist.CLIP_STILL_REASON_CODE_HEADER);
|
|
33630
|
+
if (code === void 0 || !CARRIED_TRAILER_CODES.includes(code)) return null;
|
|
33631
|
+
return {
|
|
33632
|
+
code: code === "stream-empty" ? "stream-empty" : "stream-short",
|
|
33633
|
+
reason: trailerText(trailers, "x-camstack-reason") ?? code
|
|
33634
|
+
};
|
|
33635
|
+
}
|
|
33636
|
+
/** The abort's reason, as a sentence the provider's log can be matched against. */
|
|
33637
|
+
function describeReason(reason) {
|
|
33638
|
+
if (reason instanceof Error) return reason.message;
|
|
33639
|
+
if (typeof reason === "string") return reason;
|
|
33640
|
+
return "the clip stream was abandoned before it opened";
|
|
33641
|
+
}
|
|
32486
33642
|
function headerText(res, name) {
|
|
32487
33643
|
const value = res.headers[name];
|
|
32488
33644
|
if (Array.isArray(value)) return value[0];
|
|
@@ -32490,14 +33646,26 @@ function headerText(res, name) {
|
|
|
32490
33646
|
}
|
|
32491
33647
|
function createHttpClipStreamOpener(options = {}) {
|
|
32492
33648
|
const headersTimeoutMs = options.headersTimeoutMs ?? 3e4;
|
|
32493
|
-
return (url, handlers) => new Promise((resolve) => {
|
|
33649
|
+
return (url, handlers, signal) => new Promise((resolve) => {
|
|
32494
33650
|
let settled = false;
|
|
33651
|
+
let onAbort = null;
|
|
32495
33652
|
const settle = (value) => {
|
|
32496
33653
|
if (settled) return;
|
|
32497
33654
|
settled = true;
|
|
32498
33655
|
clearTimeout(timer);
|
|
33656
|
+
if (onAbort !== null) signal.removeEventListener("abort", onAbort);
|
|
32499
33657
|
resolve(value);
|
|
32500
33658
|
};
|
|
33659
|
+
const abandoned = () => ({
|
|
33660
|
+
kind: "refused",
|
|
33661
|
+
status: 0,
|
|
33662
|
+
code: "abandoned",
|
|
33663
|
+
detail: describeReason(signal.reason)
|
|
33664
|
+
});
|
|
33665
|
+
if (signal.aborted) {
|
|
33666
|
+
resolve(abandoned());
|
|
33667
|
+
return;
|
|
33668
|
+
}
|
|
32501
33669
|
let target;
|
|
32502
33670
|
try {
|
|
32503
33671
|
target = new URL(url);
|
|
@@ -32526,7 +33694,16 @@ function createHttpClipStreamOpener(options = {}) {
|
|
|
32526
33694
|
const timer = setTimeout(() => {
|
|
32527
33695
|
req.destroy(/* @__PURE__ */ new Error(`no status line in ${headersTimeoutMs} ms`));
|
|
32528
33696
|
}, headersTimeoutMs);
|
|
33697
|
+
onAbort = () => {
|
|
33698
|
+
req.destroy(new Error(describeReason(signal.reason)));
|
|
33699
|
+
settle(abandoned());
|
|
33700
|
+
};
|
|
33701
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
32529
33702
|
req.on("error", (err) => {
|
|
33703
|
+
if (signal.aborted) {
|
|
33704
|
+
settle(abandoned());
|
|
33705
|
+
return;
|
|
33706
|
+
}
|
|
32530
33707
|
settle({
|
|
32531
33708
|
kind: "refused",
|
|
32532
33709
|
status: 0,
|
|
@@ -32557,7 +33734,7 @@ function createHttpClipStreamOpener(options = {}) {
|
|
|
32557
33734
|
return;
|
|
32558
33735
|
}
|
|
32559
33736
|
res.on("data", (chunk) => handlers.onData(chunk));
|
|
32560
|
-
res.on("end", () => handlers.onEnd());
|
|
33737
|
+
res.on("end", () => handlers.onEnd(readClipStreamTrailers(res.trailers)));
|
|
32561
33738
|
res.on("error", (err) => handlers.onError(err));
|
|
32562
33739
|
settle({
|
|
32563
33740
|
kind: "open",
|
|
@@ -33147,24 +34324,13 @@ function pickAdaptiveTier(input) {
|
|
|
33147
34324
|
*
|
|
33148
34325
|
* Flow: Camera → RTSP → Broker → EncodedPacket → RTP packetization → WebRTC
|
|
33149
34326
|
*/
|
|
33150
|
-
/**
|
|
33151
|
-
|
|
33152
|
-
|
|
33153
|
-
|
|
33154
|
-
|
|
33155
|
-
|
|
33156
|
-
|
|
33157
|
-
upgradeHealthyMs: 4e3,
|
|
33158
|
-
upgradeHealthyGrowthFactor: 3,
|
|
33159
|
-
maxProgressiveUpgradeHealthyMs: 3e4,
|
|
33160
|
-
minTimeBetweenChangesMs: 4e3,
|
|
33161
|
-
maxUpgradeBackoffMs: 9e5,
|
|
33162
|
-
stallKbpsFloor: 64,
|
|
33163
|
-
stallConfirmMs: 6e3,
|
|
33164
|
-
recoverKbpsFloor: 128,
|
|
33165
|
-
waitAfterChangeMs: 8e3,
|
|
33166
|
-
maxClientSampleAgeMs: 1e4
|
|
33167
|
-
};
|
|
34327
|
+
/**
|
|
34328
|
+
* A hold code that has been logged stays quiet this long before it may log
|
|
34329
|
+
* again (`HoldLogGate`). 60 000 ms: a hovering link changes code every 4–8 s.
|
|
34330
|
+
*/
|
|
34331
|
+
var HOLD_LOG_QUIET_MS = 6e4;
|
|
34332
|
+
/** A failing adaptive tick logs at most once per this long, per session. */
|
|
34333
|
+
var TICK_FAILURE_LOG_QUIET_MS = 6e4;
|
|
33168
34334
|
/**
|
|
33169
34335
|
* True when `broker` is an in-process StreamBroker exposing the Opus egress
|
|
33170
34336
|
* controls. Uses `in` narrowing + `typeof` checks (no cast) so the returned
|
|
@@ -33401,6 +34567,14 @@ var BrokerWebrtcServer = class {
|
|
|
33401
34567
|
*/
|
|
33402
34568
|
lastLadderDecision = /* @__PURE__ */ new Map();
|
|
33403
34569
|
/**
|
|
34570
|
+
* Try-climb memory per DEVICE (review I4): a new viewer session of a camera
|
|
34571
|
+
* whose next rung keeps failing inherits the backoff instead of starting at
|
|
34572
|
+
* try #1. Forgotten after the backoff cap without a refresh.
|
|
34573
|
+
*/
|
|
34574
|
+
deviceTryMemory = new DeviceTryMemory(ADAPTIVE_CONFIG.tryBackoffMaxMs);
|
|
34575
|
+
/** The newest viewer probe per device, from offers and telemetry (`probe-evidence.ts`). */
|
|
34576
|
+
deviceProbe = new DeviceProbeMemory(PROBE_MEMORY_TTL_MS);
|
|
34577
|
+
/**
|
|
33404
34578
|
* brokerId → where its media is ingested. Injected by the manager, which
|
|
33405
34579
|
* owns the ownership map; unset (or a null answer) means UNRESOLVED, and
|
|
33406
34580
|
* the overlay says so rather than naming the local node.
|
|
@@ -34398,7 +35572,8 @@ var BrokerWebrtcServer = class {
|
|
|
34398
35572
|
};
|
|
34399
35573
|
if (streamId.endsWith("/adaptive")) {
|
|
34400
35574
|
const initialTier = this.profileTierResolver?.(brokerId) ?? "high";
|
|
34401
|
-
this.
|
|
35575
|
+
this.recordDeviceProbe(deviceId, probeFromHints(hints.downlinkMbps, Date.now()));
|
|
35576
|
+
this.startAdaptive(entry, deviceId, initialTier, sessionId, intent);
|
|
34402
35577
|
}
|
|
34403
35578
|
return {
|
|
34404
35579
|
sessionId,
|
|
@@ -34516,11 +35691,13 @@ var BrokerWebrtcServer = class {
|
|
|
34516
35691
|
const ladder = controller !== void 0 ? {
|
|
34517
35692
|
lastDecision: decision,
|
|
34518
35693
|
failures: [...controller.failureCounts],
|
|
34519
|
-
upgradeCount: controller.successfulUpgradeCount
|
|
35694
|
+
upgradeCount: controller.successfulUpgradeCount,
|
|
35695
|
+
climbHold: controller.climbHold === null ? null : toWireClimbHold(controller.climbHold)
|
|
34520
35696
|
} : decision !== null ? {
|
|
34521
35697
|
lastDecision: decision,
|
|
34522
35698
|
failures: [],
|
|
34523
|
-
upgradeCount: 0
|
|
35699
|
+
upgradeCount: 0,
|
|
35700
|
+
climbHold: null
|
|
34524
35701
|
} : null;
|
|
34525
35702
|
let brokerStats = null;
|
|
34526
35703
|
try {
|
|
@@ -34563,7 +35740,7 @@ var BrokerWebrtcServer = class {
|
|
|
34563
35740
|
/** Snapshot the ladder's move so the overlay can name it later. */
|
|
34564
35741
|
recordLadderDecision(sessionId, decision, tier, forcedTranscodeDown) {
|
|
34565
35742
|
this.lastLadderDecision.set(sessionId, {
|
|
34566
|
-
|
|
35743
|
+
...wireLadderCause(decision, ADAPTIVE_CONFIG.stallConfirmMs),
|
|
34567
35744
|
tier,
|
|
34568
35745
|
forcedTranscodeDown,
|
|
34569
35746
|
lossRatio: decision.lossRatio,
|
|
@@ -34574,6 +35751,39 @@ var BrokerWebrtcServer = class {
|
|
|
34574
35751
|
});
|
|
34575
35752
|
}
|
|
34576
35753
|
/**
|
|
35754
|
+
* The IDR interval the broker feeding a session measured, or null when it
|
|
35755
|
+
* has measured none (a fresh broker reports 0). Sizes the delivery window.
|
|
35756
|
+
*/
|
|
35757
|
+
brokerIdrIntervalMs(brokerId) {
|
|
35758
|
+
try {
|
|
35759
|
+
const idr = this.brokers.get(brokerId)?.getStats().idrIntervalMs ?? null;
|
|
35760
|
+
return idr !== null && idr > 0 ? idr : null;
|
|
35761
|
+
} catch {
|
|
35762
|
+
return null;
|
|
35763
|
+
}
|
|
35764
|
+
}
|
|
35765
|
+
/**
|
|
35766
|
+
* The expected bitrate of `tier` for this device, from the broker serving
|
|
35767
|
+
* that rung — null when no such broker is registered here or it has
|
|
35768
|
+
* measured nothing. Unknown never vetoes a climb (D315/D393).
|
|
35769
|
+
*/
|
|
35770
|
+
expectedTierKbps(deviceId, tier) {
|
|
35771
|
+
const prefix = `${String(deviceId)}/`;
|
|
35772
|
+
const candidates = [];
|
|
35773
|
+
for (const [bid, broker] of this.brokers) if (bid.startsWith(prefix)) candidates.push({
|
|
35774
|
+
brokerId: bid,
|
|
35775
|
+
tier: this.profileTierResolver?.(bid) ?? null,
|
|
35776
|
+
broker
|
|
35777
|
+
});
|
|
35778
|
+
const match = preferLiveAdaptiveCandidates(candidates).find((c) => c.tier === tier);
|
|
35779
|
+
if (match === void 0) return null;
|
|
35780
|
+
try {
|
|
35781
|
+
return expectedRungKbps(match.broker.getStats());
|
|
35782
|
+
} catch {
|
|
35783
|
+
return null;
|
|
35784
|
+
}
|
|
35785
|
+
}
|
|
35786
|
+
/**
|
|
34577
35787
|
* Wire an {@link AdaptiveController} to a freshly-created adaptive session.
|
|
34578
35788
|
* The controller is fed loss from two sources — RTCP Receiver Reports
|
|
34579
35789
|
* (`onReceiverReport`) and, when injected, the device's client-reported
|
|
@@ -34590,12 +35800,20 @@ var BrokerWebrtcServer = class {
|
|
|
34590
35800
|
* whichever produces the higher loss ratio across its window. The async
|
|
34591
35801
|
* poll is guarded against overlapping ticks via `tickInFlight`.
|
|
34592
35802
|
*/
|
|
34593
|
-
startAdaptive(entry, deviceId, initialTier, sessionId,
|
|
35803
|
+
startAdaptive(entry, deviceId, initialTier, sessionId, intent) {
|
|
35804
|
+
const startedAt = Date.now();
|
|
35805
|
+
/** Rung bitrates, looked up at most once per tier per tick (review m12). */
|
|
35806
|
+
const rungKbps = /* @__PURE__ */ new Map();
|
|
34594
35807
|
const controller = new AdaptiveController({
|
|
34595
35808
|
initialTier,
|
|
34596
|
-
priorFailures,
|
|
34597
|
-
priorUpgradeCount,
|
|
35809
|
+
priorFailures: intent?.failures,
|
|
35810
|
+
priorUpgradeCount: intent?.upgradeCount,
|
|
35811
|
+
tryClimb: intent?.tryClimb ?? this.deviceTryMemory.get(deviceId, startedAt) ?? void 0,
|
|
34598
35812
|
config: ADAPTIVE_CONFIG,
|
|
35813
|
+
expectedTierKbps: (tier) => {
|
|
35814
|
+
if (!rungKbps.has(tier)) rungKbps.set(tier, this.expectedTierKbps(deviceId, tier));
|
|
35815
|
+
return rungKbps.get(tier) ?? null;
|
|
35816
|
+
},
|
|
34599
35817
|
onSwitchTier: (tier, decision) => {
|
|
34600
35818
|
this.recordLadderDecision(sessionId, decision, tier, false);
|
|
34601
35819
|
this.switchSessionTier(entry, deviceId, tier, sessionId, false, decision);
|
|
@@ -34612,6 +35830,9 @@ var BrokerWebrtcServer = class {
|
|
|
34612
35830
|
reason: decision.cause,
|
|
34613
35831
|
lossRatio: Number(decision.lossRatio.toFixed(4)),
|
|
34614
35832
|
clientKbps: decision.stall.clientKbps,
|
|
35833
|
+
sentKbps: decision.stall.sentKbps,
|
|
35834
|
+
deliveryRatio: decision.stall.deliveryRatio,
|
|
35835
|
+
deliveryWindowMs: decision.stall.deliveryWindowMs,
|
|
34615
35836
|
stalledForMs: decision.stall.stalledForMs,
|
|
34616
35837
|
serverSending: decision.stall.serverSending
|
|
34617
35838
|
}
|
|
@@ -34631,14 +35852,39 @@ var BrokerWebrtcServer = class {
|
|
|
34631
35852
|
* sending?" half of the stall verdict. */
|
|
34632
35853
|
let lastRtpSent = entry.session.rtpPacketsSentTotal;
|
|
34633
35854
|
const CLIENT_LOSS_SPAN = 1e3;
|
|
35855
|
+
const meter = new DeliveryMeter(ADAPTIVE_DELIVERY_CONFIG, deliveryMeterStart(startedAt, intent !== void 0, ADAPTIVE_CONFIG.waitAfterChangeMs));
|
|
35856
|
+
const holdLog = new HoldLogGate(HOLD_LOG_QUIET_MS);
|
|
35857
|
+
let lastTickFailureLogAt = Number.NEGATIVE_INFINITY;
|
|
35858
|
+
const logHoldChange = (now) => {
|
|
35859
|
+
const hold = controller.climbHold;
|
|
35860
|
+
if (hold === null || !holdLog.shouldLog(hold.code, now)) return;
|
|
35861
|
+
this.logger.info("adaptive: climb held", {
|
|
35862
|
+
tags: { deviceId },
|
|
35863
|
+
meta: {
|
|
35864
|
+
sessionId,
|
|
35865
|
+
deviceId,
|
|
35866
|
+
tier: controller.currentTier,
|
|
35867
|
+
nextTier: hold.nextTier,
|
|
35868
|
+
code: hold.code,
|
|
35869
|
+
summary: describeClimbHold(hold),
|
|
35870
|
+
deliveryRatio: hold.deliveryRatio,
|
|
35871
|
+
deliveryWindowMs: hold.deliveryWindowMs,
|
|
35872
|
+
capacityKbps: hold.capacityKbps,
|
|
35873
|
+
probeKbps: hold.probeKbps,
|
|
35874
|
+
nextTierKbps: hold.nextTierKbps
|
|
35875
|
+
}
|
|
35876
|
+
});
|
|
35877
|
+
};
|
|
34634
35878
|
entry.adaptiveTimer = setInterval(() => {
|
|
34635
35879
|
if (tickInFlight) return;
|
|
34636
35880
|
tickInFlight = true;
|
|
34637
35881
|
(async () => {
|
|
34638
35882
|
try {
|
|
35883
|
+
rungKbps.clear();
|
|
34639
35884
|
if (entry.timeline?.currentMode === "recorded") {
|
|
34640
35885
|
controller.suspend(Date.now());
|
|
34641
35886
|
lastRtpSent = entry.session.rtpPacketsSentTotal;
|
|
35887
|
+
logHoldChange(Date.now());
|
|
34642
35888
|
return;
|
|
34643
35889
|
}
|
|
34644
35890
|
const client = this.clientNetworkStats ? await this.clientNetworkStats(deviceId) : null;
|
|
@@ -34646,11 +35892,15 @@ var BrokerWebrtcServer = class {
|
|
|
34646
35892
|
const rtpSent = entry.session.rtpPacketsSentTotal;
|
|
34647
35893
|
const serverSending = rtpSent > lastRtpSent;
|
|
34648
35894
|
lastRtpSent = rtpSent;
|
|
34649
|
-
|
|
34650
|
-
|
|
34651
|
-
|
|
34652
|
-
|
|
34653
|
-
|
|
35895
|
+
this.feedAdaptive({
|
|
35896
|
+
entry,
|
|
35897
|
+
deviceId,
|
|
35898
|
+
controller,
|
|
35899
|
+
meter,
|
|
35900
|
+
client,
|
|
35901
|
+
serverSending,
|
|
35902
|
+
now
|
|
35903
|
+
});
|
|
34654
35904
|
const pct = client?.packetLossPercent ?? null;
|
|
34655
35905
|
if (pct !== null && pct !== void 0) {
|
|
34656
35906
|
clientHighestSequence += CLIENT_LOSS_SPAN;
|
|
@@ -34661,6 +35911,21 @@ var BrokerWebrtcServer = class {
|
|
|
34661
35911
|
}, now);
|
|
34662
35912
|
}
|
|
34663
35913
|
controller.tick(now);
|
|
35914
|
+
this.deviceTryMemory.set(deviceId, controller.tryClimbCarry, now);
|
|
35915
|
+
logHoldChange(now);
|
|
35916
|
+
} catch (err) {
|
|
35917
|
+
const now = Date.now();
|
|
35918
|
+
if (now - lastTickFailureLogAt >= TICK_FAILURE_LOG_QUIET_MS) {
|
|
35919
|
+
lastTickFailureLogAt = now;
|
|
35920
|
+
this.logger.warn("adaptive: tick failed", {
|
|
35921
|
+
tags: { deviceId },
|
|
35922
|
+
meta: {
|
|
35923
|
+
sessionId,
|
|
35924
|
+
deviceId,
|
|
35925
|
+
error: require_dist.errMsg(err)
|
|
35926
|
+
}
|
|
35927
|
+
});
|
|
35928
|
+
}
|
|
34664
35929
|
} finally {
|
|
34665
35930
|
tickInFlight = false;
|
|
34666
35931
|
}
|
|
@@ -34668,6 +35933,55 @@ var BrokerWebrtcServer = class {
|
|
|
34668
35933
|
}, ADAPTIVE_TICK_MS);
|
|
34669
35934
|
}
|
|
34670
35935
|
/**
|
|
35936
|
+
* One evaluation's transport, capacity and RTT evidence into the controller.
|
|
35937
|
+
* The device-scoped client record is used as this session's own only when
|
|
35938
|
+
* this is the device's ONLY adaptive session here (review I2).
|
|
35939
|
+
*/
|
|
35940
|
+
feedAdaptive(feed) {
|
|
35941
|
+
const { entry, deviceId, controller, meter, client, now } = feed;
|
|
35942
|
+
const sessionsOnDevice = this.adaptiveSessionCount(deviceId);
|
|
35943
|
+
const shared = sessionsOnDevice > 1;
|
|
35944
|
+
let rttMs = null;
|
|
35945
|
+
if (client) {
|
|
35946
|
+
const reportAgeMs = Math.max(0, now - client.lastUpdated);
|
|
35947
|
+
meter.observe(client.estimatedBandwidthKbps, client.lastUpdated);
|
|
35948
|
+
if (!shared) {
|
|
35949
|
+
this.recordDeviceProbe(deviceId, probeFromReport({
|
|
35950
|
+
downlinkMbps: client.downlinkMbps,
|
|
35951
|
+
downlinkAgeMs: client.downlinkAgeMs ?? 0,
|
|
35952
|
+
reportAgeMs
|
|
35953
|
+
}, now));
|
|
35954
|
+
if (reportAgeMs <= ADAPTIVE_DELIVERY_CONFIG.maxClientSampleAgeMs) rttMs = client.rttMs ?? null;
|
|
35955
|
+
}
|
|
35956
|
+
}
|
|
35957
|
+
const history = entry.session.sentVideoHistory;
|
|
35958
|
+
controller.onThroughput(buildStallSample({
|
|
35959
|
+
delivery: meter.evaluate(history, deliveryWindowMs(this.brokerIdrIntervalMs(entry.brokerId), ADAPTIVE_DELIVERY_CONFIG), now),
|
|
35960
|
+
recent: meter.evaluateNewest(history, now),
|
|
35961
|
+
clientReporting: meter.isReporting(now),
|
|
35962
|
+
serverSending: feed.serverSending,
|
|
35963
|
+
adaptiveSessionsOnDevice: sessionsOnDevice
|
|
35964
|
+
}), now);
|
|
35965
|
+
const probe = this.deviceProbe.get(deviceId, now);
|
|
35966
|
+
controller.onCapacity({
|
|
35967
|
+
rembKbps: entry.session.receiverEstimateKbps(now),
|
|
35968
|
+
probeKbps: probe?.kbps ?? null,
|
|
35969
|
+
probeFresh: isProbeFresh(probe, now)
|
|
35970
|
+
});
|
|
35971
|
+
controller.onRtt(rttMs, now);
|
|
35972
|
+
}
|
|
35973
|
+
/** Adaptive sessions of `deviceId` currently on this broker server. */
|
|
35974
|
+
adaptiveSessionCount(deviceId) {
|
|
35975
|
+
const prefix = `${String(deviceId)}/`;
|
|
35976
|
+
let count = 0;
|
|
35977
|
+
for (const s of this.sessions.values()) if (s.adaptiveController !== void 0 && s.brokerId.startsWith(prefix)) count += 1;
|
|
35978
|
+
return count;
|
|
35979
|
+
}
|
|
35980
|
+
/** Keep the newest probe per device; an absent one changes nothing. */
|
|
35981
|
+
recordDeviceProbe(deviceId, probe) {
|
|
35982
|
+
this.deviceProbe.record(deviceId, probe);
|
|
35983
|
+
}
|
|
35984
|
+
/**
|
|
34671
35985
|
* Schedule an adaptive tier switch via a close-and-recreate re-offer.
|
|
34672
35986
|
*
|
|
34673
35987
|
* Records the controller's desired tier (+ forced transcode-down) in
|
|
@@ -34684,11 +35998,18 @@ var BrokerWebrtcServer = class {
|
|
|
34684
35998
|
const prev = this.adaptiveIntent.get(sessionId);
|
|
34685
35999
|
const failures = new Map(entry.adaptiveController?.failureCounts ?? prev?.failures);
|
|
34686
36000
|
const upgradeCount = entry.adaptiveController?.successfulUpgradeCount ?? prev?.upgradeCount ?? 0;
|
|
36001
|
+
const tryClimb = entry.adaptiveController?.tryClimbCarry ?? prev?.tryClimb;
|
|
34687
36002
|
this.adaptiveIntent.set(sessionId, {
|
|
34688
36003
|
prefersTier: tier,
|
|
34689
36004
|
forceTranscodeDown: forceTranscodeDown || (prev?.forceTranscodeDown ?? false),
|
|
34690
36005
|
failures,
|
|
34691
|
-
upgradeCount
|
|
36006
|
+
upgradeCount,
|
|
36007
|
+
tryClimb: tryClimb ?? {
|
|
36008
|
+
failures: 0,
|
|
36009
|
+
notBefore: 0,
|
|
36010
|
+
lastRevert: null,
|
|
36011
|
+
trialFrom: null
|
|
36012
|
+
}
|
|
34692
36013
|
});
|
|
34693
36014
|
const epoch = (entry.session.pendingRenegotiation?.epoch ?? 0) + 1;
|
|
34694
36015
|
entry.session.pendingRenegotiation = {
|
|
@@ -34706,8 +36027,12 @@ var BrokerWebrtcServer = class {
|
|
|
34706
36027
|
failures: Object.fromEntries(failures),
|
|
34707
36028
|
upgradeCount,
|
|
34708
36029
|
reason: decision?.cause ?? "unknown",
|
|
36030
|
+
trial: decision?.trial ?? false,
|
|
34709
36031
|
lossRatio: decision ? Number(decision.lossRatio.toFixed(4)) : null,
|
|
34710
36032
|
clientKbps: decision?.stall.clientKbps ?? null,
|
|
36033
|
+
sentKbps: decision?.stall.sentKbps ?? null,
|
|
36034
|
+
deliveryRatio: decision?.stall.deliveryRatio ?? null,
|
|
36035
|
+
deliveryWindowMs: decision?.stall.deliveryWindowMs ?? null,
|
|
34711
36036
|
stalledForMs: decision?.stall.stalledForMs ?? null,
|
|
34712
36037
|
serverSending: decision?.stall.serverSending ?? null
|
|
34713
36038
|
}
|
|
@@ -34724,6 +36049,10 @@ var BrokerWebrtcServer = class {
|
|
|
34724
36049
|
}
|
|
34725
36050
|
if (!brokerStillServed && !this.stopped) this.brokerLinger.hold(entry.brokerId);
|
|
34726
36051
|
this.adaptiveIntent.delete(sessionId);
|
|
36052
|
+
if (entry.adaptiveController !== void 0) {
|
|
36053
|
+
const deviceId = deviceIdFromBrokerId(entry.brokerId);
|
|
36054
|
+
if (this.adaptiveSessionCount(deviceId) === 0) this.deviceProbe.forget(deviceId);
|
|
36055
|
+
}
|
|
34727
36056
|
this.pruneLadderDecisions();
|
|
34728
36057
|
if (entry.adaptiveTimer) clearInterval(entry.adaptiveTimer);
|
|
34729
36058
|
if (entry.attributionTimer) clearTimeout(entry.attributionTimer);
|
|
@@ -35884,7 +37213,10 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
35884
37213
|
return {
|
|
35885
37214
|
packetLossPercent: client.packetLossPercent,
|
|
35886
37215
|
estimatedBandwidthKbps: client.estimatedBandwidthKbps,
|
|
35887
|
-
lastUpdated: client.lastUpdated
|
|
37216
|
+
lastUpdated: client.lastUpdated,
|
|
37217
|
+
rttMs: client.rttMs,
|
|
37218
|
+
...client.downlinkMbps !== void 0 ? { downlinkMbps: client.downlinkMbps } : {},
|
|
37219
|
+
...client.downlinkAgeMs !== void 0 ? { downlinkAgeMs: client.downlinkAgeMs } : {}
|
|
35888
37220
|
};
|
|
35889
37221
|
} catch {
|
|
35890
37222
|
return null;
|
|
@@ -35892,8 +37224,8 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
35892
37224
|
clearTimeout(timer);
|
|
35893
37225
|
}
|
|
35894
37226
|
},
|
|
35895
|
-
locateRecordedSegment: (args) => this.ctx.api.
|
|
35896
|
-
readRecordedSegmentBytes: async (args) => (await this.ctx.api.
|
|
37227
|
+
locateRecordedSegment: (args) => this.ctx.api.recordingArchive.locateSegment.query(args),
|
|
37228
|
+
readRecordedSegmentBytes: async (args) => (await this.ctx.api.recordingArchive.readSegmentBytes.query(args)).data,
|
|
35897
37229
|
readClipBytes: async (args) => {
|
|
35898
37230
|
const res = await this.ctx.api.videoclips.readClipBytes.query({
|
|
35899
37231
|
deviceId: args.deviceId,
|
|
@@ -35926,7 +37258,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
35926
37258
|
},
|
|
35927
37259
|
readRecordedGopBytes: async (args) => {
|
|
35928
37260
|
try {
|
|
35929
|
-
return await this.ctx.api.
|
|
37261
|
+
return await this.ctx.api.recordingArchive.readGopBytes.query({
|
|
35930
37262
|
deviceId: args.deviceId,
|
|
35931
37263
|
profile: args.profile,
|
|
35932
37264
|
startMs: args.startMs,
|
|
@@ -35941,7 +37273,7 @@ var StreamBrokerAddon = class extends require_dist.BaseAddon {
|
|
|
35941
37273
|
});
|
|
35942
37274
|
}
|
|
35943
37275
|
return {
|
|
35944
|
-
data: (await this.ctx.api.
|
|
37276
|
+
data: (await this.ctx.api.recordingArchive.readSegmentBytes.query({
|
|
35945
37277
|
deviceId: args.deviceId,
|
|
35946
37278
|
profile: args.profile,
|
|
35947
37279
|
startMs: args.startMs
|